V8's Mutable Heap Numbers: Turbocharging JavaScript Performance
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:
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.randomtriggers the allocation of a new HeapNumber object for the updatedseed. 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.