How V8 Turbocharged JavaScript with Mutable Heap Numbers
V8 optimized the async-fs benchmark by making heap numbers mutable in script contexts, avoiding costly HeapNumber allocations for Math.random's seed.
In the relentless pursuit of speed, the V8 team recently targeted the JetStream2 benchmark suite to smooth out performance cliffs. One standout improvement—a 2.5× boost in the async-fs benchmark—came from rethinking how numeric values are stored in script contexts. This optimization was inspired by a common pattern in the benchmark that also appears in real-world code. Below, we break down the problem and the solution in a Q&A format.
What was the bottleneck in the async-fs benchmark?
The async-fs benchmark simulates a file system using asynchronous JavaScript operations. Surprisingly, the major slowdown was inside a custom Math.random implementation. The benchmark needed deterministic random numbers, so it replaced the native Math.random with one that updates a seed variable stored in the ScriptContext. Each call to Math.random mutated seed, but because the script context held only a pointer to an immutable HeapNumber, every mutation required allocating a new HeapNumber object on the garbage-collected heap. Over thousands of calls, this allocation churn caused a severe performance drop.
What is a ScriptContext and why does it matter?
A ScriptContext is an internal array that stores all values accessible within a given script—variables, function scopes, etc. In V8, each element of this array is a tagged value (32 bits on 64‑bit systems). The least significant bit (tag) tells the engine how to interpret the value: a 0 means it’s a Small Integer (SMI) stored directly (shifted left by one bit), while a 1 means it’s a compressed pointer to a heap object. Numbers larger than 2³¹ or with fractional parts cannot be SMIs; they are stored as HeapNumber objects (64‑bit doubles) on the heap, with the script context holding only a pointer. This design is efficient for integers but creates overhead when a variable’s numeric value changes frequently.
How did V8 store the seed variable before the optimization?
Before the optimization, the seed variable in the async-fs benchmark was stored as a HeapNumber object. The script context slot contained a compressed pointer to that immutable HeapNumber on the heap. Because seed is updated on every call to Math.random, the runtime could not reuse the existing HeapNumber—it had to allocate a brand new 64‑bit double on the heap each time. This allocation, plus the associated garbage collector pressure, became the primary performance bottleneck, accounting for hundreds of thousands of allocations in a short period.
What did the V8 team change to fix this?
The team introduced mutable heap numbers. Instead of forcing every numeric variable that is not an SMI to be an immutable HeapNumber, they allowed the script context to directly store the raw double-precision floating-point value without a tag. This means the 64‑bit value of seed can be stored inline in the context array, and updates simply overwrite that slot—no new heap allocations needed. The engine still uses the tag bit to distinguish between an SMI and this “untagged double” representation. The change eliminated the allocation bottleneck, giving the 2.5× speedup in the async-fs benchmark and contributing to an overall improvement in the JetStream2 score.
Why was this pattern considered real-world?
Although the optimization was inspired by a benchmark, the pattern of storing a mutable numeric seed (or similar state) in a context variable appears in real JavaScript applications. For example, game engines, procedural generation libraries, or cryptography polyfills often implement their own deterministic Math.random or pseudo‑random number generators. Any code that frequently updates a non‑SMI number in a closure or global scope could suffer from the same HeapNumber allocation overhead. By making heap numbers mutable when stored in contexts, V8 addresses a performance cliff that developers might encounter outside synthetic tests.
What are the key learning outcomes for developers?
Developers should understand that not all numbers are created equal in JavaScript engines. While the V8 team optimizes internal representations transparently, performance‑sensitive code that frequently mutates numbers outside the SMI range (like sliding‑window counters, large hashes, or random seeds) can benefit from this improvement. However, the main takeaway is that modern V8 now handles such patterns more efficiently without manual workarounds. If you see a performance issue related to repeated number assignments, consider whether the value fits in an SMI (integer up to 2³¹). If not, the engine’s new mutable heap number technique reduces allocation pressure automatically—just update your V8 version to at least the one containing this optimization.