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.

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