Repository navigation
Description
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_nanboxcannot 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_shapesidesteps 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
0x1c8180ffand the read's0x1c0080ff, both tested against0x10000002; 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
proggeramlug commentedon Sep 20, 2026
Correction: the provenance rule was never the blocker
The proposal above — extend
ptr_shape.rs's provenance rule to module-levelconst— is wrong. Provenance and containment both already pass, unmodified, on a module-levelconst.A one-line probe dropping the module-global filter from the
Ptr<Shape>seed (ptr_shape_report.rs:937), built withPERRY_NO_CACHE=1andcmp-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=texton that probe build:with the note that module-init and program-entry bodies set
repsel_context_allows_canonical_i32: false, andFnCtx::ptr_shape_receiver_factreturnsNonefor 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-361recordsmodule_global— "a module-level binding, held in an@perry_global_*NaN-boxed LLVM global rather than a local slot" — asTier::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-173disablesPtr<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 placedpopulate_global_this_builtinsinside aGcSuppressScope. That issue's closing comment re-verifiedtest_gap_repsel_ptr_shape_localsat 10/10 on the evacuating arm and 3/3 underPERRY_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
constread inside a function — the seed can never help regardless:collect_type_factsis per-function (hir_facts.rs:488, it takesparams), so when the enclosing function is compiled, theStmt::Letfor the const is not among its statements.The full ladder, with the rung that matters most
thisin a class methodconstconst, loop at module levelconst, used in a functionconst, three reads in a functionThe last row is exactly how
validateandresolveread 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 (
UseWalkatptr_shape.rs:401is a struct withwalk_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.perf(codegen): stop disabling Ptr<Shape> in entry bodies for a bug th…
fix(codegen): name the mechanism that drops an unspendable Ptr<Shape>…
proggeramlug commentedon Sep 21, 2026
Measured the residual this issue names in its last line — "that remainder is representation
propagation rather than provenance" — from the type side, on
upstream/mainf88acda(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
anyor inferred:mr1const o: any = {a:1,…}mrtypedconst o = {a:1,…}→{a: number, …}Identical to the digit. And both compiled bodies contain
js_dynamic_string_or_number_addand
js_number_coerce— the arithmetic on anumberfield stays dynamic. So the residualis 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:
olive but read outside the loopo.aobjs[i&7].a, no hoisting possible)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 (
ounread) matters: with no read atall
ois dead and eliminated, so that variant measures nothing.Marginal per additional read is flat, and there is no CSE:
o.a + o.b)o.a + o.a)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=1gives a symbol tablewithout changing emitted code — unlike
--debug-symbols, which addsjs_set_call_location.Verified on this fixture: 123.00 instructions/iteration stripped and unstripped. That makes
nm/objdumpattribution 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_shapeextension addresses the provenance half; the rows above say therepresentation half is worth roughly 26 instructions per additional read against node's 3–4,
and that it is invisible to the type system.
proggeramlug commentedon Sep 21, 2026
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: anyvs inferred), not the field,and their receiver is a module-level
const— which is exactly the provenance class thisissue 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:pxnumclass C { a: number; … }pxanyclass C { a: any; … }Δ38.00, 34%, character-identical but for
: numbervs: any— and confirmedstructurally, not just by cost: the
numberarm's loop body contains nojs_dynamic_string_or_number_addand nojs_number_coerce; theanyarm 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=1tooling 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| 0is itself expensive — measured separatelyat +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.proggeramlug commentedon Sep 21, 2026
The
* 1residual is one predicate arm, and it is worth half the remaining gapFollow-up to my own comment above, which claimed a TypeScript annotation buys exactly zero on the
evidence of
mr1vsmrtypedboth measuring 123.00. That comparison was confounded and I amwithdrawing the conclusion: it annotates the binding, not the field, and its receiver is a
module-level
constthatPtr<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 typeis worth 34%:
75.00vs113.00for source that differs only in: numbervs: any, withjs_dynamic_string_or_number_addandjs_number_coerceboth 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,…)witha: number, returned so it escapes scalar replacement whilekeeping
Ptr<Shape>containment:js_dynamic_string_or_number_addvmulsdh = h + o.a;h = h + o.a * 1;* 1halves it.vmulsd=0— LLVM folds the multiply away entirely, so it does no arithmeticwork. It only changes which
Exprnodeexpr_produces_canonical_raw_f64sees: a barePropertyGettakes the arm attype_analysis/numeric.rs:745-752, which requiresfact.numeric_fields.contains(property), whileo.a * 1takes theExpr::Binaryarm, which asksis_numeric_exprand succeeds.On this fixture
ptr_shape_receiver_factresolves (the report says the selection is consumed), sothe failing conjunct is
numeric_fields.contains("a")— on a field whose only store is theconstructor's
this.a = aresolved throughnew C(1,…).The file's own comment predicted this and names the same mechanism for #10777:
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
Binaryarm already vouches for the identicalvalue.
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* 1probe abovecosts 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 —
| 0costs +43 instructions/iteration of open-coded ToInt32on these same fixtures, which is larger than the property read beside it, and every
mr*/ops4regression fixture carries it.
proggeramlug commentedon Sep 21, 2026
The boundary of
Ptr<Shape>guard elision for a module-levelconst, measuredPosting 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 whichPtr<Shape>is demotedto 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/mainf88acda(v0.5.1631), perrymaster x86-64, marginal
instructions:u, min of 3, fitted 500k→5M, everyrow 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 as03d3ae024.Re-measured today (
O.a + O.bin the loop, float accumulator):constthisin a class methodconst, loop at module level, nothing else names itconst, loop at module level, plus one never-called readerconst, loop inside a functionThe 110 and 132 rows are gone. The remaining spread is 36 → 76.
2. The cliff in one pair — and it is not about storage
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 "arepresentation 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:
Ptr<Shape>access sites (expr/property_get/helpers.rs:568,:640)do
lower_expr(ctx, object)on the receiver and thenbitcast/and POINTER_MASK/gep/load. They never read a slot. Whateverlower_expryields — an alloca load or an@perry_global_*load — works unchanged.const K = 7read from inside a functioncosts 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 acrossany opaque call — the rebase-after-safepoint property the shadow-slot argument provides
holds a fortiori.)
expr/slot_rep.rs'smodule_globaldenial is correct for canonical i32/u32/Str slots,which rewrite storage. It is not a statement about
Ptr<Shape>. Reading it as one is whatsent #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
constreachable 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
LocalVarentry is published as a live accessor whose getter wrapper loads@perry_global_*at read time (codegen/helpers.rs:1438) — sons.Ois the same objectunder 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.globalThisneeds no clauseeither: only top-level
varand bare function declarations are reflected in a Script, never atop-level
const.(b) Init-dominance — and neither issue names it. For a function-local, provenance gives
dominance for free: the
Stmt::Letprecedes every use in the same body. For a module-levelbinding the
Letlives inhir.initand the reads live in other regions, so it dominatesnothing. And perry does not enforce TDZ on a module-level
const:The cell holds
TAG_UNDEFINEDand only the guard catches it. A guard-free read would computeTAG_UNDEFINED & POINTER_MASK, gep and load — a wild load, on a program that is merelybuggy. 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 exemptsreturn <the local>because "a return is a terminator … the caller cannot have touched theobject yet" — an argument about a function-local's lifetime. For a module-level binding
the return is a terminator for
leak, not forO: the caller may reshape the record whileevery other region keeps reading
O.fieldat a fixed offset. The array-element-push exemptionfails 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:constfixturePtr<Shape>verdictscalar_replaced" (#7115)keep(O)if (h < 0) return OLane 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. Theconsequence 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 "localconst" 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 thatin mind.
6. Measurement note
Fixtures here carry no
| 0(#10897: it is +43 instructions of ToInt32 and it inflated theops4/mr*rows). Accumulators are floats and the result is printed. The42 → 74pair aboveis 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.
proggeramlug commentedon Sep 21, 2026
The rule-6 work is parked on a branch, not lost —
perf/10769-module-global-ptr-shape7af6bad057c90e15ccb6dc340283065f07a43d70, based onf88acdaa1(v0.5.1631), pushed toPerryTS/perry. No PR: under
OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md§C1Ptr<Shape>is demoted to eliding the last guard and must not gain provenance classes, sothis 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-levelconstmay seedPtr<Shape>when the containment walk covers every region of the moduleand two obligations a function-local never carries are discharged — no export surface
publishes a second name (6b), and the module-level
Stmt::Letprovably runs before any usercode can read the binding (6d, the init-dominance clause). The admitted ids travel through
ModuleDispatchFacts, and every region's owncollect_type_factsre-runs containment overits own statements before consuming one, so a region missing from the enumeration is a region
that still proves itself.
Measured,
upstream/mainf88acda, marginalinstructions:u, min of 3, fitted500k→5M, no
| 0, both armscmp-verified distinct, every row output-identical to node:const, loop inside a functionconst+ one never-called reader (the 42/74 cliff)ops4own3ops4w4ops4inh / inh3export const O— must stay denied (6b)function leak(){ return O; }— must stay denied (6c)const/this/ module-global scalarTDZ 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:
returnand array-element-push exemptions do not transfer. They are justifiedby 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_boundedwithdraws both for a module-level root;returning_the_module_const_from_a_function_denies_itis the regression test.@perry_global_*cell, and its own region's pass already proves it with the numeric-fieldclaim 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-codegenrun was cut short by the charterchange (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.pyonperrymaster has the four prepared sabotages (6b export, 6c module-wide walk, the
returnexemption, 6d init-dominance), each paired with the unit test it must redden.
perf(object): retain owner proofs through own-property reads