Skip to content

codegen: proven-Number f64 locals stay nanbox-rooted (not raw double) in functions that read typed arrays — ~4x f64-vs-i32 gap #8619

Closed

Description

@proggeramlug

Summary

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.

Reproducer

let arr = new Float64Array(4096);
for (let i = 0; i < 4096; i++) { arr[i] = i * 0.5; }
function hot() {
  let s = 0.0;
  for (let i = 0; i < 4096; i++) {
    let x = arr[i] + 1.0;   // fresh, proven Number
    s = s + x;
  }
  return s;
}
console.log("s:" + hot());

PERRY_SAVE_LL on hot: 0 alloca double, 4 alloca ptr addrspace(1) (nanbox), 7 js_write_barrier_root_nanbox, 0 fadd. 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

  1. 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?
  2. The loop-carried accumulator s = s + <number> needs a fixpoint proof to keep its raw-double representation across the back-edge.
  3. 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.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 22, 2026
  2. proggeramlug commented on Aug 22, 2026

    @proggeramlug
    ContributorAuthor

    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 TaPtr parameter 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).

    Param case — fixed in #8625 (measurable now)

    For a Float64Array parameter, 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.

    PR: #8625

  3. added a commit that references this issue on Aug 22, 2026
  4. proggeramlug commented on Aug 24, 2026

    @proggeramlug
    ContributorAuthor

    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.

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

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions