Skip to content

perf: the same property access costs 45-132 instructions depending only on receiver provenance, while node is flat at 12-14 — module-level const is excluded from ptr_shape #10769

Open

Description

@proggeramlug

Summary

The same property access costs perry 45 to 132 instructions depending only on where its receiver was declared. node is flat at 12–14 across all four.

Identical body in every arm — O.b = k; h += O.a — all four node-identical, fitted at two ranges, and all four write b so nothing can be constant-folded:

receiver provenance perry node bun
this in a class method 45.00 12.04 13.02
function-local const O = {a,b,c} 52.99 13.59 13.62
module-level const O = {a,b,c} 110.00 13.55 15.04
function parameter 131.99 13.07 12.55

2.9× spread inside perry on provenance alone, for code that is otherwise character-identical.

The fast arm shows what is achievable

Callgrind confirms the 45 arm inlines into main at exactly 45.0/iteration with zero per-iteration runtime calls. Its entire loop body:

  • store: 7 instructions — mov, the non-NaN-double test, bzhi, vmovsd
  • read: 4 instructions — mov, mov, bzhi, vmovq

No tag test, no small-handle test, no header word, no shape token — not twice, not once. The receiver proof was taken outside the loop.

So perry can already do a guarded property read in 4 instructions. It just does not do it for most receivers.

The mechanism exists and is shipping

collectors/ptr_shape.rs — 1,989 lines, audited, in production. class_field_inline_guard.rs:22-24 names it from the other side: "statically-proven receivers skip the diamond entirely."

Its own header says anon-shape literals qualify, and they do: {a:1,b:2,c:3} lowers to Expr::New { class_name: "__AnonShape_…" }.

The only thing disqualifying the module-level arm is that its Stmt::Let lives in module.init rather than a function body. A module-level const record — which is the commonest spelling there is for a lookup table, a config object, or a dispatch map — is not "a function-local let", so the analysis never looks at it.

Proposed

Extend ptr_shape.rs's provenance rule to a module-level const with the same containment proof it already requires. Predicted 110 → ~53.

That is better than the three-removal alternative below, on one existing mechanism rather than three new ones on the hottest path in the language.

The residual — 53 against node's 13.6 — is still 3.9×, and that remainder is representation propagation rather than provenance.

The alternative, and why it is worse

The other route was to remove redundant work from the guarded path directly: the duplicate proof (110 → 88), the normalising call (→ 67), and the root-shading barriers (→ 57–62). All three land at 2.8–3.0× node, not parity, and the first does nothing at all for single-access bodies.

Two findings from costing it, which stand regardless:

  • js_write_barrier_root_nanbox cannot be ruled out of the gap between a store guard and the following read guard. It is a call on the incremental-marking path, and "nothing happened in between" could not be established. ptr_shape sidesteps this entirely — its proof is provenance plus containment, not an absence argument.
  • The store's guard strictly implies the read's. The store's header mask is 0x1c8180ff and the read's 0x1c0080ff, both tested against 0x10000002; the store's is a superset whose extra bits must be zero to pass. Useful to whoever does build the adjacent version.

Also worth correcting: the "two barrier probes after a read" described earlier are root-shading barriers after each write to the NaN-boxed accumulator (emit_persistent_shadow_root_barrier, shadow_slot.rs:381). They are present in the 45-instruction arm too. Removing them means proving the stored value can never be a pointer, which belongs with representation propagation.

Related: #10761 (the parity measurements), #10768 (two read_stub gaps found while auditing an alternative).

Activity

  1. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Correction: the provenance rule was never the blocker

    The proposal above — extend ptr_shape.rs's provenance rule to module-level const — is wrong. Provenance and containment both already pass, unmodified, on a module-level const.

    A one-line probe dropping the module-global filter from the Ptr<Shape> seed (ptr_shape_report.rs:937), built with PERRY_NO_CACHE=1 and cmp-verified distinct, moved nothing: 110.00 → 110.00, and the function-parameter arm 132.00 → 131.99.

    Asking the compiler instead of guessing, via PERRY_OPT_REPORT=text on that probe build:

    Ptr<Shape> 1 selected / 0 denied (100% of 1 candidates)
    x_mod.ts :: local 'O' -> Ptr<Shape> (in module-init module_init)

    with the note that module-init and program-entry bodies set repsel_context_allows_canonical_i32: false, and FnCtx::ptr_shape_receiver_fact returns None for the whole body when that flag is clear. The shape proof is made, counted as a win, and then dropped at every access site.

    The two exclusions that actually stand, neither a provenance rule

    (A) Storage. slot_rep.rs:353-361 records module_global — "a module-level binding, held in an @perry_global_* NaN-boxed LLVM global rather than a local slot" — as Tier::CompilerLimitation, tracked as #7109. Ptr<Shape> is a representation defined over local slots; a module binding has a collector-rewritten global cell instead. Admitting it is slot-reload discipline for a global — representation work, not analysis work.

    (B) Context, and its stated justification is stale. slot_rep.rs:163-173 disables Ptr<Shape> in entry bodies because "#6991 is an open rooting bug in exactly that position".

    #6991 is closed. It was fixed by #7249 (64c1f56fb), which placed populate_global_this_builtins inside a GcSuppressScope. That issue's closing comment re-verified test_gap_repsel_ptr_shape_locals at 10/10 on the evacuating arm and 3/3 under PERRY_GC_ZEAL=1, at 3.4× the heap movement level the original crash was observed at. The fix landed in the runtime, not in this gate — so the gate is guarding against a bug that no longer exists, and the witness test to re-establish that is already in the tree.

    Lifting (B) alone only helps code whose loop is at module level, because (A) still filters the seed. And for the case that actually matters — a module const read inside a function — the seed can never help regardless: collect_type_facts is per-function (hir_facts.rs:488, it takes params), so when the enclosing function is compiled, the Stmt::Let for the const is not among its statements.

    The full ladder, with the rung that matters most

    receiver perry node vs node
    this in a class method 45.00 12.04 3.7×
    function-local const 52.99 13.59 3.9×
    module const, loop at module level 110.00 15.28 7.2×
    module const, used in a function 132.00 31.78 4.2×
    function parameter 131.99 13.07 10.1×
    module const, three reads in a function 151.99 11.47 13.3×

    The last row is exactly how validate and resolve read a lookup table, and it is the worst dotted-read ratio measured on this campaign.

    Status

    No change was made, so there is nothing to measure — an absence, not a zero. The module-wide containment walk remains feasible if anyone wants it (UseWalk at ptr_shape.rs:401 is a struct with walk_stmts, callable repeatedly, and local ids are module-unique), but it is pointless until (A) is resolved, because the fact would be dropped at every access site exactly as the optimiser report already says.

  2. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Measured the residual this issue names in its last line — "that remainder is representation
    propagation rather than provenance"
    — from the type side, on upstream/main f88acda
    (v0.5.1631), x86-64 perrymaster. Marginal instructions/iteration, min of 3, fitted between
    N=500k and N=5M, every row output-identical to node.

    A TypeScript type annotation buys exactly zero. Same fixture twice, differing only in
    whether the receiver is any or inferred:

    fixture receiver perry node
    mr1 const o: any = {a:1,…} 123.00 7.39
    mrtyped const o = {a:1,…} → {a: number, …} 123.00 6.26

    Identical to the digit. And both compiled bodies contain js_dynamic_string_or_number_add
    and js_number_coerce — the arithmetic on a number field stays dynamic. So the residual
    is not reachable from the type checker: it is the storage representation, exactly as this
    issue predicts, and no amount of annotation touches it.

    The read premium, isolated against a live-but-unread control:

    in-loop body perry node
    loop only, o live but read outside the loop 8.00 6.88
    one own read o.a 123.00 7.39
    receiver varies per iteration (objs[i&7].a, no hoisting possible) 184.00 17.82

    perry is at parity on the bare loop (8.00 vs 6.88–8.96), so the +115 is the read and
    what it drags in, not loop overhead. The first control (o unread) matters: with no read at
    all o is dead and eliminated, so that variant measures nothing.

    Marginal per additional read is flat, and there is no CSE:

    reads in body perry node perry marginal
    1 123.00 7.39 —
    2 (o.a + o.b) 147.00 11.40 +24.0
    4 200.00 16.11 +26.5
    8 308.00 31.36 +27.0
    2 of the same key (o.a + o.a) 147.00 10.24 +24.0

    Reading the same key twice costs the same as reading two different keys — perry does not
    CSE a repeated read, while node pays less for the repeat than for a distinct key. Consistent
    with #10884's finding that the miss path rejoins between consecutive reads so the second
    read's guard cannot share the first's proof.

    Tooling note for whoever picks this up: PERRY_KEEP_SYMBOLS=1 gives a symbol table
    without changing emitted code — unlike --debug-symbols, which adds js_set_call_location.
    Verified on this fixture: 123.00 instructions/iteration stripped and unstripped. That makes
    nm/objdump attribution usable on a binary whose numbers you can still quote.

    Not proposing anything separate — this is evidence for the split this issue already draws.
    The ptr_shape extension addresses the provenance half; the rows above say the
    representation half is worth roughly 26 instructions per additional read against node's 3–4,
    and that it is invisible to the type system.

  3. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Correction to my comment above (5761232052). The
    headline claim in it — "a TypeScript type annotation buys exactly zero" — rests on a
    confounded comparison, and I should not have drawn that conclusion from it.

    Those two fixtures annotate the binding (const o: any vs inferred), not the field,
    and their receiver is a module-level const — which is exactly the provenance class this
    issue reports as Ptr<Shape>-denied. So neither arm ever reached a type-consuming lowering.
    The annotation bought zero because nothing downstream of it ran, not because the type
    information is unusable.

    Annotating the field, on a receiver perry admits (opt report: Ptr<Shape> 1 selected … 1 CONSUMED by codegen), the type is worth a third:

    fixture difference perry node
    pxnum class C { a: number; … } 75.00 7.94
    pxany class C { a: any; … } 113.00 6.94

    Δ38.00, 34%, character-identical but for : number vs : any — and confirmed
    structurally, not just by cost: the number arm's loop body contains no
    js_dynamic_string_or_number_add and no js_number_coerce; the any arm contains both.

    So type information does flow. What it cannot do is reach a receiver this issue's
    provenance rule excludes — which strengthens the case for the fix proposed here rather than
    weakening it.

    What survives from the original comment unchanged: the bare-loop control (perry at
    parity with node when the receiver is live but unread), the flat marginal of +24…27 per
    additional read, the absence of CSE on a repeated key, and the PERRY_KEEP_SYMBOLS=1
    tooling note.

    One further caveat on those numbers, which is not about this issue: every fixture in that
    table computes h = (h + o.a) | 0, and the | 0 is itself expensive — measured separately
    at +43 instructions/iteration of open-coded ToInt32, larger than the property read beside
    it. Being filed separately. Readers should treat the absolute values above as
    read-plus-ToInt32, not as read alone; the differences between arms are unaffected, since
    every arm pays the same | 0.

  4. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    The * 1 residual is one predicate arm, and it is worth half the remaining gap

    Follow-up to my own comment above, which claimed a TypeScript annotation buys exactly zero on the
    evidence of mr1 vs mrtyped both measuring 123.00. That comparison was confounded and I am
    withdrawing the conclusion: it annotates the binding, not the field, and its receiver is a
    module-level const that Ptr<Shape> denies — so neither arm reached any type-consuming lowering.
    The annotation bought zero because nothing downstream of it ran.

    On a receiver the compiler admits (Ptr<Shape> 1 selected / 1 CONSUMED), the declared field type
    is worth 34%: 75.00 vs 113.00 for source that differs only in : number vs : any, with
    js_dynamic_string_or_number_add and js_number_coerce both gone from the loop body.

    The sharper result

    f88acda, marginal instr/iteration, min of 3, fitted 500k→5M, output-identical to node. Fixture is
    a function-local new C(1,…) with a: number, returned so it escapes scalar replacement while
    keeping Ptr<Shape> containment:

    loop body perry node js_dynamic_string_or_number_add vmulsd
    h = h + o.a; 32.00 8.79 present —
    h = h + o.a * 1; 16.00 10.68 absent 0

    * 1 halves it. vmulsd=0 — LLVM folds the multiply away entirely, so it does no arithmetic
    work. It only changes which Expr node expr_produces_canonical_raw_f64 sees: a bare
    PropertyGet takes the arm at type_analysis/numeric.rs:745-752, which requires
    fact.numeric_fields.contains(property), while o.a * 1 takes the Expr::Binary arm, which asks
    is_numeric_expr and succeeds.

    On this fixture ptr_shape_receiver_fact resolves (the report says the selection is consumed), so
    the failing conjunct is numeric_fields.contains("a") — on a field whose only store is the
    constructor's this.a = a resolved through new C(1,…).

    The file's own comment predicted this and names the same mechanism for #10777:

    "That lost label is the whole of the 29-vs-9 gap in #10777: o.a * 1 reaches 9 only because
    Binary is a shape the arm below recognises, not because the multiply normalises anything."

    16.00 against node's 10.68 is 1.50× — the closest this campaign has measured on a property read,
    and it needs no new soundness argument, because the Binary arm already vouches for the identical
    value.

    One correction to my method, recorded because it cost a build

    I built a compiler change for a different model first — vouching the accumulator rather than the
    read — and it measured 32.00 → 32.00, arms byte-identical under cmp. The * 1 probe above
    costs one 30-second rebuild with an existing binary and would have pointed at the right arm before
    any Rust was written.

    Also filed from this lane: #10897 — | 0 costs +43 instructions/iteration of open-coded ToInt32
    on these same fixtures, which is larger than the property read beside it, and every mr*/ops4
    regression fixture carries it.

  5. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    The boundary of Ptr<Shape> guard elision for a module-level const, measured

    Posting this as findings, not as a fix plan. The campaign is re-anchored on ONE PATH
    (OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md §C1), under which Ptr<Shape> is demoted
    to eliding the last guard rather than being the entrance to the fast path. Its soundness
    boundary still matters, so here is what I established, on upstream/main f88acda
    (v0.5.1631), perrymaster x86-64, marginal instructions:u, min of 3, fitted 500k→5M, every
    row output-identical to node.

    1. The ladder has moved. Both issues' numbers are stale.

    #10769's entry-body context gate landed as 6bcc2ae50, and the citation fix as 03d3ae024.
    Re-measured today (O.a + O.b in the loop, float accumulator):

    receiver perry node
    function-local const 36.00 ~11
    this in a class method 47.00 ~12
    module const, loop at module level, nothing else names it 42.00 ~12
    module const, loop at module level, plus one never-called reader 74.00 ~11
    module const, loop inside a function 76.00 ~11
    function parameter 75.00 ~11

    The 110 and 132 rows are gone. The remaining spread is 36 → 76.

    2. The cliff in one pair — and it is not about storage

    const O = {a:1,b:2};
    let h = 0.0; for (let k = 0; k < N; k++) h += O.a + O.b;      // 42.00
    const O = {a:1,b:2};
    function unused() { return O.a; }        // NEVER CALLED
    let h = 0.0; for (let k = 0; k < N; k++) h += O.a + O.b;      // 74.00

    Same object, same loop, 1.76× apart because an unrelated function mentions the binding.
    That mention is what promotes it to @perry_global_* (codegen/module_globals_emit.rs:427),
    and the Ptr<Shape> seed filters global-backed ids (collectors/escape_check.rs:27).

    #10769's "(A) Storage" diagnosis is refuted. That comment concluded Ptr<Shape> is "a
    representation defined over local slots" and that admitting a module binding is "slot-reload
    discipline for a global — representation work, not analysis work". Two independent
    refutations:

    • From source. The Ptr<Shape> access sites (expr/property_get/helpers.rs:568, :640)
      do lower_expr(ctx, object) on the receiver and then bitcast / and POINTER_MASK /
      gep / load. They never read a slot. Whatever lower_expr yields — an alloca load or an
      @perry_global_* load — works unchanged.
    • From measurement. A module-global scalar const K = 7 read from inside a function
      costs 9.00 instructions; the function-local spelling costs 9.00. The global cell is
      free. (@perry_global_* has external linkage deliberately, so LLVM must reload it across
      any opaque call — the rebase-after-safepoint property the shadow-slot argument provides
      holds a fortiori.)

    expr/slot_rep.rs's module_global denial is correct for canonical i32/u32/Str slots,
    which rewrite storage. It is not a statement about Ptr<Shape>. Reading it as one is what
    sent #10769 down the representation route.

    3. What actually bounds guard elision here — two obligations, one of them unnamed

    (a) Export surfaces, including the namespace object. The specific question was: is a
    module-level const reachable through the module's own namespace object (import * as ns),
    and does that count as a second name?
    From the code: yes, and only if it is exported.
    Namespace entries are built from the export list (commands/compile/run_pipeline.rs:2918),
    and a LocalVar entry is published as a live accessor whose getter wrapper loads
    @perry_global_* at read time (codegen/helpers.rs:1438) — so ns.O is the same object
    under a second name, and any importer can reshape it. A non-exported binding never becomes a
    namespace entry, so it needs no separate namespace clause. Exported bindings additionally get
    external linkage plus a perry_fn_<prefix>__<name> getter. globalThis needs no clause
    either: only top-level var and bare function declarations are reflected in a Script, never a
    top-level const.

    (b) Init-dominance — and neither issue names it. For a function-local, provenance gives
    dominance for free: the Stmt::Let precedes every use in the same body. For a module-level
    binding the Let lives in hir.init and the reads live in other regions, so it dominates
    nothing
    . And perry does not enforce TDZ on a module-level const:

    function f(){ return O.a; }
    f();                    // node: ReferenceError.  perry: TypeError, from the GUARDED path.
    const O = {a:1,b:2};

    The cell holds TAG_UNDEFINED and only the guard catches it. A guard-free read would compute
    TAG_UNDEFINED & POINTER_MASK, gep and load — a wild load, on a program that is merely
    buggy. This is a real constraint on guard elision for any module-level receiver, and it is
    orthogonal to aliasing, which is the only thing #10803's withdrawal argued about.

    4. Two of rule 2's exemptions are function-local arguments and do not transfer

    I built a module-wide containment walk to test the boundary, and it proved a case it should
    have denied
    : function leak(){ return O; } alongside readers elsewhere. Rule 2 exempts
    return <the local> because "a return is a terminator … the caller cannot have touched the
    object yet"
    — an argument about a function-local's lifetime. For a module-level binding
    the return is a terminator for leak, not for O: the caller may reshape the record while
    every other region keeps reading O.field at a fixed offset. The array-element-push exemption
    fails for the same reason.

    Worth recording even under ONE PATH: any widening of a containment region past one function
    body must re-derive the exemptions, not inherit them.

    5. Confirming lane 14: containment and scalar replacement are nearly the same condition

    Measured on f88acda with PERRY_OPT_REPORT=text:

    local-const fixture Ptr<Shape> verdict
    never escapes selected, NOT consumed — "dropped by scalar_replaced" (#7115)
    escapes as a call argument — keep(O) 0 selected / 1 denied — rule 2 (containment), "referenced as a bare value somewhere other than a declared-field access or a vetted method call"
    escapes in the return position — if (h < 0) return O 1 selected, 1 CONSUMED

    Lane 14 is right, and this also corrects #10803's own body, which reports "local const,
    escapes into keep(O): 47, 1 selected"
    . On v0.5.1631 an argument escape is denied. The
    consequence for anyone measuring: the return position is the only spelling that yields a
    local-receiver arm which is both non-scalar-replaced and Ptr<Shape>-proven.
    A "local
    const" row built any other way is measuring scalar replacement or the guarded diamond, not
    Ptr<Shape>. Every local-receiver number quoted on this campaign should be re-read with that
    in mind.

    6. Measurement note

    Fixtures here carry no | 0 (#10897: it is +43 instructions of ToInt32 and it inflated the
    ops4/mr* rows). Accumulators are floats and the result is printed. The 42 → 74 pair above
    is a good regression witness for any work that claims to flatten receiver provenance: it holds
    the object, the loop and the field set constant and varies only which region names the binding.

  6. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    The rule-6 work is parked on a branch, not lost — perf/10769-module-global-ptr-shape

    7af6bad057c90e15ccb6dc340283065f07a43d70, based on f88acdaa1 (v0.5.1631), pushed to
    PerryTS/perry. No PR: under OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md §C1
    Ptr<Shape> is demoted to eliding the last guard and must not gain provenance classes, so
    this is revisited last. Recording it here so it can be picked up rather than rebuilt.

    What it does. Adds rule 6 (collectors/ptr_shape_module_global.rs): a module-level
    const may seed Ptr<Shape> when the containment walk covers every region of the module
    and two obligations a function-local never carries are discharged — no export surface
    publishes a second name (6b), and the module-level Stmt::Let provably runs before any user
    code can read the binding (6d, the init-dominance clause). The admitted ids travel through
    ModuleDispatchFacts, and every region's own collect_type_facts re-runs containment over
    its own statements before consuming one, so a region missing from the enumeration is a region
    that still proves itself.

    Measured, upstream/main f88acda, marginal instructions:u, min of 3, fitted
    500k→5M, no | 0, both arms cmp-verified distinct, every row output-identical to node:

    arm base branch
    module const, loop inside a function 76.00 46.00
    module const + one never-called reader (the 42/74 cliff) 74.00 47.00
    as above, allocating in the loop so the object moves 112.00 81.00
    ops4 own3 122.00 107.00
    ops4 w4 192.00 126.00
    ops4 inh / inh3 345 / 284 345 / 284 (unchanged)
    export const O — must stay denied (6b) 76.00 76.00
    function leak(){ return O; } — must stay denied (6c) 76.00 76.00
    a function adds a property — must stay denied (rule 2) 76.00 76.00
    function parameter (out of scope) 75.00 75.00
    function-local const / this / module-global scalar 36 / 47 / 9 unchanged

    TDZ behaviour is byte-identical to base and to node on the fixture in the comment above.

    Two things the build caught that outlive the branch, and that anyone widening a
    containment region past one function body will hit:

    • Rule 2's return and array-element-push exemptions do not transfer. They are justified
      by a function-local's lifetime — "a return is a terminator … the caller cannot have
      touched the object yet". The first arm therefore proved function leak(){ return O; },
      which hands the record to a caller who can reshape it while every other region keeps reading
      it at a fixed offset. UseWalk::lifetime_bounded withdraws both for a module-level root;
      returning_the_module_const_from_a_function_denies_it is the regression test.
    • Seeding a binding no other region names costs 5 instructions. It is not promoted to an
      @perry_global_* cell, and its own region's pass already proves it with the numeric-field
      claim rule 6 has to stand down from — 42 → 47. The seed set is restricted to bindings some
      non-init region actually references, which is exactly the population the storage filter was
      dropping.

    Not verified: the full cargo test -p perry-codegen run was cut short by the charter
    change (the two test-only compile errors it surfaced are fixed on the branch), and no
    sabotage run was completed. Anyone resuming should start there — /tmp/sabotage2.py on
    perrymaster has the four prepared sabotages (6b export, 6c module-wide walk, the return
    exemption, 6d init-dominance), each paired with the unit test it must redden.

  7. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Oct 26, 2026
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