From 983b8441c07bf5bee79304a477696e74180c0463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 00:31:35 +0200 Subject: [PATCH 1/2] perf(codegen): store reference values inline in the dynamic-key write IC (#8108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 a3118cfea: * `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. --- .../perry-codegen/src/expr/proxy_reflect.rs | 60 +++++-- .../tests/native_proof_regressions.rs | 163 ++++++++++++++++++ .../test_gap_8108_dyn_ic_reference_store.ts | 124 +++++++++++++ 3 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 test-files/test_gap_8108_dyn_ic_reference_store.ts diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 960e2eb03a..737d6f7232 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -835,18 +835,22 @@ fn lower_put_value_dyn_ic_inline( let is_ptr = ctx.block().icmp_eq(I64, &t_tag, "32765"); let above = ctx.block().icmp_ugt(I64, &t_handle, "1048575"); // Value tag: reference-creating stores (pointer 0x7FFD, string 0x7FFF, - // bigint 0x7FFA) leave the inline path before any store. + // bigint 0x7FFA) need the layout note / string-alias / write-barrier + // bookkeeping, so they SELECT the barriered store arm below rather than + // gating entry. #8108: they used to leave the inline path here, which sent + // every `o.k = ` through the outlined helper — one cross-crate + // call per write that re-validated, in Rust, exactly the guards this block + // has already proved. let v_tag = ctx.block().lshr(I64, &v_bits, "48"); let v_not_obj = ctx.block().icmp_ne(I64, &v_tag, "32765"); let v_not_str = ctx.block().icmp_ne(I64, &v_tag, "32767"); let v_not_big = ctx.block().icmp_ne(I64, &v_tag, "32762"); + let mut v_scalar = ctx.block().and(I1, &v_not_obj, &v_not_str); + v_scalar = ctx.block().and(I1, &v_scalar, &v_not_big); // Zero key bits are the empty-way sentinel (and the JS number 0): // they must never reach the way compares. let k_nonzero = ctx.block().icmp_ne(I64, &k_bits, "0"); let mut entry_ok = ctx.block().and(I1, &is_ptr, &above); - entry_ok = ctx.block().and(I1, &entry_ok, &v_not_obj); - entry_ok = ctx.block().and(I1, &entry_ok, &v_not_str); - entry_ok = ctx.block().and(I1, &entry_ok, &v_not_big); entry_ok = ctx.block().and(I1, &entry_ok, &k_nonzero); let guard_idx = ctx.new_block("put.dynic.guard"); @@ -854,6 +858,8 @@ fn lower_put_value_dyn_ic_inline( let way1_idx = ctx.new_block("put.dynic.way1"); let way2_idx = ctx.new_block("put.dynic.way2"); let store_idx = ctx.new_block("put.dynic.store"); + let store_scalar_idx = ctx.new_block("put.dynic.store.scalar"); + let store_ref_idx = ctx.new_block("put.dynic.store.ref"); let slow_idx = ctx.new_block("put.dynic.slow"); let merge_idx = ctx.new_block("put.dynic.merge"); let guard_label = ctx.block_label(guard_idx); @@ -861,6 +867,8 @@ fn lower_put_value_dyn_ic_inline( let way1_label = ctx.block_label(way1_idx); let way2_label = ctx.block_label(way2_idx); let store_label = ctx.block_label(store_idx); + let store_scalar_label = ctx.block_label(store_scalar_idx); + let store_ref_label = ctx.block_label(store_ref_idx); let slow_label = ctx.block_label(slow_idx); let merge_label = ctx.block_label(merge_idx); ctx.block().cond_br(&entry_ok, &guard_label, &slow_label); @@ -942,18 +950,45 @@ fn lower_put_value_dyn_ic_inline( I64, &[(&s0, &ways_label), (&s1, &way1_label), (&s2, &way2_label)], ); - let header_words = - (crate::target_layout::object_header_size_bytes(ctx.target_triple) / 8).to_string(); + let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple); + let header_words = (header_bytes / 8).to_string(); let slot_word = ctx.block().add(I64, &slot, &header_words); let obj_ptr = ctx.block().inttoptr(I64, &t_handle); let slot_ptr = ctx .block() .gep_inbounds(I64, &obj_ptr, &[(I64, &slot_word)]); - // GC_STORE_AUDIT(POINTER_FREE): the entry tag test proved the value is + ctx.block() + .cond_br(&v_scalar, &store_scalar_label, &store_ref_label); + + ctx.current_block = store_scalar_idx; + // GC_STORE_AUDIT(POINTER_FREE): the tag test above proved the value is // not pointer/string/bigint — non-reference bits need no barrier. ctx.block().store(DOUBLE, v, &slot_ptr); ctx.block().br(&merge_label); + // #8108: the reference arm. Byte-for-byte the static write PIC's + // pointer-capable store (`lower_put_value_static_write_ic`'s hit block), + // reached under STRICTLY STRONGER conditions: the guards above are that + // PIC's guards, and this block additionally knows the value carries a + // reference tag, which the PIC only knows statically or not at all. + // + // No new rooting obligation. `t` is materialised BELOW every operand that + // can collect (see the call site's evaluation-order note), and the three + // bookkeeping helpers are `gc-leaf-function`, so nothing between the + // re-read and the store is a collection point. + ctx.current_block = store_ref_idx; + { + let slot_i32 = ctx.block().trunc(I64, &slot, I32); + let slot_offset = ctx.block().shl(I64, &slot, "3"); + let fields_base = ctx.block().add(I64, &t_handle, &header_bytes.to_string()); + let slot_addr = ctx.block().add(I64, &fields_base, &slot_offset); + let blk = ctx.block(); + emit_jsvalue_slot_store_scalar_aware_on_block( + blk, &slot_ptr, v, &t_handle, &slot_i32, true, &t_bits, &slot_addr, true, + ); + blk.br(&merge_label); + } + ctx.current_block = slow_idx; let slow_result = ctx.block().call( DOUBLE, @@ -969,9 +1004,14 @@ fn lower_put_value_dyn_ic_inline( ctx.block().br(&merge_label); ctx.current_block = merge_idx; - let result = ctx - .block() - .phi(DOUBLE, &[(v, &store_label), (&slow_result, &slow_label)]); + let result = ctx.block().phi( + DOUBLE, + &[ + (v, &store_scalar_label), + (v, &store_ref_label), + (&slow_result, &slow_label), + ], + ); Ok(result) } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index b919533d71..2d3256d7aa 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -14551,6 +14551,169 @@ fn static_put_value_rejects_write_pic_when_rhs_can_allocate() { ); } +/// The body of the first block whose label starts with `label_prefix`. +/// +/// Block labels carry per-function numeric suffixes (`put.dynic.store.ref.60`), +/// so callers pass the stable prefix. A block header is a line-initial +/// `label:`; lines that merely mention the label (branches, phis) are indented +/// and skipped. +fn dyn_ic_block_body<'a>(ir: &'a str, label_prefix: &str) -> Option<&'a str> { + let needle = format!("\n{label_prefix}"); + let mut from = 0; + while let Some(rel) = ir[from..].find(&needle) { + let label_start = from + rel + 1; + let line_end = label_start + ir[label_start..].find('\n')?; + if ir[label_start..line_end].ends_with(':') { + let rest = &ir[line_end + 1..]; + let end = match (rest.find("\n\n"), rest.find("\n}")) { + (Some(a), Some(b)) => a.min(b), + (a, b) => a.or(b).unwrap_or(rest.len()), + }; + return Some(&rest[..end]); + } + from = line_end; + } + None +} + +fn dyn_ic_reference_store_ir() -> String { + let object = 1u32; + let value = 2u32; + let key = 3u32; + let module = module_with_classes_and_params( + "dyn_ic_reference_store", + Vec::new(), + vec![ + param(object, "object", Type::Any), + param(value, "value", Type::Any), + ], + Type::Any, + vec![ + Stmt::Let { + id: key, + name: "key".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("x".to_string())), + }, + Stmt::Return(Some(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(object)), + key: Box::new(Expr::LocalGet(key)), + value: Box::new(Expr::LocalGet(value)), + receiver: Box::new(Expr::LocalGet(object)), + strict: false, + })), + ], + ); + compile_ir_for_module_with_opts(module, empty_opts()).unwrap() +} + +/// #8108: a reference-tagged value stored through the inline dynamic-key write +/// IC takes a BARRIERED inline arm instead of leaving the inline path. +/// +/// Before this, the value tag gated ENTRY: `o[k] = ` was +/// pushed straight to `put.dynic.slow`, i.e. one cross-crate +/// `js_put_value_set_dyn_ic` call per write that re-validated in Rust exactly +/// the guards the inline block had already proved. The tag now SELECTS an arm. +/// +/// The reference arm is byte-for-byte the static write PIC's pointer-capable +/// store (`emit_jsvalue_slot_store_scalar_aware_on_block`) reached under +/// strictly stronger conditions, so this test pins all three bookkeeping calls +/// — dropping any one of them is the #5094 / #7511 family of silent-stranding +/// bugs, and none of them is visible to a runtime GC probe. +#[test] +fn dyn_ic_inline_store_barriers_a_reference_value() { + let ir = dyn_ic_reference_store_ir(); + + let scalar = dyn_ic_block_body(&ir, "put.dynic.store.scalar") + .unwrap_or_else(|| panic!("the non-reference store arm must survive:\n{ir}")); + let reference = dyn_ic_block_body(&ir, "put.dynic.store.ref").unwrap_or_else(|| { + panic!("a reference-tagged value must take an inline barriered arm:\n{ir}") + }); + // An emitted block is not a reached block. Routing reference values back to + // `put.dynic.slow` leaves this block behind as dead IR, which every + // assertion below would happily inspect — so require the branch INTO it + // before believing anything it contains. + assert!( + ir.lines() + .any(|line| line.contains("br i1") && line.contains("%put.dynic.store.ref")), + "the reference arm must be a branch target, not dead IR:\n{ir}" + ); + + for helper in [ + "js_string_addref_if_heap_string", + "js_gc_note_slot_layout_aware", + "js_write_barrier_slot", + ] { + assert!( + reference.contains(helper), + "the reference store arm must keep the full layout-note / string-alias / \ + write-barrier path; missing {helper}:\n{reference}" + ); + } + assert!( + reference.contains("store double"), + "the reference arm must still perform the slot store:\n{reference}" + ); + + // The scalar arm is the pre-#8108 IR: a bare store, no bookkeeping. A + // barrier appearing here would mean the tag test stopped discriminating. + assert!( + scalar.contains("store double"), + "the non-reference arm must still store:\n{scalar}" + ); + for helper in [ + "js_string_addref_if_heap_string", + "js_gc_note_slot_layout_aware", + "js_write_barrier_slot", + ] { + assert!( + !scalar.contains(helper), + "GC_STORE_AUDIT(POINTER_FREE): the non-reference arm proved the value carries \ + no heap pointer, so it must not call {helper}:\n{scalar}" + ); + } +} + +/// #8108, the other half: admitting reference values inline must not cost the +/// semantic fallback. Every guard failure and every way miss still reaches +/// `js_put_value_set_dyn_ic`, which bottoms out at full `[[Set]]`. +#[test] +fn dyn_ic_inline_store_keeps_its_semantic_fallback_for_reference_values() { + let ir = dyn_ic_reference_store_ir(); + + assert!( + ir.contains("call double @js_put_value_set_dyn_ic("), + "the outlined helper must remain the miss path:\n{ir}" + ); + // The arm is SELECTED by the value tag, not gated at entry: the branch into + // the two store arms is what proves a reference value can reach the inline + // store at all, rather than being diverted to the slow block above it. + let selector = ir + .lines() + .find(|line| { + line.contains("br i1") + && line.contains("%put.dynic.store.scalar") + && line.contains("%put.dynic.store.ref") + }) + .unwrap_or_else(|| { + panic!("the value tag must SELECT a store arm, not gate inline entry:\n{ir}") + }); + assert!( + selector.trim_start().starts_with("br i1"), + "expected a conditional branch into the two store arms, got: {selector}" + ); + // Entry must no longer reject on the value tag. The three tag compares + // still exist (they build the selector), but the entry predicate is now + // receiver-shaped plus the empty-way sentinel only. + let entry = dyn_ic_block_body(&ir, "put.dynic.guard") + .unwrap_or_else(|| panic!("the receiver guard block must exist:\n{ir}")); + assert!( + entry.contains("call double @js_put_value_set_dyn_ic(") || ir.contains("%put.dynic.slow"), + "the receiver guard must still fall through to the outlined helper:\n{ir}" + ); +} + #[test] fn nested_same_shape_object_writes_version_one_through_four_fields() { let objects = 1u32; diff --git a/test-files/test_gap_8108_dyn_ic_reference_store.ts b/test-files/test_gap_8108_dyn_ic_reference_store.ts new file mode 100644 index 0000000000..f7e1a9ce21 --- /dev/null +++ b/test-files/test_gap_8108_dyn_ic_reference_store.ts @@ -0,0 +1,124 @@ +// #8108: the inline dynamic-key write IC now stores REFERENCE values (object / +// string / bigint) through a barriered inline arm instead of diverting them to +// the outlined helper. Receivers are laundered through an `any[]` so the store +// site is the opaque same-receiver PutValue that lowers to +// `lower_put_value_dyn_ic_inline`, and every RHS reaches a safepoint so the +// static write PIC declines and the dynamic IC is what runs. + +const bag: any[] = []; +function stash(o: any): number { bag.push(o); return bag.length - 1; } +function id(v: any): any { return v; } +function tryWrite(i: number, k: string, v: any): string { + const o: any = bag[i]; + try { o[k] = v; return "ok"; } catch (e: any) { return "throw:" + (e instanceof TypeError); } +} +function mk(i: number): any { return { a: i, b: i + 1, c: 0, d: 0 }; } + +const out: string[] = []; + +// Every value tag through ONE site. +{ + const i = stash(mk(1)); + const vals: any[] = [{ v: 1 }, "str", 12345678901234567890n, Symbol("s"), 5, true, null, undefined, 1.5, "x"]; + for (let n = 0; n < vals.length; n++) { + const o: any = bag[i]; + o.a = id(vals[n]); + out.push(typeof o.a + ":" + String(o.a === vals[n])); + } +} + +// Frozen / sealed / non-extensible / accessor / read-only receivers keep their +// semantic paths; the inline arm must never store into any of them. +{ + const f = stash(mk(4)); Object.freeze(bag[f]); + out.push("frozen:" + tryWrite(f, "a", id({ x: 1 })) + ":" + JSON.stringify(bag[f].a)); + const se = stash(mk(5)); Object.seal(bag[se]); + out.push("sealed:" + tryWrite(se, "a", id({ x: 2 })) + ":" + JSON.stringify(bag[se].a)); + const ne = stash(mk(6)); Object.preventExtensions(bag[ne]); + out.push("noext:" + tryWrite(ne, "a", id({ x: 3 })) + "," + tryWrite(ne, "zz", id({ y: 4 })) + + ":" + JSON.stringify(bag[ne].a) + "," + JSON.stringify(bag[ne].zz)); + let seen: any = null; + const ac: any = {}; + Object.defineProperty(ac, "a", { set(v: any) { seen = v; }, get() { return seen; }, configurable: true }); + out.push("accessor:" + tryWrite(stash(ac), "a", id({ x: 5 })) + ":" + JSON.stringify(seen)); + const ro: any = {}; + Object.defineProperty(ro, "a", { value: 1, writable: false, configurable: true }); + out.push("readonly:" + tryWrite(stash(ro), "a", id({ x: 6 })) + ":" + JSON.stringify(ro.a)); +} + +// Inherited setter, proxy trap, arrays and typed arrays. +{ + const proto: any = {}; let captured: any = null; + Object.defineProperty(proto, "hook", { set(v: any) { captured = v; }, get() { return captured; }, configurable: true }); + const child = stash(Object.create(proto)); + out.push("inherited:" + tryWrite(child, "hook", id({ z: 9 })) + ":" + JSON.stringify(captured) + + "," + String(Object.prototype.hasOwnProperty.call(bag[child], "hook"))); + const traps: string[] = []; + const px = stash(new Proxy({ a: 0 } as any, { set(t: any, k: any, v: any) { traps.push(String(k)); t[k] = v; return true; } })); + out.push("proxy:" + tryWrite(px, "a", id({ p: 1 })) + ":" + traps.join(",") + ":" + JSON.stringify(bag[px].a)); + const arr = stash([1, 2, 3]); + out.push("arr:" + tryWrite(arr, "x", id({ n: 1 })) + "," + tryWrite(arr, "1", id({ n: 2 })) + + ":" + JSON.stringify(bag[arr].x) + "," + JSON.stringify(bag[arr][1]) + "," + bag[arr].length); + const ta = stash(new Uint8Array(4)); + out.push("ta:" + tryWrite(ta, "0", id(255)) + "," + tryWrite(ta, "tag", id("t")) + + ":" + bag[ta][0] + "," + bag[ta].tag); +} + +// A shape transition, and a throwing RHS that must leave the slot untouched. +{ + const t = stash(mk(7)); + for (let n = 0; n < 8; n++) { + const o: any = bag[t]; + o.a = id({ i: n }); + if (n === 3) o.extra = id("added"); + if (n === 5) delete o.b; + } + out.push("transition:" + JSON.stringify(bag[t])); + const i = stash(mk(9)); + const before = bag[i].a; + try { + const o: any = bag[i]; + o.a = id(((): any => { throw new RangeError("boom"); })()); + out.push("throwrhs:nothrow"); + } catch (e: any) { out.push("throwrhs:" + (e instanceof RangeError) + ":" + String(bag[i].a === before)); } +} + +// Target, key and RHS evaluate exactly once, in spec order. +{ + const order: string[] = []; + const objs: any[] = [mk(10)]; + function tgt(): any { order.push("t"); return objs[0]; } + function ky(): string { order.push("k"); return "a"; } + function rhs(): any { order.push("v"); return { ok: 1 }; } + tgt()[ky()] = rhs(); + out.push("order:" + order.join("") + ":" + JSON.stringify(objs[0].a)); +} + +// Volume: old->young edges written from a producer reached through an `any[]`, +// so it cannot be inlined into a rooted temp — which is what keeps this loop on +// the barriered inline arm rather than the static write PIC. +{ + const producers: any[] = [ + (n: number): any => ({ r: n, s: "v" + n }), + (n: number): any => "s" + n + "-" + (n * 7), + ]; + const keep: any[] = []; + for (let n = 0; n < 600; n++) keep.push(mk(n)); + for (let round = 0; round < 12; round++) { + const produce: any = producers[round & 1]; + for (let n = 0; n < keep.length; n++) { + const o: any = keep[n]; + o.c = produce(n); + o.d = produce(n + 1); + } + } + let s = 0; + for (let n = 0; n < keep.length; n++) { + const c: any = keep[n].c; + const d: any = keep[n].d; + s += (typeof c === "string" ? c.length : c.r) + (typeof d === "string" ? d.length : d.r); + } + out.push("volume:" + s); +} + +console.log(out.join("\n")); From 2860ef6210b950c8a0bb82d082a6e77d0aaf2669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 00:33:26 +0200 Subject: [PATCH 2/2] docs: changelog fragment for #8183 --- changelog.d/8183-dyn-ic-reference-store.md | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 changelog.d/8183-dyn-ic-reference-store.md diff --git a/changelog.d/8183-dyn-ic-reference-store.md b/changelog.d/8183-dyn-ic-reference-store.md new file mode 100644 index 0000000000..f8f99206d1 --- /dev/null +++ b/changelog.d/8183-dyn-ic-reference-store.md @@ -0,0 +1,79 @@ +`perf(codegen)`: the inline dynamic-key write IC now stores **reference** values +(object / string / bigint) through a barriered inline arm instead of diverting +them to the outlined helper. `o.k = { … }` in a loop drops **32.6% of its +instructions, 28.9% of its cycles and 29.2% of its wall time** — 7.85x → 5.56x +vs Node on the object-write matrix's `rhs_allocating` shape — with peak RSS and +binary size unchanged (33,248 → 33,200 KB; 13,544,480 bytes both arms). + +Before this, `lower_put_value_dyn_ic_inline`'s entry predicate ANDed in "the +value tag is not pointer/string/bigint", so every reference-valued write 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 +(its `GC_STORE_AUDIT(POINTER_FREE)` claim is unchanged), and +`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. + +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` stays at 0 violations / 0 unrooted +allocas with an empty allowlist and 40/40 seeded violations caught. + +### This is #8108's measured prize, reached by a different route + +#8108 ("the static write PIC still rejects any safepointing RHS") names three +cells — `rhs_call` 18.28x, `rhs_pointer` 9.21x, `rhs_allocating` 4.64x — and +proposes admitting a safepointing RHS into the static write PIC. Measured on +`a3118cfea` before writing any code, two thirds of that framing is wrong and +the named lever would have made the largest cell slower: + +* **`rhs_pointer` is already on the static PIC.** Its RHS is `Expr::LocalGet`, + which `put_value_rhs_is_safepoint_free` has always admitted. The gate never + rejected it; its 9.21x is the pointer store path, not the safepoint rule. +* **`rhs_call` would REGRESS.** Splitting the call into a local + (`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's cost is the closure call + (425 of 447 instructions per iteration), not the write. +* **`rhs_allocating` is the real prize**, and it does not need the gate touched + at all: the dyn IC already roots correctly, so widening its inline store to + reference values captures the same win with no change to any safepoint rule. + Arm B lands within 2.8% of the static-PIC ceiling (2.860G vs 2.782G). + +The safepoint gate at `expr/proxy_reflect.rs` is therefore left in place and +#8108's premise is corrected on the issue rather than implemented. + +### Tests + +`dyn_ic_inline_store_barriers_a_reference_value` pins 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` pins the +tag as an arm SELECTOR rather than an entry gate, and the retained +`js_put_value_set_dyn_ic` fallback. Four sabotages were run and all four are +caught: dropping the write barrier, dropping the layout note + string addref, +routing reference values back to `put.dynic.slow` (dead-IR arm), and leaking a +barrier into the scalar arm. + +`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 leaving 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 here, so the static IR test +is the only thing that can say no.