Fbhchile

7 Insights into V8's Mutable Heap Numbers Optimization

V8's mutable heap numbers optimization yielded a 2.5x speedup in JetStream2's async-fs benchmark by eliminating costly HeapNumber allocations during frequent seed updates in a custom Math.random.

Fbhchile · 2026-05-03 20:07:32 · Environment & Energy

In the relentless pursuit of JavaScript performance, V8's engineers recently turned their attention to the JetStream2 benchmark suite, aiming to smooth out performance cliffs. Their investigation uncovered a fascinating optimization that yielded a remarkable 2.5x speedup in the async-fs benchmark—a result that not only boosted the overall score but also revealed a pattern that appears in real-world code. Here are seven key insights into this transformation, from the surprising bottleneck to the elegant solution of mutable heap numbers.

1. The Unexpected Culprit Behind async-fs

The async-fs benchmark, as its name implies, simulates an asynchronous file system in JavaScript. While one might expect file I/O operations to be the primary performance bottleneck, the real drag came from an unlikely source: a custom implementation of Math.random. This function, used to seed deterministic behavior in the benchmark, turned out to be responsible for significant overhead. By profiling the execution, V8's team discovered that the frequent updates to the seed variable triggered excessive heap allocations. This hidden inefficiency presented a clear opportunity for optimization.

7 Insights into V8's Mutable Heap Numbers Optimization
Source: v8.dev

2. A Homemade Math.random Implementation

The culprit was a manually crafted pseudo-random number generator. The seed variable was updated through a series of bitwise operations on each call, producing a deterministic sequence. The implementation looked like this:

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;
  };
})();

Though efficient from an algorithmic standpoint, its interaction with V8's internal storage mechanisms created a critical performance bottleneck.

3. The Language of Tagged Values

Inside V8, every JavaScript value is represented as a tagged 32-bit entity on 64-bit systems. The least significant bit determines the type: a 0 tag marks a Small Integer (SMI), which stores the value itself directly (left-shifted by one). A 1 tag indicates a compressed pointer to a heap object. For floating-point numbers or integers outside the SMI range, V8 uses HeapNumber objects, which are 64-bit doubles stored on the heap. This approach efficiently handles common integer operations but becomes costly when numbers change frequently.

4. ScriptContext: Where Variables Live

The seed variable resides in a ScriptContext, a storage array for global-like variables within a script. Each slot in the ScriptContext holds a tagged value. For the seed, V8 initially placed a pointer to an immutable HeapNumber object. The ScriptContext layout consists of fixed slots for metadata (like the global object) and variable slots. When seed is updated, the previous HeapNumber is discarded and a new one allocated, leading to pathological allocation behavior.

5. The Cost of Immutable Numbers

Every call to Math.random forced V8 to allocate a new HeapNumber for the updated seed. Heap allocations are relatively expensive due to memory management overhead. In a tight loop performing thousands of calls, this cost became dominant. Profiling revealed that over 40% of the time spent in Math.random was due to allocation and garbage collection of these short-lived HeapNumber objects. The immutable design—intended to simplify optimization—created a severe performance cliff for this mutation-heavy pattern.

6. The Solution: Mutable Heap Numbers

To eliminate the allocation overhead, V8's engineers introduced mutable heap numbers. Instead of replacing the HeapNumber on each update, the engine now allows the seed slot to point to a special mutable HeapNumber whose value can be changed in place. This is achieved by storing the double value directly in the ScriptContext slot (using a special tag) when the slot is known to hold a number that is frequently mutated. The optimization detects such patterns and promotes the variable to a mutable representation, dramatically reducing memory traffic.

7. Real-World Impact and Results

The result was a 2.5x speedup on the async-fs benchmark. Overall JetStream2 scores rose noticeably. Beyond synthetic tests, this optimization benefits real-world code that patterns similar to the benchmark—for example, simulations, game loops, or any JavaScript that frequently updates numeric state in hot paths. Mutable heap numbers represent a broader shift in V8's philosophy: recognizing when immutability is a liability and adapting the engine to embrace mutation without sacrificing safety. This work continues to inspire further optimizations in handling numeric types.

Conclusion: A Fine-Tuned Engine

V8's journey to eliminate performance cliffs often uncovers elegant solutions hidden beneath the surface. The mutable heap numbers optimization showcases how a deep understanding of both JavaScript patterns and engine internals can yield dramatic improvements. By turning a textbook cost (allocation) into a constant-time operation, V8 made everyday code faster without changing a single line of JavaScript. As the engine evolves, such insights will continue to push the boundaries of what's possible in web performance.

Recommended