V8's Mutable Heap Numbers: A 2.5x Speed Boost for JavaScript
V8 optimized JetStream2's async-fs benchmark by introducing mutable heap numbers, replacing immutable HeapNumber allocations for seed variables, yielding a 2.5x speedup and better real-world performance.
Introduction
At V8, performance is an ongoing mission. Recently, the team targeted the JetStream2 benchmark suite to identify and remove performance bottlenecks. One optimization, inspired by the async-fs benchmark, resulted in a remarkable 2.5x improvement for that specific test and a noticeable lift in the overall score. While the benchmark triggered the change, similar patterns appear in real-world JavaScript code.
The async-fs Benchmark and Its Math.random
The async-fs benchmark implements a JavaScript file system with asynchronous operations. Surprisingly, its main performance culprit was the custom Math.random implementation. To guarantee reproducible results, the benchmark uses a deterministic pseudo-random number generator:
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 variable seed is updated on every call, generating the pseudo‑random sequence. Critically, seed is stored in a ScriptContext, a storage area for variables accessible within a script.
Understanding ScriptContext and Number Storage
Internally, V8 represents a ScriptContext as an array of tagged values. On 64‑bit systems (default configuration), each tagged value occupies 32 bits. The least significant bit acts as a tag:
- If the bit is 0, the value is a 31‑bit Small Integer (SMI). The actual integer is stored left‑shifted by one bit.
- If the bit is 1, the value is a compressed pointer to a heap object. The pointer is incremented by one.
This tagging scheme determines how numbers are stored:
- SMIs reside directly in the ScriptContext.
- Larger numbers or numbers with decimal parts are stored indirectly as immutable HeapNumber objects on the heap (a 64‑bit double). The ScriptContext holds a compressed pointer to the HeapNumber.
The ScriptContext layout includes slots for metadata, the global object (NativeContext), and variables like seed. An untagged double‑precision value occupies its own slot. This design efficiently handles various numeric types while optimizing for the common SMI case.
The Performance Bottleneck
Profiling the custom Math.random exposed two major issues:
- HeapNumber allocation: The slot for the
seedvariable points to an immutable HeapNumber. Every timeMath.randomupdatesseed, a new HeapNumber object must be allocated on the heap. This allocation cost adds up quickly, especially whenMath.randomis called frequently. - No in-place mutation: Because HeapNumbers are immutable, updating
seedcannot happen in place. The engine must create a fresh object and update the pointer, causing memory management overhead.
In the async-fs benchmark, the heavy use of Math.random for file system operations (e.g., generating unique IDs) made this bottleneck significant.
The Optimization: Mutable Heap Numbers
The V8 team introduced a new mechanism: mutable heap numbers. Instead of replacing the entire HeapNumber when seed changes, the engine now allows the existing HeapNumber object to be mutated in place—its double value can be overwritten. This eliminates the allocation and garbage collection pressure.
The key changes include:
- Specialization: The ScriptContext slot for
seedis designated as a mutable number slot. - In-place update: When a new value is assigned, the engine directly modifies the HeapNumber’s double field without allocating a new object.
- Backward compatibility: The mutable heap number still adheres to JavaScript’s semantics (like strict mode and type stability).
This optimization was inspired by the benchmark but naturally applies to any hot code that repeatedly updates a numeric variable in a context, such as counters, accumulators, or generators.
Impact and Conclusion
After implementing mutable heap numbers, the async-fs benchmark showed a 2.5x speedup. The overall JetStream2 score also improved noticeably. This case illustrates how profiling pinpointed a seemingly small issue—HeapNumber allocation in a script context—that caused a large performance cliff.
V8’s approach to mutable heap numbers demonstrates a pragmatic tradeoff: sacrificing immutability in a strictly controlled internal case to gain runtime efficiency. Such optimizations, while triggered by benchmarks, often translate to real‑world benefits for JavaScript applications that perform heavy numeric computations.
For more details on V8’s performance improvements, see related articles on number storage and bottleneck analysis.