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 = load ptr addrspace(1), ptr %slot ; array head, from the GC shadow slot
%data = load i64, ptr %dataslot ; data pointer
... shl/add to the element address ...
store double %val, ptr %elem
%c = load i32, 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.
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] + 1read-modify-write, 250k elements x 250 iterations, quiet host, min-of-3:number[](the benchmark)Float64Array, identical loopTwo 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:expr/index_set_packed_loop.rsderives the receiver withlower_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.rsalready 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.PERRY_GC_FORCE_EVACUATEarm 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.