perf(shapes): drop SipHash from ids_by_facts, the last one on the shape path - #8884
perf(shapes): drop SipHash from ids_by_facts, the last one on the shape path#8884proggeramlug wants to merge 2 commits into
Conversation
…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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe shape facts reverse index switches from SipHash to ChangesShape facts hashing
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to 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)
✅ Passed checks (4 passed)
Full details: Description checkExplanation 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 CoverageExplanation 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.)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
* 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>
|
Landed on |
Dropped SipHash from
ids_by_facts, the last shape-table map still using it.Profiling
claude -pputRandomState::hash_oneat 17 self-samples insideshapes::alone (57 across the process) — pure hashing overhead on a lookupthat 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, whosewrite_*methods OVERWRITE theaccumulator — 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 onlywrite, so thederived
Hash'swrite_u32/write_u64calls all forward there and FOLD withFNV-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, whereit fails with "changing
keysalone must change the hash".Found while re-profiling
claude -pafter #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
Bug Fixes
Tests