Repository navigation
Description
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:
- 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.
- 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
docs(engine-plan): 2026-08-06 ticket sync and repsel stack position (#…
fix(#7480): read element headers through the canonical validated acce…
perf(#7480): drop the keepalive anchors until a consumer exists
test(#7480): serialize the element-shape tests and close the epoch-in…
feat(repsel): per-array homogeneous element-shape invariant (#7480)
style: rustfmt the #7480 element-shape module and its tests
fix(#7480): read element headers through the canonical validated acce…
perf(#7480): drop the keepalive anchors until a consumer exists
test(#7480): serialize the element-shape tests and close the epoch-in…
perf(repsel): per-array homogeneous element-shape invariant (#7480) (#…
test(gc): drive the element-shape revocation matrix through the real …
perf(repsel): element-shape versioned loop clone (#7480 / #5093)
proggeramlug commentedon Aug 8, 2026
#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
arrhas a declared class element type, behind a preheader guard onjs_array_ensure_element_shapeplus an explicitGC_TYPE_ARRAYbrand. Measured41 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/GcHeaderreadability /GC_TYPE_OBJECT/OBJECT_TYPE_REGULAR/class_idbut not the per-object facts a raw-f64slot load needs:
keys_arrayidentity —delete elem.fcompacts the packed inline slotswhile preserving
class_id, so a class-id match alone does not prove thelayout;
field_count;OBJ_FLAG_HAS_DESCRIPTORS(instance-level installs deliberately do not flipthe 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: adowngraded element holds a NaN-boxed value where the clone reads a raw
double.Today they cost one 4-byte load of the three contiguous
GcHeaderbytes, plus afield_countload, akeys_arrayload, and one side-exit branch per access.Folding them into
element_class_of_bitswould make the reads bare — but eachone needs an invalidation surface that does not exist:
delete elem.f,Object.defineProperty(elem, …), and typed-layout downgrade do not funnelthrough
note_element_store, and a record that goes stale on any of them is amiscompile 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 isnow at 13 ms. The class genuinely exists (an object literal lowers to
New { __AnonShape_<hash> }with a synthesized constructor) and matching thedeclared object type's
property_orderagainst this module's__AnonShape_*classes finds it — that was implemented and then removed, because the class
name alone buys nothing:
receiver_class_namereturningNonefor anObject-typed element is also what makeslower_raw_f64_class_field_get_for_number_contextdecline, so the read stilllowers 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_nametotype an
Object-typed property read. That is squarely the #6377 hazard ("moretype 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:
arr[j+1]) — needs the preheader'slength >= boundcheck 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.
partial effect), same argument
packed_f64_range_loop_dense_body_collectalready makes.
through the inheritance chain; a native base (
extends Array) must staydeclined, 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
brto 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 exactlythis), 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 baregep + loadcomposing with 3b'sproven-local machinery, and helping non-loop access too. Extension 1 is its
prerequisite as much as it is this clone's.
perf(repsel): element-shape versioned loop clone (#7480 / #5093)
perf(repsel): element-shape versioned loop clone (#7480 / #5093) (#7612)
17 remaining items
perf(repsel): stop emitting the two calls that silently delete a guar…
perf(repsel): the element-shape loop clone fires on `arr.length` and …
perf(typed-feedback): stop emitting recording calls into default buil…
perf(typed-feedback): stop emitting recording calls into default buil…
fix(gc): make the moving-loop poll default ON in the code, not just t…
fix(gc): make the moving-loop poll default ON in the code, not just t…
perf(class): stop disarming every dispatch guard when a class prototy…
perf(class): stop disarming every dispatch guard when a class prototy…
perf(class): stop disarming every dispatch guard when a class prototy…
fix(codegen): strict `o.x += 1` on an inherited non-writable / access…
feat(runtime): prove the element shape of class-0 record arrays
feat(codegen): key the element-shape loop clone on a runtime shape
perf(gc): relocation moves only address-keyed layout records (#10362)
perf(runtime): drop the provably-no-op layout note from the raw-f64 push
perf(runtime): drop the provably-no-op layout note from the raw-f64 push