You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480
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").
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.
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).
#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 preservingclass_id, so a class-id match alone does not prove the
layout;
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:
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.
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.
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