Skip to content

perf(shapes): drop SipHash from ids_by_facts, the last one on the shape path - #8884

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf-shape-facts-hasher
Closed

perf(shapes): drop SipHash from ids_by_facts, the last one on the shape path#8884
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf-shape-facts-hasher

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Dropped SipHash from ids_by_facts, the last shape-table map still using it.

Profiling claude -p put RandomState::hash_one at 17 self-samples inside
shapes:: alone (57 across the process) — pure hashing overhead on a lookup
that runs on every descriptor install and retire.

Its sibling maps already moved off SipHash (#8125). The standing comment on
this one argued only against PtrHasher, whose write_* methods OVERWRITE the
accumulator — right for a single-word key, wrong for this five-field one, which
would collapse to its last field and collide every descriptor sharing it. That
objection does not apply to FastKeyHasher: it implements only write, so the
derived Hash's write_u32 / write_u64 calls all forward there and FOLD with
FNV-1a, reaching every field.

The key is internal shape state, never program input, so DoS-resistant hashing
buys nothing — the same rationale already applied to the descriptor side tables.

The new test pins the folding property directly: vary one field at a time and
require a distinct hash each time. Sabotage-checked against PtrHasher, where
it fails with "changing keys alone must change the hash".


Found while re-profiling claude -p after #8875. That fix removed the dominant descriptor scan, and the profile is now flat — no single hotspot, biggest self-time entry 69 samples — so what is left is a set of small, individually-cheap wins. This is one of them.

Suite: 2717 passed, 0 failed.

Scope note: I have not measured this one end-to-end on the bundle in isolation; a bundle rebuild is ~60 min and the change is worth less than measurement noise on its own. It is justified by the profile plus the fact that it makes this map consistent with every sibling, not by a wall-clock delta. Happy to fold it into a batch measurement if you would rather see numbers first.

Summary by CodeRabbit

  • Performance

    • Improved internal shape-state indexing with faster hashing, supporting more efficient runtime lookups.
  • Bug Fixes

    • Ensured all shape metadata fields contribute to hash values, reducing avoidable collisions.
  • Tests

    • Added coverage confirming distinct shape metadata produces distinct, deterministic hashes.

Ralph Küpper added 2 commits August 27, 2026 16:25
…pe path

ids_by_facts was the only shape-table map still on std's RandomState.
Profiling `claude -p` showed RandomState::hash_one at 17 self-samples
inside shapes:: alone (57 across the process) — pure hashing overhead on
a lookup that runs on every descriptor install and retire.

Its sibling maps already moved off SipHash (PerryTS#8125). The standing comment
argued only against PtrHasher, whose write_* methods OVERWRITE the
accumulator — correct for a single-word key, and wrong for this
five-field one, which would collapse to its last field. That objection
does not apply to FastKeyHasher: it implements only `write`, so the
derived Hash's write_u32/write_u64 calls all forward there and FOLD with
FNV-1a, reaching every field.

The key is internal shape state, never program input, so DoS-resistant
hashing buys nothing — the same rationale already applied to the
descriptor side tables.

Test pins the folding property by varying one field at a time and
requiring a distinct hash. Sabotage-checked against PtrHasher: it fails
with 'changing keys alone must change the hash'. Suite 2717 passed.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d805beb-e2cc-4691-a23b-19091620e57c

📥 Commits

Reviewing files that changed from the base of the PR and between 245fe81 and 2c06e3f.

📒 Files selected for processing (3)
  • changelog.d/8882-shape-facts-hasher.md
  • crates/perry-runtime/src/object/shapes.rs
  • crates/perry-runtime/src/object/shapes_tests.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The shape facts reverse index switches from SipHash to FastKeyHasher. The shape table initialization matches the new map type. A test verifies that each ShapeFacts field affects the hash and that equal facts hash deterministically.

Changes

Shape facts hashing

Layer / File(s) Summary
FastKeyHash reverse index
crates/perry-runtime/src/object/shapes.rs, crates/perry-runtime/src/object/shapes_tests.rs, changelog.d/8882-shape-facts-hasher.md
ids_by_facts now uses FastKeyHashMap and new_fast_key_hash_map(). The test verifies folding across all five ShapeFacts fields and deterministic hashing. The changelog documents the change.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 2c06e

This localized performance change updates an internal shape index while preserving its keys, values, and role; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: removing SipHash from the final shape-path map.
Description check ✅ Passed The description is substantially complete and directly covers the change, rationale, implementation details, test coverage, profiling context, and measurement scope. It does not use all template headi…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is substantially complete and directly covers the change, rationale, implementation details, test coverage, profiling context, and measurement scope. It does not use all template headings, and it omits an explicit related-issue entry and checklist confirmations, but these omissions do not prevent understanding the pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

proggeramlug added a commit that referenced this pull request Aug 27, 2026
* perf: cache owning Uint32Array admissions

* perf: fast-path Array subclass length misses

* perf(array): accumulated ECS optimization work through v74

Accumulated codex ECS campaign work (v40–v74) on top of the two prior
commits on this branch: Array-subclass dense-tail fast paths and
validated-object prototype-override reads (v72), pre-statepoint inlining of
compact exact-receiver ($pshape) guarded specializations using the lowered
LLVM IR size (v74), plus the supporting collectors/tests. Details, rejected
experiments and measurements are in
secret-tests/ECS_PERFORMANCE_HANDOFF_2026-08-27.md.

Mac mini (taskpolicy -t 0 -l 0, 11 alternating pairs) at v74:
wolf-ecs add/remove 0.5562 ms/op, entity-cycle 0.4988 ms/op
(Node 26.5.1: 0.1337 / 0.1492).

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(codegen): follow one growth-forwarding edge in the guarded array store

`this.vals[i] = v` has no writeback slot: once the array grows past its
initial capacity the object field keeps the pre-grow forwarding stub, and
the guarded property-receiver STORE tier rejected the stub on every later
store (`!GC_FLAG_FORWARDED`), sending the whole store out of line through
the extend helper and the allocator/registry resolver. The READ tier already
followed one edge inline; mirror it: `deref` selects the stub's forwarding
word (heap-band checked), a new `deref.live` block re-validates the
destination header, and the fast arm stores into the live head.

wolf-ecs (Mac mini, 11 pairs): add/remove -9.15% (11/11),
entity-cycle -13.67% (11/11). Test:
index_set_barrier_tests::the_guarded_property_receiver_store_follows_one_forwarding_edge_inline

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(array): gate the raw-f64 downgrade note inline; typed-array pre-dispatch

- index_set_guarded.rs: the fast arm only calls js_array_note_numeric_write
  when the live head's `_reserved` word (already loaded by `deref.live`)
  has a raw-f64 bit set; the note is exactly "clear those bits if the value
  is not a Number" and was re-resolving the receiver through the tracked
  resolver on every pointer store.
- header.rs: js_array_note_numeric_write returns early for Number values and
  for already-clear live headers before paying clean_arr_ptr.
- indexing.rs: js_array_get_f64 dispatches a GC_TYPE_TYPED_ARRAY-tagged,
  registered receiver to js_typed_array_get before clean_arr_ptr (a
  guaranteed tracked miss for a typed array).

wolf-ecs (Mac mini, 11 pairs): add/remove -4.86% (11/11),
entity-cycle -5.50% (11/11).

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(codegen): exact inline typeof-number compare; header-branded typed-array reads

- compare.rs: `typeof local === "number"` / `!==` decides the
  definitely-Number cases inline (top 16 bits outside 0x7FF9..=0x7FFF, not the
  untagged raw typed-array pointer shape, outside the Web Streams id band) and
  keeps js_value_typeof_tag on the slow arm, so the two routes can never
  disagree. A 33-kind differential probe matches Node byte-for-byte.
- index_get/inline_dyn_typed_array.rs: the inline dynamic typed-array read
  brands the receiver off its GC_TYPE_TYPED_ARRAY header and reads the element
  kind from the TypedArrayHeader instead of probing the 64-slot direct-mapped
  PERRY_TA_KIND_CACHE, which every ordinary-array registry miss also writes
  negative entries into (hot typed arrays kept being evicted and missed the
  tier). PERRY_TA_VIEW_GUARD still gates the whole tier.

wolf-ecs (Mac mini, 11 pairs): add/remove -1.28% (11/11),
entity-cycle -0.73% (11/11).

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(array): route object receivers to the subclass fast read before clean_arr_ptr

An ordinary-object receiver (the object-backed `class X extends Array`
instance behind wolf-ecs' `packed[sparse[x]]`) can never be an ArrayHeader,
so clean_arr_ptr's tracked-allocation resolver was a guaranteed miss on every
js_array_get_f64 call for it. Ask array_subclass_fast_index_get_raw first when
the header tag already read for the Map/Set probes says GC_TYPE_OBJECT; every
rejected case still reaches the complete resolver and spec-generic Get.

wolf-ecs (Mac mini, 11 pairs): add/remove -2.03% (11/11),
entity-cycle -2.39% (11/11). Cumulative vs v74: -16.5% / -20.9%
(0.4645 / 0.3944 ms/op).

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(codegen): give integer-valued dynamic keys the inline numeric read tiers

A declared-array receiver read with an `Any`-typed key (`packed[sparse[x]]`
in the wolf-ecs SparseSet, `a[b[i]]` in general) always took the out-of-line
`js_array_get_index_or_string` route because the key carried no integer
array-index proof. Test the key inline — nonnegative, below 2^32, and equal
to its own fptosi/sitofp round trip — and on a hit take exactly the tiers a
statically proven index takes: the inline typed-array read, the dense
Array-subclass `arrlike.ic` shape cache, then the complete
`js_packed_arraylike_index_get` → `js_dyn_index_get` dispatcher. Fractional,
negative, NaN and out-of-range keys keep the previous route.

wolf-ecs (Mac mini, 11 pairs): add/remove -2.37% (11/11),
entity-cycle -2.89% (11/11); the js_array_get_index_or_string →
js_array_get_f64 → array_subclass_fast_index_get_raw chain (4.4% of the
add/remove profile) is gone. Cumulative vs v74: -18.5% / -23.1%
(0.4531 / 0.3836 ms/op). Test:
index_get_claim_tests::any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* test(codegen): update the proven-number strict-eq rooting test to the inline lowering

`x === {…}` with a proven-Number left operand now lowers to an inline
`fcmp oeq` (every non-Number NaN-box reads as a NaN double, so the object
compares unequal exactly as `js_eq` answered), leaving no `js_eq` call for
the test to find. Keep the test's actual claim — the non-pointer left
operand stays in the register produced above the right operand's
allocation instead of being rooted/re-read — on the fcmp operands, and pin
that no runtime equality call remains.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* ci: ratchet baselines for the file splits, census gate for cache carriers, changelog fragment

- addr-class ratchet/allowlist and raw-handle debt ceilings: the sites that
  the 2,000-line split moved from `array/indexing.rs` into
  `array/indexing_keyed.rs` keep their existing justification under the new
  path (indexing 4→3 / 13→7, indexing_keyed 1 / 6); lower the stale
  `field_set_by_name/fast_paths.rs` handle-floor count 3→2.
- shape-descriptor census: refresh the exact call-site multiset for the
  moved `property_get/composed_ics.rs` sites and the new
  `stmt/cached_field_index_return.rs` / `generic_dispatch.rs` header-size
  reads, and pin the scanner's rooting gate as
  `descriptor.old_carrier || descriptor.cache_carrier` — a runtime
  optimization cache that can reinstall a historical shape is a strong
  metadata owner a minor cannot enumerate (see `ShapeDescriptor::
  cache_carrier`), so its keys array must be rooted and rewritten before
  weak pruning. The sabotage self-test is updated to the new gate.
- changelog.d/8876 fragment.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(runtime): array-read fallback serves object-backed Array subclasses densely

After merging main (#8878 / #8872's canonical-i32 read split), a declared-array
receiver with a non-static key takes the guarded plain-array tier first. On an
object-backed `class X extends Array` receiver (wolf-ecs `Archetype`,
`packed[sparse[x]]` in SparseSet.has/remove) that guard always misses and
`js_typed_feedback_array_index_get_fallback_boxed`'s GC_TYPE_OBJECT arm
stringified every index into a by-name lookup (from_utf8 + string alloc +
reflection ladder per read): both wolf-ecs benchmarks regressed ~2.2x.

The fallback now asks `array_subclass_fast_index_get` for a canonical
(plain or INT32-boxed) non-negative index before its registry probes and the
by-name path; receivers without a dense proof keep the established route.

Mac mini 11-pair screen vs the pre-merge build: add/remove +0.5%, entity-cycle
-1.2% (from +126% / +121%); semantics probe byte-identical to Node.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* runtime(array): keyed index paths reload the receiver via across_* (raw-handle debt -6)

The file split moved six bare `get_raw_{mut,const}_ptr` reads into
`indexing_keyed.rs`, which the raw-handle ratchet rejects as a module that was
not listed at the merge base. Every site had the sanctioned shape already —
root the receiver, run the allocating stringify / symbol store, reload — so
they now use `across_const` / `across_mut`. `indexing_keyed.rs` needs no
ceiling; the baseline ratchets 970 -> 964.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* review: keep the fused u31 push non-reentrant, bound ECS columns, lifecycle the cache-carrier gate

Review follow-ups on #8876:

- `js_array_push_u31_with_length` stays allocate-but-never-reenter: it now
  answers null for receivers whose push can run user code (indexed
  descriptors / prototype indices, Proxy traps, foreign families) instead of
  calling the spec / public push itself; the generated caller takes the
  complete guarded push (`js_array_push_guard` + `js_array_push_f64`) in a
  new `apush.u31.generic` block. Test: the fused-push runtime test declines a
  typed-array receiver; the composed-clone IR test pins the hot path / fallback
  split.
- `js_packed_ecs_u32_loop_guard` declines admission when a component column
  is shorter than the admitted bound the receiver guard published (`out[6]`),
  so the fused loop cannot read past a column's payload. Test added.
- `object_hot_for_owner` validates the cached table pointer against the
  current thread's `RuntimeState` before reuse.
- `cache_carrier` gets a lifecycle: noted only after an entry naming the pair
  was inserted, and recomputed from live table occupancy after every full
  trace (`recompute_cache_carriers_after_full_trace`, called beside the
  old-carrier rotation) so a descriptor whose entries were evicted stops being
  rooted. Test: carrier bits follow live occupancy across a recompute.
- `js_object_get_symbol_then_field_ic_miss` is declared with the runtime's
  pointer parameter type.
- Minor: parenthesized mixed `&&`/`||` assertion, unique test class ids,
  `function_this_safe` visited-key includes the terminal-`this` allowance,
  exhaustive `UnaryOp` match, changelog fragment restated as shipped behavior.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ

* perf(shapes): drop SipHash from ids_by_facts, the last one on the shape path

ids_by_facts was the only shape-table map still on std's RandomState.
Profiling `claude -p` showed RandomState::hash_one at 17 self-samples
inside shapes:: alone (57 across the process) — pure hashing overhead on
a lookup that runs on every descriptor install and retire.

Its sibling maps already moved off SipHash (#8125). The standing comment
argued only against PtrHasher, whose write_* methods OVERWRITE the
accumulator — correct for a single-word key, and wrong for this
five-field one, which would collapse to its last field. That objection
does not apply to FastKeyHasher: it implements only `write`, so the
derived Hash's write_u32/write_u64 calls all forward there and FOLD with
FNV-1a, reaching every field.

The key is internal shape state, never program input, so DoS-resistant
hashing buys nothing — the same rationale already applied to the
descriptor side tables.

Test pins the folding property by varying one field at a time and
requiring a distinct hash. Sabotage-checked against PtrHasher: it fails
with 'changing keys alone must change the hash'. Suite 2717 passed.

* changelog: add fragment for the ids_by_facts hasher change

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via the #8887 batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant