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
Boolean array stores have no inline fast path: 20x node, ~82% of the time in per-store bookkeeping calls #9237
An array store whose value is not a raw f64 — a boolean, null, undefined — permanently misses the inline store fast path, and every subsequent store pays three runtime calls whose first act is to decline to do anything.
The measurement
Same loop, same array length, only the element type differs (Linux box, PERRY_NO_CACHE=1):
loop
perry
node
ratio
flags[i] = i & 1 (number[])
37 ms
7 ms
5.3×
flags[i] = (i & 1) === 0 (boolean[])
180 ms
9 ms
20×
Profile of the boolean version (--debug-symbols, perf):
23.6% typed_feedback::plain_array_index_set_guard
20.6% gc::layout::layout_note_slot_aware
17.5% js_typed_feedback_plain_array_index_set_guard (extern wrapper)
17.1% js_array_note_numeric_write
16.3% perry_fn_..._run ← the actual loop
~82% is per-store bookkeeping; 16% is the loop.
Why it happens
The inline guard in lower_index_set_fast requires the receiver to be dense in the raw-f64 sense — _reserved & (GC_ARRAY_RAW_F64_LAYOUT | GC_ARRAY_RAW_F64_HOLES) != 0. A boolean array loses those bits on its first store (that is what js_array_note_numeric_write does), so from the second store onward the inline guard can never pass and the store falls to js_typed_feedback_plain_array_index_set_guard forever.
The two note calls compound it, and both are idempotent by their own first lines:
js_array_note_numeric_write returns immediately once the raw-f64 bits are clear — which they are, permanently, after store Support custom menu bar items #1. It is called ~20M times to re-discover that.
layout_note_slot_aware returns immediately when neither the new nor the old value is pointer-bearing — always true for booleans.
So the steady state is: a call to decline, a call to decline, and a guard call that does the real work.
Two independent directions
Hoist the declines. Both early-returns are cheap bit tests the caller can do inline; the codegen already has emit_may_carry_heap_pointer_check for the pointer half. That alone addresses ~40% of this benchmark without any new tier. (Care needed: js_array_note_numeric_write's already_clear test also validates the GC header, so an inline version must either reuse a guard arm that already proved the header valid, or keep the call for the unvalidated case.)
Give non-f64 arrays an inline tier. A boolean/tagged element array is pointer-free and fixed-width; the guard it needs is a header check plus a bounds check, not a raw-f64 layout claim. This is the larger piece and would also lift benchmarks/suite/11_prime_sieve.ts (4.5× Node, dominated by sieve[j] = false).
Not attempted here — filing with the measurement and root cause rather than a half-built tier. Related: #9195 removed a fourth per-store call (a dead js_string_addref_if_heap_string) on this same path, worth ~4%.
Located the cheaper half precisely — there is already a working template for it in-tree.
expr/index_set_guarded.rs (~line 306) already skips the numeric-write note when the receiver's header has both raw-f64 bits clear: it tests the _reserved word it just loaded, branches around the call, and its comment gives the safety argument (nothing between the header load and the note can set a raw-f64 bit — the slot store, string addref, layout note and write barrier only ever clear them).
expr/index.rs's lower_index_set_fast does not have that skip. Its three emit_array_numeric_write_note_on_block call sites (≈446, 469, 659) call unconditionally under if !value_is_numeric.
Confirmed from the IR of the boolean-store loop: the note calls land in idxset.inbounds and idxset.extend_inline — lower_index_set_fast's own blocks — and the module contains zeronumnote skip blocks, versus the guarded path which emits them. So the boolean store pays the call per element on a path whose sibling already knows how to avoid it.
Porting the skip is the bounded piece. Two things to watch, which is why I am not attaching a patch:
Header validity. The guarded path's skip sits in an arm that already proved is_array && !forwarded; the runtime's own already_clear test re-validates the GC header via try_read_gc_header and proceeds when the header is unreadable or forwarded. An inline skip must therefore either sit in an arm with the same proof, or keep the call for the unvalidated case — skipping on the raw bits alone would change behaviour for a forwarded receiver.
reserved is not in scope at all three sites (it is loaded at ≈226 and ≈614 in different scopes), and the sites sit inside let blk = ctx.block(); borrows, so a naive edit fights the borrow checker. Re-loading the word at the note site is still far cheaper than the call and is what I would do.
The layout note (layout_note_slot_aware, 23.5%) is the same shape and is the better first target: its early return is if !value_is_pointer && !old_is_pointer — two pure tag tests on values the caller already has, with no header access, so it needs none of the validity care above. emit_may_carry_heap_pointer_check already exists for exactly that test.
Activity
proggeramlug commentedon Aug 31, 2026
Located the cheaper half precisely — there is already a working template for it in-tree.
expr/index_set_guarded.rs(~line 306) already skips the numeric-write note when the receiver's header has both raw-f64 bits clear: it tests the_reservedword it just loaded, branches around the call, and its comment gives the safety argument (nothing between the header load and the note can set a raw-f64 bit — the slot store, string addref, layout note and write barrier only ever clear them).expr/index.rs'slower_index_set_fastdoes not have that skip. Its threeemit_array_numeric_write_note_on_blockcall sites (≈446, 469, 659) call unconditionally underif !value_is_numeric.Confirmed from the IR of the boolean-store loop: the note calls land in
idxset.inboundsandidxset.extend_inline—lower_index_set_fast's own blocks — and the module contains zeronumnoteskip blocks, versus the guarded path which emits them. So the boolean store pays the call per element on a path whose sibling already knows how to avoid it.Porting the skip is the bounded piece. Two things to watch, which is why I am not attaching a patch:
is_array && !forwarded; the runtime's ownalready_cleartest re-validates the GC header viatry_read_gc_headerand proceeds when the header is unreadable or forwarded. An inline skip must therefore either sit in an arm with the same proof, or keep the call for the unvalidated case — skipping on the raw bits alone would change behaviour for a forwarded receiver.reservedis not in scope at all three sites (it is loaded at ≈226 and ≈614 in different scopes), and the sites sit insidelet blk = ctx.block();borrows, so a naive edit fights the borrow checker. Re-loading the word at the note site is still far cheaper than the call and is what I would do.The layout note (
layout_note_slot_aware, 23.5%) is the same shape and is the better first target: its early return isif !value_is_pointer && !old_is_pointer— two pure tag tests on values the caller already has, with no header access, so it needs none of the validity care above.emit_may_carry_heap_pointer_checkalready exists for exactly that test.perf(codegen): gate array-store GC bookkeeping inline (boolean-store …
perf(codegen): gate array-store GC bookkeeping inline (boolean-store …
perf(codegen): gate array-store GC bookkeeping inline (boolean-store …
proggeramlug commentedon Sep 2, 2026
Closing: both halves landed. #9246 gates the two bookkeeping calls inline; #9250 extends the inline store tier to tagged receivers.