Repository navigation
repsel: a proven element fetch is still a runtime call — specialize a[i] inside the element-shape guarded clone #7771
Description
The measurement
On main @ v0.5.1448, fetching an element from an array whose element shape is already proven is still a runtime call.
class P { constructor(public x: number, public y: number) {} }
function run(n: number): number {
const a: P[] = [];
for (let i = 0; i < n; i++) a.push(new P(i, i + 1));
let s = 0;
for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } // <- a[i]
return s;
}--opt-report: local r -> Ptr<Shape> (in function run). The promotion succeeds, the by-name field machinery is gone, and the loop runs under the element-shape guard.
Isolating the read loop (subtracting a build-only variant, because construction is ~12 calls and swamps it):
js_array_get_f64 x1 <- THIS ISSUE
js_number_coerce x2 <- separate issue, see "Related"
js_gc_loop_safepoint x1 <- by design (#7721)
a[i] where a's element shape is proven, i is an in-bounds i32 loop counter, and the result is immediately bound to a Ptr<Shape> local — and it is still a call into the runtime.
Where this sits
The guarded-clone machinery already exists and is already entered here. expr/element_shape_guard.rs provides:
emit_element_shape_loop_preheader_check— run once, before the fast cloneemit_element_shape_field_load— run per access inside the fast clone
So there is already a per-access fast path for the field load inside the clone. The element fetch is what has no equivalent: it still goes through the generic index-get lowering (expr/index_get.rs / expr/index_get/) to js_array_get_f64.
The engine plan names this as the third leg of the same route — invariant bit → versioned-loop consumer → element Ptr<Shape>. The first two shipped (#7480, #7669, #7701). This is the one left.
First thing to establish, because it changes the shape of the fix: confirm whether the fast clone is genuinely entered for this loop, or whether it is admitted and then degrades. #7701 added assert_fast_clone_is_entered for exactly this — a cond_br INTO the clone, not an unconditional branch to the slow path. If the clone is not entered, the fix is upstream of this issue and this ticket is mis-scoped; say so rather than building on it.
Acceptance criteria
- The read loop above emits zero
js_array_get_f64fora[i]. --opt-reportstill showslocal r -> Ptr<Shape>.- The bounds and shape guards still hold. The generic path checks holes, accessors, growth-forwarding stubs, proxies and subclass receivers; a specialized fetch must be guarded such that each of those still reaches correct behaviour. Cover, against Node: an array with a hole (
a[5] = undefined; delete a[3]), one grown past inline capacity mid-loop,a.lengthreduced mid-loop, anArraysubclass, a proxy-wrapped array, and an element replaced with a different shape mid-loop. - The clone must still be entered. Assert
cond_brINTO the fast clone (perf(repsel): the element-shape loop clone fires onarr.lengthand aliased element types (#7480) #7701'sassert_fast_clone_is_entered). fix(gc): restore evacuation at precise safepoints — the pacing half of #7682 #7690's lesson applies directly: a call added inside a clone whose admission rests on being call-free-by-construction deletes the clone rather than slowing it — the loop silently reverts to the slow path and the benchmark still passes. - GC:
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800clean over the reproducer, with the[gc-zeal]verdict showing non-zerocopying_minorsandmoved_objects. A specialized fetch holds a raw element address across the loop body; if a collection moves the array, that address must be re-read.js_array_refresh_local_headexists for this and fix(deforest): the caller's binding kept a growth-forwarding stub after a deforested call (#7661) #7751 has just made the producer side sound — check whether the specialized path needs the same treatment. - Protected floors hold on the pinned quiet mini, interleaved, best-of-N, output byte-verified against Node before timing.
Traps
- Isolate the read loop by subtraction. Build a build-only variant and diff the call multisets, as above; construction dominates otherwise.
- A benchmark win with the clone deleted is a regression that looks like a win — see criterion 4.
--opt-reportis the instrument for whether the promotion still holds.PERRY_NO_AUTO_OPTIMIZE=1changes nothing here (verified identical IR), so it is safe for iteration.- Build
-p perry -p perry-runtime-static -p perry-stdlib-staticand pinPERRY_RUNTIME_DIR, or both arms of an A/B link the same stale.a. - Independent of repsel: rule-1 provenance does not survive a function boundary — a typed
P[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 — the symptom reproduces on the already-promoted local above — but repsel: rule-1 provenance does not survive a function boundary — a typedP[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 is what makes it fire when the array crosses a function boundary.
Related
#7151 covers element read forms (direct A[i].field vs bound const r = A[i], and map/forEach/reduce callback params). It is not this issue: measured, both forms behave identically here, and both still call js_array_get_f64. Do not fold them together, but check #7151 before widening the read forms this fix admits.
#7766 (rule-1 provenance, the hop before), and the numeric-field-slot issue filed alongside this one (the js_number_coerce in the same measurement). Closed context: #7480 / #7669 / #7701 (the guarded clone), #7149 / #7034 §3 (the element rule).
Activity
proggeramlug commentedon Aug 10, 2026
Per the issue's "first thing to establish": the fast clone is NOT entered for this loop — it is never emitted. The matcher only admitted single-statement
acc = arr[j].fieldbodies, so the two-statement reproducer never reached the clone machinery at all; noelement_shape.*block exists in its IR on main. Additionally, the measured per-iterationjs_array_get_f64is already gone at runtime on current main: #7751's producer-side store-back repaired the binding after growth, so the bounded-index path's inline fast arm runs (50M fetches in 0.09s user; forcing the slow arm costs 2.65s). What remains emitted per iteration on main is the bounded-index guard diamond, the hole-select, twojs_number_coercediamonds, and the poll.The ticket's goal stands; the mechanism was upstream, as anticipated. #7780 (draft) implements the upstream fix: the matcher now admits the element-binding body form and the binding is virtual inside the fast clone — the read loop's fast clone contains no calls at all, entered-not-just-emitted asserted, zeal+fromspace-protect clean with 1762 copying minors / 400k moved objects, hazard battery byte-identical to node, mini interleaved best-of-5: 0.07–0.08s → 0.05s user.
Two pre-existing wrong-answer/parity bugs surfaced while building the criterion-3 battery (both A/B-proven identical on pristine main): #7775 (uncalled
new Proxy(arr, {})poisons unrelated read loops module-wide) and #7776 (heterogeneous element viaas any→NaNwhere node concatenates).feat(codegen): admit the element-binding body form into the element-s…
test(gap): behavioral gates for the element-binding read loop (#7771)
repsel: admit the element-binding body form into the element-shape ve…
fix(codegen): drop the trip-count obligation for a non-counter index