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
codegen: proven-Number f64 locals stay nanbox-rooted (not raw double) in functions that read typed arrays — ~4x f64-vs-i32 gap #8619
In a function that reads a typed array, numeric f64 locals — including a fresh, non-loop-carried one — are stored as nanbox GC roots (with a per-write js_write_barrier_root_nanbox) instead of raw doubles, even when their value is a proven Number. This is measurable: a dense Float64Array reduction runs ~4× slower than the equivalent Int32Array one, and the gap is the f64 accumulator's rooting + write-barrier overhead (the i32 accumulator stays native and unrooted).
Surfaced while inlining typed-array reads (#8617). That PR removed the per-read runtime call (Float64Array 3.60s → 1.73s); the remaining ~1.73s vs the i32 path's 0.42s is this rooting overhead.
PERRY_SAVE_LL on hot: 0alloca double, 4alloca ptr addrspace(1) (nanbox), 7js_write_barrier_root_nanbox, 0fadd. Both x and s are nanbox-rooted; the loop compare i < 4096 also round-trips through f64 (sitofp + fcmp + nanbox-bool + icmp eq) instead of a native icmp slt i32.
For comparison, a pure-numeric loop with no typed-array in the function keeps some alloca double + fadd. So the typed-array involvement broadly disables the raw-double representation for the whole function — even for locals unrelated to the array.
What to investigate
Why representation selection (repsel / numeric-envelope) declines the raw-double slot for a proven-Number local in a function that reads a typed array — is a conservative flag set function-wide?
The loop-carried accumulator s = s + <number> needs a fixpoint proof to keep its raw-double representation across the back-edge.
The i32 loop-condition (i < const) should lower to a native icmp slt i32, not the f64 round-trip. (LLVM may already fold the sitofp+fcmp; worth confirming against the optimized asm before treating it as a runtime cost.)
Caveat
Some of the pre-optimization nanbox/round-trip IR is cleaned by LLVM at -Os/-O3, and the write barrier is runtime-guarded (a load+branch, skipped when the incremental marker is inactive). The measured 4× f64-vs-i32 gap says a real residual cost remains, but the exact split (rooting vs barrier vs compare) should be confirmed by profiling the optimized binary. Related: #5497 (type-directed specialization), #8617.
Investigated. There are two distinct sub-cases behind this, with different residual costs on main, so I split them.
The gate
The rooting decision is per-local: a numeric local is un-rooted iff number_by_construction proves it (codegen/function.rs drops such ids from the shadow-slot map, so no js_shadow_slot_bind / js_write_barrier_root_nanbox is emitted and the RS4GC pass leaves the slot a raw alloca double instead of retyping it to alloca ptr addrspace(1)).
Inside that proof, collectors/ptr_shape_numeric.rs::numeric_view_value_or_undefined is the only thing that admits a typed-array element read as "Number-or-undefined, never a pointer" (usable on one side of +, where undefined becomes NaN rather than selecting string concatenation). It recognised only a LOCAL view with a compiler-visible TypedArrayNew init. So for both a spec-proven TaPtrparameter and a module-global typed array, the fresh let x = arr[i] + 1.0 failed the numeric proof, which cascaded to s = s + x — both stayed rooted, and s's update stayed the opaque js_dynamic_string_or_number_add (not fadd).
For a Float64Arrayparameter, the spec-ABI already inlines the read and specializes the body (hot$spec_ta7x4096), so the pre-opt IR of the hot loop was: x correctly a raw double + fadd, but the loop-carried s an alloca ptr addrspace(1) root updated through js_dynamic_string_or_number_add. #8625 teaches the fixpoint that a read off a spec_ta_lens binding is Number-or-undefined (those keys are exactly SpecParamRep::TaPtr, which spec_abi_sites admits only for spec_ta_kind_is_numeric kinds — BigInt arrays are never TaPtr, so the read is never a pointer). Result: the accumulator drops its root and both adds become fadd.
Real, post-LLVM cost confirmed: js_dynamic_string_or_number_add is an opaque call LLVM cannot fold, and the addrspace(1) root is a real RS4GC spill. Measured on a 200000×4096 reduction by parameter: 5.1–7.3s → ~1.0s (~5×), byte-identical output vs the rooted build across the moving-GC matrix and vs Node.
Module-global case (the reproducer) — not worth a rooting-only fix on main yet
For the reproducer as written (let arr = new Float64Array(...) at module scope), the hot loop on main is:
%r16 = call double @js_typed_array_get(i64 %r14, i32 %r15) ; read is a RUNTIME CALL
%r17 = call double @js_dynamic_string_or_number_add(%r16, 1.0) ; add is dynamic
… s and x both alloca ptr addrspace(1) + js_write_barrier_root_nanbox
i.e. the module-global read is not inlined — receiver_class_name doesn't resolve a module global, so PR #8617 (typed-array reads through module-global bindings) is the prerequisite, and it is currently unmerged. With the read still a per-element runtime call, the rooting is a secondary cost: the Float64Array-global and Int32Array-global variants both run ~27s here (the js_typed_array_get call dominates, not the f64-vs-i32 accumulator). So a rooting-only fix for module globals buys little until the read inlines.
The sound extension is ready to sequence after #8617: module_global_proven_types[arr] already proves a single-let, never-reassigned TypedArrayNew global is one specific numeric typed array (the same strength as the TaPtr contract #8625 uses), so feeding those ids into the same numeric_view set un-roots the module-global accumulator too — once the read is inlined for it.
Resolved by #8625 (merged to main): a read off a spec_ta_lens (SpecParamRep::TaPtr) binding is now Number-by-construction, so proven-numeric f64 locals in typed-array functions use a raw double slot instead of a nanbox root (~5x on the param reduction, byte-identical across the moving-GC matrix). The module-global variant from the original report is covered now that #8617 (module-global typed-array read inlining) is merged. Closing.
Activity
proggeramlug commentedon Aug 22, 2026
Investigated. There are two distinct sub-cases behind this, with different residual costs on
main, so I split them.The gate
The rooting decision is per-local: a numeric local is un-rooted iff
number_by_constructionproves it (codegen/function.rsdrops such ids from the shadow-slot map, so nojs_shadow_slot_bind/js_write_barrier_root_nanboxis emitted and the RS4GC pass leaves the slot a rawalloca doubleinstead of retyping it toalloca ptr addrspace(1)).Inside that proof,
collectors/ptr_shape_numeric.rs::numeric_view_value_or_undefinedis the only thing that admits a typed-array element read as "Number-or-undefined, never a pointer" (usable on one side of+, whereundefinedbecomesNaNrather than selecting string concatenation). It recognised only a LOCAL view with a compiler-visibleTypedArrayNewinit. So for both a spec-provenTaPtrparameter and a module-global typed array, the freshlet x = arr[i] + 1.0failed the numeric proof, which cascaded tos = s + x— both stayed rooted, ands's update stayed the opaquejs_dynamic_string_or_number_add(notfadd).Param case — fixed in #8625 (measurable now)
For a
Float64Arrayparameter, the spec-ABI already inlines the read and specializes the body (hot$spec_ta7x4096), so the pre-opt IR of the hot loop was:xcorrectly a rawdouble+fadd, but the loop-carriedsanalloca ptr addrspace(1)root updated throughjs_dynamic_string_or_number_add. #8625 teaches the fixpoint that a read off aspec_ta_lensbinding is Number-or-undefined(those keys are exactlySpecParamRep::TaPtr, whichspec_abi_sitesadmits only forspec_ta_kind_is_numerickinds — BigInt arrays are neverTaPtr, so the read is never a pointer). Result: the accumulator drops its root and both adds becomefadd.Real, post-LLVM cost confirmed:
js_dynamic_string_or_number_addis an opaque call LLVM cannot fold, and theaddrspace(1)root is a real RS4GC spill. Measured on a 200000×4096 reduction by parameter: 5.1–7.3s → ~1.0s (~5×), byte-identical output vs the rooted build across the moving-GC matrix and vs Node.Module-global case (the reproducer) — not worth a rooting-only fix on
mainyetFor the reproducer as written (
let arr = new Float64Array(...)at module scope), the hot loop onmainis:i.e. the module-global read is not inlined —
receiver_class_namedoesn't resolve a module global, so PR #8617 (typed-array reads through module-global bindings) is the prerequisite, and it is currently unmerged. With the read still a per-element runtime call, the rooting is a secondary cost: theFloat64Array-global andInt32Array-global variants both run ~27s here (thejs_typed_array_getcall dominates, not the f64-vs-i32 accumulator). So a rooting-only fix for module globals buys little until the read inlines.The sound extension is ready to sequence after #8617:
module_global_proven_types[arr]already proves a single-let, never-reassignedTypedArrayNewglobal is one specific numeric typed array (the same strength as theTaPtrcontract #8625 uses), so feeding those ids into the samenumeric_viewset un-roots the module-global accumulator too — once the read is inlined for it.PR: #8625
merge: land #8623, #8625, #8627 with metadata fixes and a non-vacuous…
proggeramlug commentedon Aug 24, 2026
Resolved by #8625 (merged to main): a read off a
spec_ta_lens(SpecParamRep::TaPtr) binding is now Number-by-construction, so proven-numeric f64 locals in typed-array functions use a rawdoubleslot instead of a nanbox root (~5x on the param reduction, byte-identical across the moving-GC matrix). The module-global variant from the original report is covered now that #8617 (module-global typed-array read inlining) is merged. Closing.