Skip to content

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

Closed

Description

@proggeramlug

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, and PERRY_GEN_GC=0);
  • pointer-capable values keep the full layout-note / string-alias / write-barrier
    path (emit_jsvalue_slot_store_scalar_aware_on_block or 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_pointer and rhs_allocating show a measured improvement
    under run_matrix.py with 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.py over
    scripts/gc_root_dominance_corpus.sh stays at 0 violations with an empty
    allowlist.

Split out of #6812. Sibling: #8098 (class_id == 0 receivers, 78x).

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 14, 2026
  2. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Premise check on a3118cfea (v0.5.1510): two of the three cells are misattributed, and the named lever regresses the largest one

    Measured before writing any code, per the "reproduce the premise before you build"
    rule. Release build, one CARGO_TARGET_DIR / PERRY_RUNTIME_DIR / PERRY_CACHE_DIR
    per arm, PERRY_NO_AUTO_OPTIMIZE=1, best-of-5, output byte-checked against Node
    26.5.1. Instruction counts from /usr/bin/time -l on the dev box; wall/cycles from the
    quiet mini. The probes are the matrix's own cell bodies, copied verbatim from
    benchmarks/object-write-6812/matrix.ts and rescaled.

    1. Which path each cell actually takes today

    Classified from --trace llvm (note the block labels carry numeric suffixes —
    put.pic.guard.50:, not put.pic.guard: — a bare grep reports zero for all of them):

    cell shape write path on main
    o.x = pointer (rhs_pointer) static write PIC
    o.x = produce(r, i) (rhs_call) inline dynamic-key IC, raw-store arm
    o.x = { value: r+i } (rhs_allocating) inline dynamic-key IC → outlined helper, one call per write
    const v = <either>; o.x = v static write PIC — this is exactly the IR slice A would produce

    rhs_pointer is not a slice-A cell. Its RHS is an Expr::LocalGet, and
    put_value_rhs_is_safepoint_free has admitted LocalGet since #6811. The gate at
    proxy_reflect.rs:441 never rejected it. Its 9.21x is the pointer store path, not the
    safepoint rule, so slice A cannot move it by construction.

    2. Slice A would make rhs_call — the largest cell — slower

    Splitting 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:

    probe (12M writes) path instructions cycles ms
    o.x = produce(r, i) dyn IC, raw store 5.362G 834.2M 258
    const v = produce(r, i); o.x = v static write PIC 6.512G (+21.4%) 987.0M (+18.3%) 305 (+18.2%)

    Cause, 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-leaf bookkeeping 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 tag
    and stores bare. That is 118 instructions per write versus 22.

    Repeated with a real class receiver to rule out the #8098 class_id == 0 confound:
    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 problem
    wearing an object-write costume.

    3. rhs_allocating is the real prize — and it needs no change to any safepoint rule

    The dyn IC's inline arm refuses reference-tagged values, so every o.x = { … } write
    takes the outlined js_put_value_set_dyn_ic — a cross-crate call that re-validates in
    Rust 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 (t is materialised below everything that can collect), so the safepoint
    gate does not need to be touched at all
    .

    Measured (best-of-5 on the quiet mini, 4.8M writes):

    instructions cycles IPC ms peak RSS vs node
    main 4.243G 690.8M 6.14 212 33,248 KB 7.85x
    patched 2.860G (−32.6%) 491.0M (−28.9%) 5.83 150 (−29.2%) 33,200 KB 5.56x
    static-PIC ceiling (const v = …; o.x = v) 2.782G 478.0M 5.82 146 33,280 KB 5.41x

    The patch lands within 2.8% of the ceiling, o.x = produce(r,i) and
    o.x = pointer are unmoved (−0.45% / −0.00%), peak RSS is unchanged, and binary size is
    identical 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:

    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 is
    any-typed but numeric at runtime — const v = f(); o.x = v, an extremely common
    shape — 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_call rows dropped from its table. Otherwise it can close as
    superseded 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) verified
    against Node 26.5.1 on both arms.

  3. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    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 llvm on a3118cfea:

    this issue's cell claim measured
    rhs_pointer 9.21x rejected by the safepoint gate already on the static write PIC. Its RHS is Expr::LocalGet, which put_value_rhs_is_safepoint_free has admitted since #6811. The gate never saw it.
    rhs_call 18.28x the biggest slice-A win slice A makes it 21.4% slower — and 95% of the cell is not the write.
    rhs_allocating 4.64x slice-A win real, and it needs no change to any safepoint rule.

    const v = f(); o.x = v is 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 unconditional gc-leaf bookkeeping 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 class receiver to rule out the #8098 class_id == 0 confound: 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-crate js_put_value_set_dyn_ic call 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_DIR and PERRY_CACHE_DIR, output byte-checked against node 26.5.1:

    shape instructions cycles ms peak RSS
    o.x = {value: r+i} x4.8M 4.243 G → 2.860 G (−32.6%) 690.8 M → 491.0 M (−28.9%) 212 → 150 33,248 → 33,200 KB
    o.x = produce(r,i) x12M 5.362 → 5.337 G — 258 → 257 12,016 → 12,000 KB
    o.x = pointer x9.6M unchanged unchanged 106 → 106 unchanged

    7.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_call quirk: any static-PIC site whose value is any-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions