perf(codegen): store reference values inline in the dynamic-key write IC (#8108) - #8183
Conversation
… IC (#8108) `lower_put_value_dyn_ic_inline`'s entry predicate ANDed in "the value tag is not pointer/string/bigint", so every reference-valued `o[k] = v` left the inline path before the receiver guards and took `js_put_value_set_dyn_ic` — one cross-crate call per write that re-validated, in Rust, exactly the guards the inline block had already proved. The tag now SELECTS a store arm. `put.dynic.store.scalar` keeps the pre-existing bare store and its `GC_STORE_AUDIT(POINTER_FREE)` claim unchanged; `put.dynic.store.ref` runs `emit_jsvalue_slot_store_scalar_aware_on_block` — byte-for-byte the static write PIC's pointer-capable store, reached under strictly stronger conditions, since the guards above it are that PIC's guards and this block additionally knows the value carries a reference tag. The one thing the outlined helper does that the inline arm does not, `canonicalize_typed_slot_store_bits`, is provably a no-op here: it returns early for every tag except `INT32_TAG`, which the reference arm excludes. No new rooting obligation. The target is materialised below every operand that can collect (the call site's existing evaluation-order argument), and all three bookkeeping helpers are `gc-leaf-function`, so nothing between the re-read and the store is a collection point. Measured best-of-5 on the quiet mini, release build, per-arm PERRY_RUNTIME_DIR and PERRY_CACHE_DIR, output verified against Node 26.5.1: o.x = { value: r + i } x4.8M 4.243G -> 2.860G instr (-32.6%) 690.8M -> 491.0M cycles (-28.9%) 212 -> 150 ms (-29.2%) 33248 -> 33200 KB peak RSS 7.85x -> 5.56x vs node o.x = produce(r, i) x12M 5.362G -> 5.337G instr (-0.45%) o.x = pointer x9.6M 2.2649G -> 2.2649G instr binary size 13,544,480 bytes both arms IPC moves 6.14 -> 5.83 on the improved cell, so the cycle win is smaller than the instruction win; both are reported. Arm B lands within 2.8% of the static-PIC ceiling for the same shape (2.860G vs 2.782G). This is #8108's measured prize reached by a different route, and the issue's own framing does not survive measurement on a3118cf: * `rhs_pointer` (9.21x) is ALREADY on the static write PIC. Its RHS is an `Expr::LocalGet`, which `put_value_rhs_is_safepoint_free` has always admitted. The safepoint gate never rejected it. * `rhs_call` (18.28x) would REGRESS. `const v = f(); o.x = v` is exactly the IR slice A would produce and it costs +21.4% instructions (5.362G -> 6.512G): the static PIC's hit block emits three unconditional `gc-leaf` bookkeeping calls whenever the value is not statically provable non-pointer, where the dyn IC proves it at runtime and stores bare. 95% of that cell is the closure call (425 of 447 instructions per iteration). * `rhs_allocating` is the real prize, and it needs no change to any safepoint rule. So `expr/proxy_reflect.rs`'s safepoint gate is left in place.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughDynamic-key write ICs now inline stores for reference-tagged values with layout-aware write barriers. Scalar stores retain their direct path, and unsupported cases retain the semantic fallback. Native IR proofs and runtime coverage validate the new paths. ChangesDynamic-key reference store support
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: ✨ Finishing Touches📝 Generate docstrings
🧪 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 |
…ive test (#8184), and make a deleted barrier detectable (#8185) (#8189) * perf(codegen): put the static write PIC's GC bookkeeping behind one live test The `put.pic.hit` block emitted three unconditional `gc-leaf` calls (`js_string_addref_if_heap_string`, `js_gc_note_slot_layout_aware`, `js_write_barrier_slot`) whenever the stored value was not provably a non-pointer at compile time — which is every `o.x = v` whose RHS is an untyped local. Route that arm through `emit_jsvalue_slot_store_pointer_tested` (#7511), already shipped on the class-field store path, which asks the same question ONCE inline of the bits being stored and branches over all three. `emit_jsvalue_slot_store_pointer_tested` gains a `stem` parameter so the blocks it emits are named per call site; the PIC passes "put.pic" so an IR assertion about this site cannot be satisfied by a class-field store elsewhere in the same module. Also (#8185): document the write-barrier invariant as the mirror image of the rooting invariant — for a deleted barrier the runtime instruments are the ones that cannot see it and a static IR assertion is the only detector — and move #8183's barrier assertions out of `tests/` (nightly-only) into a `--lib` unit test that per-PR `cargo-test` actually runs. Refs #8184, #8185, #6812, #7511, #8183. * fix(gc): stop citing PERRY_GC_ZEAL, which no longer has a live parser `gc/pin.rs`'s young-pin incoherence report PRINTS a reproduce command whose first variable nothing in the tree reads, and `dyn_eval`'s rooting-domain note names it as a live instrument. Neither is a knob any more; both now name `PERRY_GC_SCHEDULE_RATE`, which `scripts/check_gc_env_knobs.py` confirms a parser owns. A diagnostic that hands the reader a dead variable sends them to run the DEFAULT configuration and read its green as a result — the same 'the gate ran but its subject never did' shape this PR is about, one level up. Refs #8185. * docs(changelog): #8189 fragment --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…ure, not comments (#8185) (#8206) * gc: make the store-site inventory verify its claims, not its comments (#8185) A GC_STORE_AUDIT marker was a trusted comment: deleting a write barrier while leaving its BARRIERED marker in place was a clean pass, and #8183 showed a release build with exactly that defect passes every runtime probe byte-identically. The inventory now binds each claim class to evidence: - BARRIERED in perry-codegen: every stem-labelled barrier emitter call site must carry a literal stem, and the stem set must equal VERIFIED_BARRIER_STEMS in the new per-PR lib test crates/perry-codegen/src/expr/barrier_stem_census_tests.rs, which compiles a probe per stem and asserts - for EVERY instance of the gate in the emitted IR - a cond_br into <stem>.barrier.<n>, the js_write_barrier_slot call inside that block, and the branch predicate walked by def-chain (br i1 true with the predicate left dead fails). Four IR-surgery sabotages run in the suite against every stem. A BARRIERED marker in a codegen file not bound to a census stem fails lint. New witness: idxset.inbounds had no IR test at all. - BARRIERED/EXTERNAL_BARRIERED in perry-runtime/perry-stdlib: verified against source structure - a barrier primitive or chain-verified discharge helper call between the marker and the end of its enclosing function; deleting the barrier inside a helper reddens every marker leaning on it. - ROOT/INIT/POINTER_FREE/STACK: still human-audited, now declared UNVERIFIED in the summary on every run instead of silently trusted. Rot exits 2 (gc_rekeyed_key_tables.py discipline): missing/empty registry, dark witness module, scan floors. --self-test plants fifteen shapes; each must be adjudicated. The scanner also gains the slot_ptr/ root_slot dest hints so deleting the shared emitters' markers is visible. Closes #8185. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: changelog fragment for #8206 Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
What
lower_put_value_dyn_ic_inline's entry predicate ANDed in "the value tag is notpointer/string/bigint", so every reference-valued
o[k] = vleft the inline pathbefore the receiver guards and took
js_put_value_set_dyn_ic— one cross-cratecall per write that re-validated, in Rust, exactly the guards the inline block had
already proved.
The tag now selects a store arm instead of gating entry:
put.dynic.store.scalar— the pre-existing bare store,GC_STORE_AUDIT(POINTER_FREE)claim unchanged, IR identical to before for non-reference values;
put.dynic.store.ref—emit_jsvalue_slot_store_scalar_aware_on_block, i.e.byte-for-byte the static write PIC's pointer-capable store, reached under
strictly stronger conditions: the guards above it are that PIC's guards, and this
block additionally knows the value carries a reference tag.
The one thing the outlined helper does that the inline arm does not —
canonicalize_typed_slot_store_bits— is provably a no-op here: it returns early forevery tag except
INT32_TAG, which the reference arm excludes.No new rooting obligation. The target is materialised below every operand that can
collect (the call site's existing evaluation-order argument), and all three bookkeeping
helpers are
gc-leaf-function, so nothing between the re-read and the store is acollection point.
scripts/gc_root_dominance_check.pyoverscripts/gc_root_dominance_corpus.sh: 0 violations, 0 unrooted allocas, emptyallowlist, 40/40 seeded violations caught, both modes.
Measurement
Best-of-5, release build, one
CARGO_TARGET_DIR/PERRY_RUNTIME_DIR/PERRY_CACHE_DIRper arm,PERRY_NO_AUTO_OPTIMIZE=1,(writes, sink)verifiedagainst Node 26.5.1. Wall/cycles from the quiet mini (
perry-macos, load ~1.5);instructions agreed with the dev box to within 0.4%. Base
a3118cfea.o.x = { value: r+i }×4.8Mo.x = produce(r, i)×12Mo.x = pointer×9.6M (static PIC)vs Node on the improved shape: 7.85x → 5.56x (node 27 ms). Arm B lands within
2.8% of the static-PIC ceiling for the same shape (2.860G vs 2.782G), so the write
path is close to spent — the residual 5.4x is allocation and object model, not the
store.
IPC drops 6.14 → 5.83 on the improved cell, so the cycle win is smaller than the
instruction win; both are reported (#8175). Binary size is unchanged:
13,544,480 bytes in both arms.
This is #8108's prize, reached by a different route — and its framing does not survive measurement
#8108 names three cells and proposes admitting a safepointing RHS into the static
write PIC. Measured on
a3118cfeabefore writing any code:rhs_pointer(9.21x) is already on the static PIC. Its RHS is anExpr::LocalGet, whichput_value_rhs_is_safepoint_freehas always admitted. Thesafepoint gate never rejected it; that cell's cost is the pointer store path.
rhs_call(18.28x) would REGRESS.const v = f(); o.x = vis exactly the IRslice A would produce, and it costs +21.4% instructions (5.362G → 6.512G, +18%
wall): the static PIC's hit block emits three unconditional
gc-leafbookkeepingcalls whenever the value is not statically provable non-pointer, where the dyn IC
proves it at runtime and stores bare. 95% of that cell is the closure call —
425 of 447 instructions per iteration — not the write.
rhs_allocatingis the real prize, and capturing it needs no change to anysafepoint rule, because the dyn IC already roots correctly.
So the gate at
expr/proxy_reflect.rsis left in place and #8108 is corrected onthe issue rather than implemented as specified.
Tests
Two IR tests in
native_proof_regressions.rs:dyn_ic_inline_store_barriers_a_reference_value— all three bookkeeping calls in thereference arm, their absence from the scalar arm, and (because an emitted block is
not a reached block) the
br i1into the reference arm;dyn_ic_inline_store_keeps_its_semantic_fallback_for_reference_values— the tag as anarm selector rather than an entry gate, plus the retained
js_put_value_set_dyn_icfallback.
Four sabotages, all four caught: drop the write barrier; drop the layout note +
string addref; route reference values back to
put.dynic.slow(leaving the arm as deadIR); leak a barrier into the scalar arm. With the change reverted the suite reads
1476 passed / 11 failed (the 9 pre-existing + these 2); with it, 1478 / 9, same
nine names.
test-files/test_gap_8108_dyn_ic_reference_store.tsis the behavioural half: everyvalue tag through one site, frozen / sealed / non-extensible / accessor / read-only
receivers, an inherited setter, a
Proxytrap, array and typed-array receivers, amid-loop shape transition, a throwing RHS that leaves no store, target→key→RHS
evaluation order, and a volume section whose producer is reached through an
any[]soit cannot be inlined into a rooted temp. Byte-identical to Node 26.5.1 under the default
GC and under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1,PERRY_GEN_GC=0,PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1, andPERRY_WRITE_BARRIERS=0.Gates
cargo fmt --all -- --check,git diff --check, all 28lint-job steps generatedfrom
.github/workflows/test.yml(step 21 re-run with a real merge base),python3 scripts/check_gc_env_knobs.py(in no workflow, #8166),cargo test -p perry-runtime --lib(2445 / 0 / 4),cargo test -p perry-codegen --no-fail-fast(1478 / 9, zero new by name),cargo clippy -p perry-codegen --all-targets(no new warnings), and bothgc_root_dominance_check.pymodes.Refs #8108 (premise corrected there, with the measurement), #8184 (the follow-up this exposed), #6812.
Probe sources and the per-arm recipe (click to expand)
Each probe is a matrix cell body copied verbatim from
benchmarks/object-write-6812/matrix.tsand rescaled so a run is ~100-300 ms. Thereceiver is read out of an
any[], which is what keeps the store on the opaquesame-receiver
PutValuerather than the class-field route — a top-level (non-function)spelling of the same loop lowers differently and does not reach either write IC.
Which path a probe took (block labels carry numeric suffixes, so a bare
grep 'put.pic.guard:'reports zero for every one of them):A.
o.x = { value: r+i }— the improved shape (rhs_allocating)B.
o.x = produce(r, i)—rhs_callC.
const v = produce(r, i); o.x = v— the IR slice A would produceD.
o.x = pointer—rhs_pointer, already on the static PICSummary by CodeRabbit
Performance
Bug Fixes
Tests