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
perf: packed-f64 range loop reloads the array head per element (bench_numeric_array_numeric 45 vs node 38; ceiling measured at 24) #9379
The last suite row above node that is not covered by an open PR, and it has a measured ceiling that turns it into a win.
Measurement
Same arr[i] = arr[i] + 1 read-modify-write, 250k elements x 250 iterations, quiet host, min-of-3:
receiver
perry
node
number[] (the benchmark)
45
38
Float64Array, identical loop
24
86
Two things follow. perry's typed path is 3.6x faster than node's on this shape, so nothing about the arithmetic or the store is slow. And perry's own number[] path is 1.9x slower than its own typed path — the entire gap is what the array receiver costs per element, not the work.
Mechanism
The loop already takes the packed-f64 range tier (packed_f64_range_store.fast), and its per-element body is:
%head.rs4p = loadptraddrspace(1), ptr%slot; array head, from the GC shadow slot%data = loadi64, ptr%dataslot; data pointer
... shl/addto the element address ...
storedouble%val, ptr%elem%c = loadi32, ptr%i; and i32 %c, 63 ; icmp eq ; br ; strided GC poll
expr/index_set_packed_loop.rs derives the receiver with lower_expr(ctx, &Expr::LocalGet(arr_id))per store site, which emits that shadow-slot load on every iteration. The typed-array path has no such indirection, which is exactly the 45-vs-24 difference.
Why hoisting is licensed — and where the risk is
The tier's own doc already asserts what the hoist needs: "the matcher proved the body cannot invalidate any of that mid-loop (no calls/closures/awaits, stores only through this path)". So the head can move only at the strided poll, not at an arbitrary point. The shape of the fix is therefore: cache the handle in the loop preheader (where the guard is already emitted), read the cached value in the body, and refresh it only on the iterations where the poll actually fires.
This is GC-root caching across a safepoint, which is the highest-risk change class in this tree — a stale cached pointer is a moved or freed object, not a slow path. Two things to build it against rather than reason about:
expr/element_shape_guard.rs already has the repair primitive (js_array_refresh_local_head) for precisely "the head may have moved, re-derive it". The packed-f64 tier does not use it; the element-shape tier does. Reuse that rather than inventing a second mechanism.
The refresh must be tied to the poll firing, not to the poll being present: the whole point is that 63 of 64 iterations have no safepoint. An off-by-one here is silent until an evacuating collection lands mid-loop, so it needs a PERRY_GC_FORCE_EVACUATE arm with the collection forced inside the loop, not merely at its boundaries.
Not attempted
I characterized this but did not implement it. It is the smallest gap on the board (1.18x) and the highest-risk area to change while carrying six other changes from the same session — three of which needed correction or deletion before shipping. Filing with the ceiling measured so whoever takes it starts from evidence rather than from the hypothesis.
Fixed in #9360 (commit the packed-f64 loop clone needs no GC poll). The cause was not the array-head reload this issue hypothesised — a forced-arm build with polls disabled hit the same 38 ms ceiling, and the poll's VOLATILE armed load turned out to be a clobber inside the loop that forced the cached receiver base to be re-derived per element. That is why striding it 1-in-64 (#9316) never recovered the loss while removing it does. bench_numeric_array_numeric 45 -> 38 ms against node's 38. Closing on merge of #9360.
Closing: fixed in #9433 (the packed-f64 loop clone needs no GC poll); bench_numeric_array_numeric 45 -> 38 ms, node parity. The array-head reload hypothesis was not the cause.
Activity
proggeramlug commentedon Sep 1, 2026
Fixed in #9360 (commit
the packed-f64 loop clone needs no GC poll). The cause was not the array-head reload this issue hypothesised — a forced-arm build with polls disabled hit the same 38 ms ceiling, and the poll's VOLATILE armed load turned out to be a clobber inside the loop that forced the cached receiver base to be re-derived per element. That is why striding it 1-in-64 (#9316) never recovered the loss while removing it does.bench_numeric_array_numeric45 -> 38 ms against node's 38. Closing on merge of #9360.proggeramlug commentedon Sep 2, 2026
Closing: fixed in #9433 (the packed-f64 loop clone needs no GC poll); bench_numeric_array_numeric 45 -> 38 ms, node parity. The array-head reload hypothesis was not the cause.
perf(codegen): read the packed loop's element base from the hoisted r…
perf(codegen): the packed loop's counter read shades no GC root