Repository navigation
perf(codegen): #6812 slice A — the static write PIC still rejects any safepointing RHS (rhs_call 18x, rhs_pointer 9x, rhs_allocating 4.6x) #8108
Description
Summary
#6812's "slice A" — root the evaluated PutValue reference so the static write
PIC can accept an RHS that allocates, calls, or otherwise reaches a safepoint —
was specified in that issue's body and never landed. It is now the largest
remaining family in the object-write matrix after class_id == 0 receivers
(#8098), covering three cells at once.
Measured on origin/main @ 514ff7c9a (v0.5.1510) against Node 26.5.1, using
the committed harness benchmarks/object-write-6812/run_matrix.py --samples 3
(it verifies (writes, sink) on every Node/Perry pair and aborts on mismatch):
| cell | node | perry | P/node | 2026-07-23 baseline |
|---|---|---|---|---|
rhs_call — RHS is a function call |
513 ms | 9,376 ms | 18.28 | 26.9x |
rhs_pointer — pointer-capable RHS |
635 ms | 5,848 ms | 9.21 | 12.3x |
rhs_allocating — RHS allocates ("s" + i) |
548 ms | 2,540 ms | 4.64 | 93.5x |
For contrast, the same matrix's rhs_numeric cell — identical in every respect
except that the RHS is a scalar — runs at 0.78x (Perry 459 ms vs Node
585 ms). The entire difference is the safepoint-free rule.
Full 26-cell table:
#6812 (comment)
The gate
crates/perry-codegen/src/expr/proxy_reflect.rs:441-448, unchanged since #6811:
// The assignment reference (target + static key) is evaluated before the
// RHS. Until PutValue reference temporaries have dedicated GC roots, an
// allocating/calling RHS could move the already-evaluated target while its
// SSA value remains stale. Keep the inline PIC to call-free expressions;
// the existing runtime lowering handles every other RHS.
if !put_value_rhs_is_safepoint_free(ctx, value) {
return Ok(None);
}put_value_rhs_is_safepoint_free (:1029) admits only locals, literals, and
numeric Binary over them. crates/perry-runtime/src/proxy/put_value.rs:465
separately records a "discarded safepointing-RHS approach", noting that the
dynamic-key IC's hot path deliberately "roots or revalidates" nothing.
Why it may be tractable now
The stated blocker is "until PutValue reference temporaries have dedicated GC
roots". That predates the Layer-1 rooting-by-construction migration (#7615),
which moved perry-codegen's lowerings onto an API built for exactly this shape.
The right first step is to check whether the reference temporary can now be
expressed as a rooted construction rather than a bare SSA value, before
designing anything new.
Correctness bar (from #6812's body, unchanged)
- target, key, and RHS evaluated exactly once, in spec order;
- a throwing RHS leaves no store;
- target movement under forced moving GC handled
(PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1, andPERRY_GEN_GC=0); - pointer-capable values keep the full layout-note / string-alias / write-barrier
path (emit_jsvalue_slot_store_scalar_aware_on_blockor an equivalently
complete implementation); - no root leaks across loop back-edges or function exits;
- paired eligible and forced-fallback tests, plus generated-IR evidence for the
intended guard/hit/miss structure — a test that only proves the fast block
exists is insufficient.
Acceptance
rhs_call,rhs_pointerandrhs_allocatingshow a measured improvement
underrun_matrix.pywith 15 samples on an idle host;rhs_numeric's 0.78x
and the other 13 currently-beating cells do not regress.- Identical
(writes, sink)on all 26 cells. scripts/gc_root_dominance_check.pyover
scripts/gc_root_dominance_corpus.shstays at 0 violations with an empty
allowlist.
Split out of #6812. Sibling: #8098 (class_id == 0 receivers, 78x).
Activity
proggeramlug commentedon Aug 15, 2026
Premise check on
a3118cfea(v0.5.1510): two of the three cells are misattributed, and the named lever regresses the largest oneMeasured before writing any code, per the "reproduce the premise before you build"
rule. Release build, one
CARGO_TARGET_DIR/PERRY_RUNTIME_DIR/PERRY_CACHE_DIRper arm,
PERRY_NO_AUTO_OPTIMIZE=1, best-of-5, output byte-checked against Node26.5.1. Instruction counts from
/usr/bin/time -lon the dev box; wall/cycles from thequiet mini. The probes are the matrix's own cell bodies, copied verbatim from
benchmarks/object-write-6812/matrix.tsand rescaled.1. Which path each cell actually takes today
Classified from
--trace llvm(note the block labels carry numeric suffixes —put.pic.guard.50:, notput.pic.guard:— a bare grep reports zero for all of them):o.x = pointer(rhs_pointer)o.x = produce(r, i)(rhs_call)o.x = { value: r+i }(rhs_allocating)const v = <either>; o.x = vrhs_pointeris not a slice-A cell. Its RHS is anExpr::LocalGet, andput_value_rhs_is_safepoint_freehas admittedLocalGetsince #6811. The gate atproxy_reflect.rs:441never rejected it. Its 9.21x is the pointer store path, not thesafepoint rule, so slice A cannot move it by construction.
2. Slice A would make
rhs_call— the largest cell — slowerSplitting the call into a local is the same work with the reference evaluated after the
RHS, i.e. precisely what rooting the reference would buy. It is measurable today with no
compiler change:
o.x = produce(r, i)const v = produce(r, i); o.x = vCause, from the emitted hit block: when the value is not statically provable
non-pointer — which is exactly what an allocating or calling RHS gives you — the static
PIC's hit path emits three unconditional
gc-leafbookkeeping calls(
js_string_addref_if_heap_string,js_gc_note_slot_layout_aware,js_write_barrier_slot). The dyn IC decides the same question from the live value tagand stores bare. That is 118 instructions per write versus 22.
Repeated with a real
classreceiver to rule out the #8098class_id == 0confound:identical (5.389G vs 6.467G).
And the cell is not about the write anyway. With the store removed but the call and
the local kept, the loop still costs 5.103G — 425 of 447 instructions per iteration is
the closure call, 95% of the cell.
rhs_call's 18.28x is a dynamic-call problemwearing an object-write costume.
3.
rhs_allocatingis the real prize — and it needs no change to any safepoint ruleThe dyn IC's inline arm refuses reference-tagged values, so every
o.x = { … }writetakes the outlined
js_put_value_set_dyn_ic— a cross-crate call that re-validates inRust exactly the guards the inline block already proved. Widening the inline arm to
reference values captures the whole win, and the dyn IC's operands are already rooted
correctly (
tis materialised below everything that can collect), so the safepointgate does not need to be touched at all.
Measured (best-of-5 on the quiet mini, 4.8M writes):
const v = …; o.x = v)The patch lands within 2.8% of the ceiling,
o.x = produce(r,i)ando.x = pointerare unmoved (−0.45% / −0.00%), peak RSS is unchanged, and binary size isidentical to the byte (13,544,480). IPC falls 6.14 → 5.83, so the cycle win is smaller
than the instruction win — both are quoted per #8175.
That is #8183.
4. Recommendation for this issue
Slice A as specified — root the evaluated PutValue reference so the static write PIC can
accept a safepointing RHS — is not worth doing in its current form:
rhs_pointer(already on the PIC);rhs_callby ~21% instructions unless the static PIC first learns thedyn IC's live value-tag test;
rhs_allocating, is now taken by perf(codegen): store reference values inline in the dynamic-key write IC (#8108) #8183 without touching asafepoint rule at all.
What is left of the idea is a different, larger prize that this measurement exposed
and that has nothing to do with safepoints: the static write PIC's hit block should put
its three bookkeeping calls behind the live pointer test that already exists in-tree
(
emit_jsvalue_slot_store_pointer_tested, #7511). Today any PIC site whose value isany-typed but numeric at runtime —const v = f(); o.x = v, an extremely commonshape — pays 118 instructions per write instead of ~25. That is filed as #8184, split out so it is not confused with the safepoint
gate.
If #8108 is kept open, its acceptance criteria should be rewritten around that, and the
rhs_pointer/rhs_callrows dropped from its table. Otherwise it can close assuperseded by #8183 plus that follow-up.
Reproduction: the probe sources and the per-arm build/measure recipe are in
#8183's description; every number above is best-of-5 with
(writes, sink)verifiedagainst Node 26.5.1 on both arms.
perf(codegen): store reference values inline in the dynamic-key write…
proggeramlug commentedon Aug 15, 2026
Closing — the premise is two-thirds stale, its named lever regresses the largest cell, and the real prize has shipped as #8183.
Classified from
--trace llvmona3118cfea:rhs_pointer9.21xExpr::LocalGet, whichput_value_rhs_is_safepoint_freehas admitted since #6811. The gate never saw it.rhs_call18.28xrhs_allocating4.64xconst v = f(); o.x = vis exactly the IR slice A would emit, so the prize was measurable with zero compiler work: 5.362 G → 6.512 G instructions (+21.4%), 258 → 305 ms. The cause is in the hit block — when the value is not statically provable non-pointer, which is precisely what an allocating or calling RHS gives you, the static PIC emits three unconditionalgc-leafbookkeeping calls (118 instr/write) where the dynamic-key IC decides from the live value tag and stores bare (22 instr/write).Re-run with a real
classreceiver to rule out the #8098class_id == 0confound: identical. With the store deleted the loop still costs 5.103 G — 425 of 447 instructions per iteration is the closure call, not the write.What shipped instead — #8183
The dyn IC's inline arm refused reference-tagged values, so every
o.x = {…}took a cross-cratejs_put_value_set_dyn_iccall that re-validated in Rust what the inline block had already proved. The tag now selects an arm; the scalar path is byte-identical to before.Best-of-5 on the quiet mini, release, per-arm
PERRY_RUNTIME_DIRandPERRY_CACHE_DIR, output byte-checked against node 26.5.1:o.x = {value: r+i}x4.8Mo.x = produce(r,i)x12Mo.x = pointerx9.6M7.85x → 5.56x vs node, within 2.8% of the static-PIC ceiling, so the write path is nearly spent. Binary size identical in both arms. IPC 6.14 → 5.83, so cycles are quoted beside instructions per #8175.
No rooting change: the target is materialised below everything that can collect, and all three helpers are
gc-leaf.What remains, filed as #8184
The 118-vs-22 gap is general, not a
rhs_callquirk: any static-PIC site whose value isany-typed but numeric at runtime pays three unconditional GC calls per write. The tree already ships the emitter that gates them behind one live test (emit_jsvalue_slot_store_pointer_tested, #7511). That is a better-posed issue than this one and does not depend on the safepoint framing.Closing here because this issue's specific claim — that the safepoint gate is what rejects these sites — is refuted for two of three cells and counterproductive for the third.