Repository navigation
repsel: Ptr<Shape> is denied to three storage classes (module globals, parameters, escaping locals) — worth 3.0x on property reads, and the one documented class cites closed #7109 which is a different mechanism #10803
Description
Summary
Ptr<Shape> is denied to three storage classes, not one. The denial costs 3.0× on the most common operation in JavaScript, and the one class that is documented in the source points at a closed issue about a different mechanism, which is why this keeps being rediscovered under other names.
Measured on v0.5.1618 (merge train 239), perrymaster, warm fit 500k→5M, perf stat -e instructions:u, min of 3. perry is flat at both fit ranges on every row; node is not, so only the warm column is quoted. Output byte-identical to node on every row.
Same scope in every row — the loop is inside run(n) — and only the binding site of O differs:
function run(n){ let h=0; for(let k=0;k<n;k++) h += O.a + O.b; return h; }binding of O |
perry | node | optimiser report |
|---|---|---|---|
function-local const |
36 | 12.21 | Ptr<Shape> 1 selected / 0 denied |
module-level const |
108 | 9.67 | 0 selected / 1 denied |
| function parameter | 109 | 12.89 | 0 selected / 1 denied |
The cost tracks PERRY_OPT_REPORT=text exactly. The fact is worth 72 instructions per iteration — 3.0× — and with it perry is 2.9× node instead of 11.2×.
Scalar replacement is ruled out
The obvious objection is that the local-const arm is cheap because the object never escapes and is scalar-replaced. It is not (measured on v0.5.1617):
| perry | Ptr<Shape> |
|
|---|---|---|
local const, never escapes |
40 | 1 selected |
local const, escapes into keep(O) |
47 | 1 selected |
local const, escapes into a module-level global |
109 | 0 selected |
The middle row genuinely escapes into a called function and still costs 47. The third row is the interesting one: the same function-local binding loses the fact as soon as it is stored into a module-level global, which is the third denied class.
The module-global denial cites a closed issue about a different mechanism
crates/perry-codegen/src/expr/slot_rep.rs:453:
const MODULE_GLOBAL_ISSUE: &str = "#7109";surfaced at :372 for the module_global denial, described as "a module-level binding, held in an @perry_global_* NaN-boxed LLVM global rather than a local slot", Tier::CompilerLimitation.
#7109 is closed (2026-07-31) and is about something else: "module-init / program-entry bodies are excluded from canonical i32+Str selection" — the repsel_context_allows_canonical_i32: false context gate in codegen/entry.rs. That gate was a different defect, it has since been lifted, and lifting it is visible here: a module-level loop now reports Ptr<Shape> 1 selected and costs 42 rather than ~110.
So anyone who reads an optimiser-report denial for a module global is sent to a closed issue describing a gate that no longer exists. That miscitation is load-bearing: it is why a previous analysis on this campaign concluded module-global storage was "a different and, on this evidence, less important blocker" and moved on.
Evidence that the denied path is not reached by fast-path work
Three property-read improvements landed between v0.5.1617 and v0.5.1618 — #10570 and #10651 (dynamic-key reads) and #10646 (static-key reads, "retired the per-access class-field latch, 28→23 instructions on the fast path"). Same fixtures, both versions:
| fixture | v0.5.1617 | v0.5.1618 |
|---|---|---|
| function-local receiver | 40 | 36 |
| module-level loop | 47 | 42 |
| module-global receiver | 108 | 108 |
| parameter receiver | 109 | 109 |
The rows that hold the fact improved; the denied rows did not move by a single instruction. Fast-path work on property reads does not reach a receiver in a denied storage class, so the denial compounds rather than being eroded by ordinary optimisation.
Why the parameter class matters disproportionately
function f({a, b}) binds through a parameter. So destructuring a parameter measures this denial, and reads as "destructuring is slow" rather than as a storage-class limitation. At least one parallel effort was scoped as a destructuring defect before this was identified. The parameter class has no tracking issue at all.
What is being asked for
Three separate things, which may not share a fix:
- Correct the citation.
MODULE_GLOBAL_ISSUEshould point at a live issue describing module-global storage, not at closed repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109. - File/track the parameter class, which is undocumented in the denial list' issue mapping and is the one most likely to be rediscovered.
- The escaping-local class — a local that is stored into a module-level global loses a fact it demonstrably held.
The representation work itself (slot-reload discipline for a global cell) is the larger item and is not proposed here. This issue is to make the three classes visible and correctly attributed, because the measured cost — 3.0×, on property access, not eroded by two rounds of fast-path optimisation — is large enough that it should not keep being lost to a stale pointer.
Reproduction
Fixtures /root/ops4/q_{mod,loc,par}.ts and the escape controls /root/ops4/r_{loc,esc,esc2}.ts on perrymaster. Fit at two widely separated ranges and check perry's figure is stable across them — it is AOT and has no warmup, so a moving perry number means the fixture is wrong. Note that --debug-symbols, needed for profiling, changes the emitted code (it adds js_set_call_location: objdump shows 0 occurrences without it, 6 with), so profile totals taken under it are not production costs.
Activity
proggeramlug commentedon Sep 20, 2026
Withdrawing the central request: these three classes are denied soundly, and I should have read the rule before filing
This issue asked for three storage classes to be admitted to
Ptr<Shape>. That request is wrong and I withdraw it. The measurements stand; the diagnosis of what they mean does not.collectors/ptr_shape.rsstates the precondition at the site that enforces it:Exclusive reachability is not an implementation detail of the optimisation — it is the optimisation. A compile-time field offset is only sound while nothing can reshape the object behind the access. All three classes I filed hold a second reference by construction:
constSo the denials are correct behaviour. Admitting them as proposed would be a miscompile, not a win.
What survives, and what does not:
O.a + O.bcosts 108 with a module-global receiver, 109 with a parameter, and 36 with an exclusively-held function-local. The gap is real and the cost tracks the optimiser report exactly.slot_rep.rs:453citesMODULE_GLOBAL_ISSUE = "#7109"for the module-global denial, and repsel: module-init / program-entry bodies are excluded from canonical i32+Str selection, so a top-level hot loop promotes nothing #7109 is closed and describes a different mechanism (the entry-body context gate, since lifted). That citation is still wrong and still misleads readers — including me. Worth fixing on its own.UseWalkis a struct withwalk_stmtsand local ids are module-unique, so it is feasible; it is a real analysis, not a gate flip.Where the actual work is.
ptr_shape.rs's own opening paragraph names the alternative:That is the right mechanism for a receiver with more than one name, and perry already has it (#9708's per-site inline caches). A module-global read is already using it — callgrind confirms the read is inline and the
js_object_get_field_ic_nonptr/_ic_slowcalls in the emitted loop are cold branches that never execute.So the well-posed question is not "why isn't
Ptr<Shape>granted here" but "why does the per-site guarded read cost ~50 instructions when node's equivalent costs ~6?" No correctness rule blocks that one. I'm retitling my effort accordingly and will open a focused issue against the guarded read path rather than continue here.Apologies to anyone who redirected work on the original framing — that includes a parallel session that stood down a destructuring lane on my account.
fix(codegen): point the module-global Ptr<Shape> denial at #10803, not …
proggeramlug commentedon Sep 21, 2026
Three corrections to this issue, measured on f88acda (v0.5.1631)
Full working posted on #10769 (comment 5763030734); this is the part that bears on the three
storage classes filed here. Marginal
instructions:u, min of 3, fitted 500k→5M, fixturescarry no
| 0(#10897), every row output-identical to node.1. The
module_globaldenial text is right for canonical slots and wrong if read ascovering
Ptr<Shape>.expr/slot_rep.rsdescribes the class as "held in an@perry_global_*NaN-boxed LLVM global rather than a local slot",Tier::CompilerLimitation.That is exactly true for i32/u32/Str, which rewrite the binding's storage.
Ptr<Shape>doesnot touch storage: its access sites (
expr/property_get/helpers.rs:568,:640) calllower_expron the receiver and then mask/gep/load. Measured: a module-global scalarconst K = 7read inside a function costs 9.00 instructions against a function-local's9.00. The global cell costs nothing. So the remaining module-global gap was never
representation work — it is a containment region that stops at one function body.
2. This issue's escape-control row does not reproduce. The body reports "local
const,escapes into
keep(O): 47,Ptr<Shape>1 selected". On v0.5.1631,PERRY_OPT_REPORT=textsays:
constfixturescalar_replaced" (#7115)keep(O)An argument escape is denied, so the
keep(O)arm was not measuring a heldPtr<Shape>fact;containment and scalar replacement are nearly the same condition, and the return position is
the only spelling that gives a local-receiver arm which is both non-scalar-replaced and
actually proven. Confirms what lane 14 found independently. Every "local const" row on this
campaign should be re-read against that.
3. The withdrawal's reasoning is half right, and the half it misses is the load-bearing
one. The withdrawal says the module-global denial is sound because "the module's own binding
— every function in the file can reach it" is a second name. Being readable from every region
of the module is not a second name; it is a wider containment region, and it is walkable
(
spec_abi_sites.rs::scan_whole_moduleis the established enumeration). The clauses thatgenuinely cannot be discharged inside one module are:
perry_fn_<prefix>__<name>getter, and becomes a live-accessor entry in the module's ownnamespace object —
run_pipeline.rs:2918builds namespace entries from the export list,and
codegen/helpers.rs:1438publishes aLocalVarentry as a getter that loads@perry_global_*at read time, sons.Ois the same object. A non-exported binding neverbecomes a namespace entry, so the namespace channel is entirely subsumed by "is it exported".
Stmt::Letdominates nothing outside
hir.init, and perry does not enforce TDZ on a module-levelconst:function f(){return O.a} f(); const O = {a:1}readsTAG_UNDEFINEDfrom the celland only the guarded path turns that into a TypeError. A guard-free read would do
TAG_UNDEFINED & POINTER_MASK, gep and load.And one finding for anyone who ever widens a containment region past one function body: rule
2's
returnand array-element-push exemptions are justified by a function-local's lifetime anddo not transfer. A module-wide walk that inherits them proves
function leak(){ return O; },which hands the record to a caller who can reshape it while other regions keep reading it at a
fixed offset. Re-derive the exemptions; do not inherit them.
Under
OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md§C1 none of this is a fix plan —Ptr<Shape>is demoted to eliding the last guard — but the boundary above is where thatelision stops being sound.