Skip to content

repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480

Closed

Description

@proggeramlug

Scoping for the repsel stack's next gap after 3b/4a: shape proofs do not survive an array element read. keep[j] yields an untyped JSValue, so .v re-enters the field-read diamond that 3b deleted for proven locals. This is the churn-probe access shape and the blocker named in the phase sequencing ("element-shape proof through arrays").

Measured (2026-08-06, release build @ main, checksums equal everywhere)

Kernel: keep: {v,w}[] of 200k, sum += keep[j].v × 50 sweeps:

runtime ms
node 15
bun 23
perry 93

6.2× vs node / 4× vs bun on the pure shape. IR inspection: the loop body carries no out-of-line guard calls (4a's inline element tier + the #5093-family inline field precheck are doing their job) — the cost is the stacked inline diamonds: element-read tier (tag/handle-band/header/layout tests) feeding a field-read precheck (gate load + tag + handle-band + 7 dependent header loads), per iteration. Exactly the 3b recon's "the guard is the cost", now twice per access.

The route decision: both candidate routes share one prerequisite

Route A — element representation (phase-4c style): prove keep : Array<Ptr<__AnonShape_…>> from construction flow, so element reads yield proven Ptr<Shape> and field reads emit bare gep+load. Full fix; helps non-loop access too.

Route B — extend the #5093 versioned-loop clone (machinery just revived by #7425; 3b's recon calls the clone "the only unguarded form today"): hoist ONE whole-array element-shape guard into the preheader, clone the body with bare reads.

The scoping finding is that B is not actually cheaper on the load-bearing part: a preheader "all elements are shape X" test needs a per-array homogeneous-element-shape invariant that does not exist today (the only array-level invariants are the numeric-layout bits, GC_ARRAY_RAW_F64_LAYOUT/_HOLES). Maintaining that invariant — set at construction, updated/cleared at every store site, self-healing like 4a's dense bit — is the same core mechanism Route A needs. Once it exists:

  1. B consumes it first (small codegen change: preheader guard + cloned body via the existing versioned-loop machinery) — fast win on loop-shaped access, which is where the churn probes live.
  2. A consumes it fully (element reads yield Ptr<Shape>, composing with 3b's proven-local machinery) — the durable fix.

So the sequencing is: invariant bit → B → A, not "A vs B".

Constraints carried from the stack

  • Construction-flow proofs only — TS annotations are hints, never layout (docs/src/internals/…, RFC §9's "typing an array makes it slower today" trap is the cautionary case).
  • Anon-shape literals are already classes (3b recon: closed-shape {k:v} lowers to __AnonShape_<hash> with a synthesized ctor), so the invariant's shape id exists for free at the allocation site.
  • The perf(codegen): keep the numeric-array specialization when the array is captured (#6369) #6377 gate: every added proof un-gates latent fast paths its own microbench never exercises. Acceptance is the full gap suite green against a same-session main baseline, plus the churn/ratchet probes stdout-identical to the node oracle — not the kernel above.
  • Arrays are movable and growth installs forwarding pointers; the invariant bit lives in the header the collector already maintains, and any cached raw base/length stays region-local SSA, re-derived after safepoints (same rule 4a followed).

Activity

  1. added 12 commits that reference this issue on Aug 6, 2026
  2. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    #7612 lands the narrow sound slice of the versioned-loop clone (route B in
    #7480's sequencing: invariant bit → B → A). Recording what it deliberately left
    out, so the next slice starts from a known state rather than a re-derivation.

    Shipped: for (let j = k0; j < B; j++) acc = <pure numeric over arr[j].field>
    where arr has a declared class element type, behind a preheader guard on
    js_array_ensure_element_shape plus an explicit GC_TYPE_ARRAY brand. Measured
    41 ms → 13 ms (3.15×) on the #7480 access shape, now at parity with node, at
    +0 bytes on programs with no qualifying loop.

    Revocation is handled by restrict-the-body: the matcher admits no stores or
    calls, and the lowering then verifies the emitted fast clone is call-free and
    branches unconditionally to the slow clone if it is not. Every revocation funnel
    is a runtime call, so call-free ⇒ nothing can revoke and nothing can move the
    array.

    Extension 1 — fold the residual per-element facts into the invariant

    The clone still pays a per-element check, because the array-level invariant
    covers POINTER_TAG / GcHeader readability / GC_TYPE_OBJECT /
    OBJECT_TYPE_REGULAR / class_id but not the per-object facts a raw-f64
    slot load needs:

    • keys_array identity — delete elem.f compacts the packed inline slots
      while preserving class_id, so a class-id match alone does not prove the
      layout;
    • field_count;
    • OBJ_FLAG_HAS_DESCRIPTORS (instance-level installs deliberately do not flip
      the process-global latch, perf: class-field inline fast path (#5093) is disabled process-wide in normal runs #5654);
    • GC_OBJ_TYPED_LAYOUT_INTACT — dropping this one is the miscompile: a
      downgraded element holds a NaN-boxed value where the clone reads a raw
      double.

    Today they cost one 4-byte load of the three contiguous GcHeader bytes, plus a
    field_count load, a keys_array load, and one side-exit branch per access.
    Folding them into element_class_of_bits would make the reads bare — but each
    one needs an invalidation surface that does not exist: delete elem.f,
    Object.defineProperty(elem, …), and typed-layout downgrade do not funnel
    through note_element_store, and a record that goes stale on any of them is a
    miscompile rather than a slow path. #7496 kept the maintenance matrix
    enumerable precisely by not opening that surface, so this should land the way
    #7496 did: invariant first, revocation matrix second (#7608's shape), consumer
    third.

    Extension 2 — object-literal element types (#7480's own kernel)

    keep: {v,w}[] is untouched: 86 ms, 8.6× node, while the named-class kernel is
    now at 13 ms. The class genuinely exists (an object literal lowers to
    New { __AnonShape_<hash> } with a synthesized constructor) and matching the
    declared object type's property_order against this module's __AnonShape_*
    classes finds it — that was implemented and then removed, because the class
    name alone buys nothing: receiver_class_name returning None for an
    Object-typed element is also what makes
    lower_raw_f64_class_field_get_for_number_context decline, so the read still
    lowers through the generic diamond, the clone fails its call-free check, and the
    only product is a block of dead fast-clone IR.

    The real prerequisite is teaching static_type_of / receiver_class_name to
    type an Object-typed property read. That is squarely the #6377 hazard ("more
    type visibility un-gates latent fast paths its own microbench never exercises")
    and needs its own PR with a full gap-suite A/B against a same-session main.

    Extension 3 — widen the admitted loop shape

    Currently: one array, index is exactly the counter, single store-free statement,
    element class has no base class. Natural widenings, roughly in order of
    value/risk:

    1. Offset indices (arr[j+1]) — needs the preheader's length >= bound
      check widened to the actual window, the way simple EMA (Exponential Moving Average) loop over a 100k-element array runs **7x slower** in the compiled Perry binary (2300ms) compared to tsx (328ms) or node (343ms). #6011's range loop does it.
    2. Multiple statements — safe for reads only (no side exit can leave a
      partial effect), same argument packed_f64_range_loop_dense_body_collect
      already makes.
    3. More than one tracked array.
    4. Element classes with a base — needs the packed slot index resolved
      through the inheritance chain; a native base (extends Array) must stay
      declined, it is the fix(runtime): a Map/Set subclass in a base-typed binding was read as a raw header (#7570) #7573/fix(runtime): an Array subclass in a base-typed binding was read as a raw header (#7574) #7603 hazard.

    Extension 4 — make the clone survive moving loop polls

    Under PERRY_GC_MOVING_LOOP_POLLS=1 (compile-time, off by default since #7161)
    the back-edge safepoint is itself a call, so the call-free scan fails and the
    clone stands down — verified in the corpus IR, where the deref block emits an
    unconditional br to the slow preheader. That is the correct behaviour today
    (a collection at the poll could move the array out from under the cached
    elements base), but it means the optimization silently disappears if that
    default ever flips. Making it poll-compatible means re-deriving the elements
    base after each safepoint and re-validating the proof identity
    (js_array_element_shape_check(arr, class_id, epoch) already exists for exactly
    this), which is the per-back-edge re-check option #7612 considered and declined
    for the first slice.

    Route A is still the durable fix

    None of the above replaces #7480's Route A — element reads yielding a proven
    Ptr<Shape> so field reads emit bare gep + load composing with 3b's
    proven-local machinery, and helping non-loop access too. Extension 1 is its
    prerequisite as much as it is this clone's.

  3. 17 remaining items

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