Skip to content

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

Open

Description

@proggeramlug

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:

  1. Correct the citation. MODULE_GLOBAL_ISSUE should 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.
  2. File/track the parameter class, which is undocumented in the denial list' issue mapping and is the one most likely to be rediscovered.
  3. 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

  1. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    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.rs states the precondition at the site that enforces it:

    1. Containment: every use of the local is a declared-chain field read/write/update or a vetted method call. Any other use — reassignment, closure capture, call argument, array/object element, throw, delete, freeze/seal, aliasing — disqualifies. The object is therefore unreachable from anywhere except this local, so no §5.2 barrier (defineProperty / delete / setPrototypeOf / Proxy / mutating Reflect) can reach it through an alias.

    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:

    class the second name
    module-level const the module's own binding — every function in the file can reach it
    function parameter the caller's reference, which outlives the call
    local stored into a global the global cell, created by the store itself

    So the denials are correct behaviour. Admitting them as proposed would be a miscompile, not a win.

    What survives, and what does not:

    Where the actual work is. ptr_shape.rs's own opening paragraph names the alternative:

    Today's guarded fast path is per-SITE and runtime-checked … because a receiver at an arbitrary site can be any object.

    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_slow calls 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.

  2. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    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, fixtures
    carry no | 0 (#10897), every row output-identical to node.

    1. The module_global denial text is right for canonical slots and wrong if read as
    covering Ptr<Shape>.
    expr/slot_rep.rs describes 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> does
    not touch storage: its access sites (expr/property_get/helpers.rs:568, :640) call
    lower_expr on the receiver and then mask/gep/load. Measured: a module-global scalar
    const K = 7 read inside a function costs 9.00 instructions against a function-local's
    9.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=text
    says:

    local-const fixture 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)
    escapes in the return position 1 selected, 1 CONSUMED

    An argument escape is denied, so the keep(O) arm was not measuring a held Ptr<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_module is the established enumeration). The clauses that
    genuinely cannot be discharged inside one module are:

    • Export surfaces. An exported binding gets external linkage plus a
      perry_fn_<prefix>__<name> getter, and becomes a live-accessor entry in the module's own
      namespace object
      — run_pipeline.rs:2918 builds namespace entries from the export list,
      and codegen/helpers.rs:1438 publishes a LocalVar entry as a getter that loads
      @perry_global_* at read time, so ns.O is the same object. A non-exported binding never
      becomes a namespace entry, so the namespace channel is entirely subsumed by "is it exported".
    • Init-dominance, which this issue does not mention at all. A module-level Stmt::Let
      dominates nothing outside hir.init, and perry does not enforce TDZ on a module-level
      const: function f(){return O.a} f(); const O = {a:1} reads TAG_UNDEFINED from the cell
      and 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 return and array-element-push exemptions are justified by a function-local's lifetime and
    do 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 that
    elision stops being sound.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions