Introduction
At the V8 team, our mission is to make JavaScript faster and more efficient. Every release brings subtle yet powerful tweaks under the hood. One such optimization recently caught our attention while analyzing the JetStream2 benchmark suite. By resolving a hidden performance bottleneck in the async-fs benchmark, we achieved a stunning 2.5× speedup in that test alone, contributing to a noticeable overall score improvement. Although the fix was motivated by a benchmark, the underlying pattern appears frequently in real-world JavaScript code.
The Challenge: A Custom Math.random
The async-fs benchmark simulates a file system in JavaScript, focusing on asynchronous operations. However, the performance culprit turned out to be something seemingly unrelated: the implementation of Math.random. For reproducibility, the benchmark uses a deterministic, custom pseudo-random number generator (PRNG) that updates a seed variable on every call. The code is roughly:
let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
...
return (seed & 0xfffffff) / 0x10000000;
};
})();
The critical variable here is seed. It is stored in a ScriptContext, an internal V8 data structure that holds values accessible within a script. ScriptContext is essentially an array of tagged values – on 64-bit systems each tag is 32 bits. A tag of 0 means a Small Integer (SMI), while a tag of 1 means a compressed pointer to a heap object.
How V8 Stores Numbers
V8’s numeric representation is clever but has a subtle limitation. A 31-bit SMI fits directly in the ScriptContext slot. Larger numbers or numbers with fractional parts must be stored as HeapNumber objects on the heap, with the slot holding a compressed pointer. HeapNumbers are immutable – once created, their value cannot change. This design works well for constants but becomes problematic when a numeric variable is updated frequently, as in the seed example.
The Performance Bottleneck: HeapNumber Allocation
Profiling the Math.random function revealed two major issues:
- HeapNumber allocation on every call: Each time
seedis assigned, V8 must allocate a new immutable HeapNumber object on the heap. This involves memory allocation and garbage collection overhead. - Pointer indirection: Accessing
seedrequires dereferencing the pointer each time, adding extra CPU cycles.
Given that the PRNG calls Math.random many thousands of times per second, these allocations quickly became a significant performance drain. In the async-fs benchmark, this bottleneck accounted for a large fraction of the runtime.
The Optimization: Mutable Heap Numbers
To eliminate the allocation overhead, the V8 team introduced a new internal representation: mutable heap numbers. Instead of storing an immutable HeapNumber, the ScriptContext slot now points to a special mutable object whose value can be updated in place. When the JavaScript code assigns a new double to seed, V8 no longer allocates a fresh object – it simply overwrites the existing number.
This change required careful design to maintain correctness across the garbage collector and compiler optimizations. The mutable heap number is allocated once and then reused for the lifetime of the context, significantly reducing memory pressure and garbage collection pauses.
Results: A 2.5× Speedup in async-fs
The impact was dramatic. With the mutable heap number optimization, the async-fs benchmark’s execution time dropped by 2.5 times. The overall JetStream2 score saw a noticeable boost. But more importantly, this optimization benefits any real-world code that frequently mutates a floating-point variable – for example, physics simulations, game loops, or financial calculations that update state thousands of times per frame.
Real‑World Relevance
While the specific pattern of a custom Math.random is rare in production code, the general pattern of repeatedly updating a numeric variable is extremely common. Any loop that adjusts a counter, accumulates a sum, or evolves a simulation state can suffer from the same HeapNumber allocation overhead. By making heap numbers mutable for context slots, V8 turns a performance cliff into a smooth slope.
This optimization is part of our broader effort to identify and eliminate performance cliffs in JavaScript engines. We encourage developers to write idiomatic code without worrying about such micro‑optimizations – V8 will handle the heavy lifting.
Conclusion
The mutable heap number optimization is a textbook example of how a seemingly small change in engine internals can yield large speedups for real code. It underscores the importance of profiling and addressing allocation bottlenecks. As V8 continues to evolve, we remain committed to delivering the fastest JavaScript execution possible.