Skip to content

perf(codegen): store reference values inline in the dynamic-key write IC (#8108) - #8183

Merged
proggeramlug merged 2 commits into
mainfrom
perf/8108-write-pic-safepoint
Aug 15, 2026
Merged

perf(codegen): store reference values inline in the dynamic-key write IC (#8108)#8183
proggeramlug merged 2 commits into
mainfrom
perf/8108-write-pic-safepoint

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

What

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 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.refemit_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 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. scripts/gc_root_dominance_check.py over
scripts/gc_root_dominance_corpus.sh: 0 violations, 0 unrooted allocas, empty
allowlist, 40/40 seeded violations caught
, both modes.

Measurement

Best-of-5, release build, one CARGO_TARGET_DIR / PERRY_RUNTIME_DIR /
PERRY_CACHE_DIR per arm, PERRY_NO_AUTO_OPTIMIZE=1, (writes, sink) verified
against 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.

shape instr A → B cycles A → B IPC A → B ms A → B peak RSS A → B
o.x = { value: r+i } ×4.8M 4.243G → 2.860G (−32.6%) 690.8M → 491.0M (−28.9%) 6.14 → 5.83 212 → 150 (−29.2%) 33,248 → 33,200 KB
same, class-instance receiver 4.256G → 2.872G (−32.5%) 694.8M → 493.1M (−29.0%) 6.12 → 5.82 214 → 151 33,296 → 33,312 KB
o.x = produce(r, i) ×12M 5.362G → 5.337G (−0.45%) 834.2M → 831.8M 6.43 → 6.42 258 → 257 12,016 → 12,000 KB
o.x = pointer ×9.6M (static PIC) 2.2649G → 2.2649G 348.2M → 348.0M 6.50 → 6.51 106 → 106 12,096 → 12,096 KB

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 a3118cfea before writing any code:

  • rhs_pointer (9.21x) is already on the static PIC. Its RHS is an
    Expr::LocalGet, which put_value_rhs_is_safepoint_free has always admitted. The
    safepoint gate never rejected it; that cell's cost is the pointer store path.
  • 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, +18%
    wall): 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 — not the write.
  • rhs_allocating is the real prize, and capturing it needs no change to any
    safepoint rule, because the dyn IC already roots correctly.

So the gate at expr/proxy_reflect.rs is left in place and #8108 is corrected on
the 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 the
    reference arm, their absence from the scalar arm, and (because an emitted block is
    not a reached block) the br i1 into the reference arm;
  • dyn_ic_inline_store_keeps_its_semantic_fallback_for_reference_values — the tag as an
    arm selector rather than an entry gate, plus the retained js_put_value_set_dyn_ic
    fallback.

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 dead
IR); 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.ts is the behavioural half: every
value tag through one site, frozen / sealed / non-extensible / accessor / read-only
receivers, an inherited setter, a Proxy trap, array and typed-array receivers, a
mid-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[] so
it 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, and PERRY_WRITE_BARRIERS=0.

Recorded because it is the reason the IR assertions exist. A release build with the
write barrier removed from the reference arm passes that entire behavioural matrix
all four GC modes, byte-identical output, exit 0. A dropped barrier is invisible to
every runtime probe available here, so the static IR test is the only thing that can
say no.

Gates

cargo fmt --all -- --check, git diff --check, all 28 lint-job steps generated
from .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 both gc_root_dominance_check.py modes.

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.ts and rescaled so a run is ~100-300 ms. The
receiver is read out of an any[], which is what keeps the store on the opaque
same-receiver PutValue rather than the class-field route — a top-level (non-function)
spelling of the same loop lowers differently and does not reach either write IC.

# one target dir, one runtime dir AND one cache dir per arm
CARGO_TARGET_DIR=$T cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
PERRY_RUNTIME_DIR=$T/release PERRY_CACHE_DIR=$T/cache-A PERRY_NO_AUTO_OPTIMIZE=1 \
  $T/release/perry probe.ts -o bin_probe
/usr/bin/time -l ./bin_probe        # best-of-5; "instructions retired", "cycles elapsed"

Which path a probe took (block labels carry numeric suffixes, so a bare
grep 'put.pic.guard:' reports zero for every one of them):

perry probe.ts -o /dev/null --trace llvm
grep -cE '^put\.pic\.guard(\.[0-9]+)?:'   .perry-trace/llvm/*.ll   # static write PIC
grep -cE '^put\.dynic\.guard(\.[0-9]+)?:' .perry-trace/llvm/*.ll   # dynamic-key IC

A. o.x = { value: r+i } — the improved shape (rhs_allocating)

function cell(): number {
  const objects: any[] = [];
  for (let i = 0; i < 2400; i++) objects.push({ x: i, y: i * 2, z: 0, w: 0 });
  const t0 = Date.now();
  for (let r = 0; r < 2000; r++) {
    for (let i = 0; i < 2400; i++) {
      const object: any = objects[i];
      object.x = { value: r + i };
    }
  }
  const elapsed = Date.now() - t0;
  let sink = 0;
  for (let i = 0; i < objects.length; i++) sink += objects[i].x.value;
  console.log("ms", elapsed, "sink", sink);
  return elapsed;
}
cell();

B. o.x = produce(r, i)rhs_call

function checksum(objects: any[], keys: string[]): number {
  let sink = 0;
  for (let i = 0; i < objects.length; i++) {
    const object: any = objects[i];
    for (let k = 0; k < keys.length; k++) sink += object[keys[k]];
  }
  return sink;
}
function cell(): number {
  const objects: any[] = [];
  for (let i = 0; i < 2400; i++) objects.push({ x: i, y: i * 2, z: 0, w: 0 });
  const candidates: any[] = [(left: number, right: number) => left + right];
  const produce: any = candidates[0];
  const t0 = Date.now();
  for (let r = 0; r < 5000; r++) {
    for (let i = 0; i < 2400; i++) {
      const object: any = objects[i];
      object.x = produce(r, i);
    }
  }
  const elapsed = Date.now() - t0;
  console.log("ms", elapsed, "sink", checksum(objects, ["x"]));
  return elapsed;
}
cell();

C. const v = produce(r, i); o.x = v — the IR slice A would produce

function checksum(objects: any[], keys: string[]): number {
  let sink = 0;
  for (let i = 0; i < objects.length; i++) {
    const object: any = objects[i];
    for (let k = 0; k < keys.length; k++) sink += object[keys[k]];
  }
  return sink;
}
function cell(): number {
  const objects: any[] = [];
  for (let i = 0; i < 2400; i++) objects.push({ x: i, y: i * 2, z: 0, w: 0 });
  const candidates: any[] = [(left: number, right: number) => left + right];
  const produce: any = candidates[0];
  const t0 = Date.now();
  for (let r = 0; r < 5000; r++) {
    for (let i = 0; i < 2400; i++) {
      const object: any = objects[i];
      const produced: any = produce(r, i);
      object.x = produced;
    }
  }
  const elapsed = Date.now() - t0;
  console.log("ms", elapsed, "sink", checksum(objects, ["x"]));
  return elapsed;
}
cell();

D. o.x = pointerrhs_pointer, already on the static PIC

function cell(): number {
  const objects: any[] = [];
  for (let i = 0; i < 2400; i++) objects.push({ x: i, y: i * 2, z: 0, w: 0 });
  const pointer: any = { value: 17 };
  const t0 = Date.now();
  for (let r = 0; r < 4000; r++) {
    for (let i = 0; i < 2400; i++) {
      const object: any = objects[i];
      object.x = pointer;
    }
  }
  const elapsed = Date.now() - t0;
  let sink = 0;
  for (let i = 0; i < objects.length; i++) sink += objects[i].x.value;
  console.log("ms", elapsed, "sink", sink);
  return elapsed;
}
cell();

Summary by CodeRabbit

  • Performance

    • Improved dynamic-key property writes for objects, strings, and bigints by using a faster inline path.
    • Preserved the existing optimized path for primitive values.
  • Bug Fixes

    • Maintained correct behavior for frozen, sealed, accessor-based, proxy, array, and typed-array receivers.
    • Preserved shape transitions, evaluation order, error handling, and reference updates.
  • Tests

    • Added comprehensive coverage for reference-valued dynamic property writes and fallback behavior.

Ralph Küpper added 2 commits August 16, 2026 00:31
… 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.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5cb1f933-682e-499e-83c7-82de7a96e5ef

📥 Commits

Reviewing files that changed from the base of the PR and between a59dc2b and 2860ef6.

📒 Files selected for processing (4)
  • changelog.d/8183-dyn-ic-reference-store.md
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • test-files/test_gap_8108_dyn_ic_reference_store.ts

📝 Walkthrough

Walkthrough

Dynamic-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.

Changes

Dynamic-key reference store support

Layer / File(s) Summary
Inline store control flow
crates/perry-codegen/src/expr/proxy_reflect.rs, changelog.d/8183-dyn-ic-reference-store.md
Dynamic-key write ICs select between direct scalar stores, barriered reference stores, and the outlined fallback. The changelog records implementation details, validation, and performance results.
IR regression coverage
crates/perry-codegen/tests/native_proof_regressions.rs
Native proof tests verify the reference store arm, write barriers, layout metadata, scalar arm, value-tag selection, and semantic fallback.
Runtime behavior validation
test-files/test_gap_8108_dyn_ic_reference_store.ts
Runtime coverage tests reference and primitive values, receiver edge cases, evaluation order, shape transitions, exceptions, and repeated old-to-young writes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6895 — Directly related dynamic-key write IC changes, including barriered inline stores for reference values.
  • PerryTS/perry#8017 — Related dynamic inline-cache store CFG and write-barrier safety changes used by this path.
  • PerryTS/perry#8118 — Related dynamic-key write IC changes that broaden eligible receivers.

Suggested labels: ready

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/8108-write-pic-safepoint

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
proggeramlug marked this pull request as ready for review August 15, 2026 22:57
@proggeramlug
proggeramlug merged commit 30db750 into main Aug 15, 2026
5 of 18 checks passed
@proggeramlug
proggeramlug deleted the perf/8108-write-pic-safepoint branch August 15, 2026 22:57
proggeramlug added a commit that referenced this pull request Aug 16, 2026
…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>
proggeramlug added a commit that referenced this pull request Aug 16, 2026
…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>
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