Skip to content

perf: packed-f64 range loop reloads the array head per element (bench_numeric_array_numeric 45 vs node 38; ceiling measured at 24) #9379

Description

@proggeramlug

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions