Gbuck12DocsEnvironment & Energy
Related
Navigating the Roadblocks: Key Questions About EV Adoption in the US Auto MarketTesla Semi Achieves Volume Production: Key Milestones and InsightsA Fleet Operator’s Guide to Tesla’s Semi Charging Infrastructure: Basecharger and MegachargerGreenlane's Electric Truck Charging Expansion: A Texas-Sized Leap ForwardAligning Climate Agendas: The Convergence of Australian Energy Policy with Hanson and TrumpNew 400MW Battery Project Approved Between Solar Farms in VictoriaHow to Maximize AI Training and Agent Performance with Google's Latest TPUs10 Fresh Desktop Wallpapers to Celebrate May 2026

V8's Mutable Heap Numbers: Turbocharging JavaScript Performance

Last updated: 2026-05-17 12:57:55 · Environment & Energy

In the relentless pursuit of faster JavaScript execution, the V8 team constantly analyzes benchmark suites to identify performance cliffs. A recent deep dive into JetStream2 uncovered a remarkable optimization opportunity in the async-fs benchmark that led to a 2.5x speed improvement and a noticeable overall score boost. This article dissects the problem — and the elegant solution centered on mutable heap numbers — that turned a routine benchmark pattern into a substantial win.

The async-fs Benchmark and a Math.random Surprise

Despite its name, the async-fs benchmark simulates an asynchronous JavaScript file system. Surprisingly, its performance bottleneck wasn't I/O-related but stemmed from a custom, deterministic implementation of Math.random. This custom function ensures consistent results across runs and relies on a single mutable variable — seed — updated on every call:

V8's Mutable Heap Numbers: Turbocharging JavaScript Performance
Source: v8.dev
let seed;
Math.random = (function() {
  return function () {
    seed = ((seed + 0x7ed55d16) + (seed << 12))  & 0xffffffff;
    seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
    seed = ((seed + 0x165667b1) + (seed << 5))   & 0xffffffff;
    seed = ((seed + 0xd3a2646c) ^ (seed << 9))   & 0xffffffff;
    seed = ((seed + 0xfd7046c5) + (seed << 3))   & 0xffffffff;
    seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
    return (seed & 0xfffffff) / 0x10000000;
  };
})();

The seed variable is stored in a ScriptContext — an internal array of tagged values accessible within a script. On 64-bit V8, each slot occupies 32 bits, using a tagging system that differentiates between small integers (SMIs) and pointers to heap objects.

How V8 Tags and Stores Numbers

V8 uses the least significant bit as a tag: 0 indicates a 31-bit SMI (stored directly, shifted left by one bit), while 1 indicates a compressed pointer to a heap object (incremented by one). This allows efficient handling of common integer values without heap allocation. However, numbers that don't fit in the SMI range — or have fractional parts — must be stored as HeapNumber objects, each a 64-bit double residing on the garbage-collected heap. The ScriptContext then holds only a pointer to that immutable heap object.

In the async-fs benchmark, the seed variable is an integer outside the 31-bit SMI range after the first arithmetic operation, so it is stored as a HeapNumber. And because HeapNumbers are immutable, every update to seed requires allocating a new HeapNumber on the heap.

The Performance Bottleneck

Profiling Math.random in the benchmark revealed two intertwined issues:

  • HeapNumber allocation thrashing: Each call to Math.random triggers the allocation of a new HeapNumber object for the updated seed. Since the benchmark exercises this function millions of times, the allocation cost becomes a major drain.
  • Garbage collector pressure: The old HeapNumbers become garbage almost immediately, burdening the GC with frequent collection cycles and further slowing execution.

Together, these issues turned a simple variable update into an expensive operation, effectively creating a hidden performance cliff in an otherwise well-optimized benchmark.

The Fix: Mutable Heap Numbers

The V8 team's insight was straightforward: if the seed variable is always used as a numeric value that changes often, why force it through immutable heap objects? The optimization involved making the HeapNumber slot in the ScriptContext mutable — allowing the double value to be updated in place without allocating a new object each time.

This required changes to V8's internal representation of ScriptContext slots. Instead of always storing a pointer to an immutable HeapNumber, V8 now recognizes when a slot is used for a frequently mutated numeric value and converts it to a mutable heap number representation. The side effect: the slot now contains the double directly (as an untagged value), bypassing the heap allocation entirely.

The result was dramatic. The async-fs benchmark saw a 2.5x speedup, directly contributing to an improvement in JetStream2's overall score. While the optimization was inspired by the benchmark, similar patterns — such as counters or accumulators updated in tight loops — appear in real-world JavaScript applications.

Conclusion

This optimization demonstrates how careful attention to the interaction between language semantics and internal representation can yield substantial performance gains. By replacing immutable HeapNumber allocations with a mutable in-place update mechanism for frequently changed numeric values, V8 eliminated a hidden bottleneck that affected both allocation and garbage collection. The async-fs benchmark served as an ideal testing ground, but the same technique can benefit real-world code that repeatedly mutates numeric variables stored in the ScriptContext. V8's mutable heap numbers are a perfect example of how small, targeted changes can turbocharge JavaScript performance.