6437
Environment & Energy

How V8 Turbocharged JavaScript Performance by Making Heap Numbers Mutable

Posted by u/Glee21 Stack · 2026-05-03 13:04:09

JavaScript engines like V8 constantly seek performance improvements. One such improvement addressed a surprising bottleneck in the JetStream2 benchmark's async-fs test. The issue was related to how Math.random's internal seed was stored, causing excessive memory allocations. By making the heap number mutable, V8 achieved a 2.5x speedup. This article explains the problem and solution in a Q&A format.

What was the performance bottleneck in the async-fs benchmark?

The async-fs benchmark simulates a JavaScript file system with asynchronous operations. Surprisingly, the main bottleneck was the custom implementation of Math.random used for deterministic results. The random function updates a seed variable on every call, which is stored in a ScriptContext. In V8's default configuration, the seed was stored as an immutable HeapNumber on the heap. Each call to Math.random created a new HeapNumber object, leading to significant memory allocation pressure. This constant allocation degraded performance dramatically. Profiling revealed that the allocation cost, not the arithmetic operations themselves, was the culprit. The benchmark repeatedly triggered garbage collection and allocation overhead, slowing down the entire async-fs test.

How V8 Turbocharged JavaScript Performance by Making Heap Numbers Mutable
Source: v8.dev

How does V8 store numbers in ScriptContext?

V8 represents variables in a ScriptContext using an array of tagged values, each 32 bits on 64-bit systems. The least significant bit acts as a tag: 0 indicates a Small Integer (SMI), where the actual value is stored directly (shifted left by one bit). 1 indicates a compressed pointer to a heap object (the pointer value incremented by one). SMIs are efficient because they fit inline. However, numbers that are too large or have fractional parts cannot be stored as SMIs; they must be stored as immutable HeapNumber objects on the heap. Each HeapNumber holds a 64-bit double value. The ScriptContext slot then stores a compressed pointer to that HeapNumber. This tagging system balances compact storage with flexibility but introduces indirection for non-integer values.

Why did Math.random cause HeapNumber allocations?

In the async-fs benchmark, the seed variable used by Math.random is updated with arithmetic that sometimes produces values outside the 31-bit SMI range. For instance, bitwise operations and shifts can generate large intermediate numbers. When the result exceeds the SMI limit, V8 stores it as a HeapNumber. Since HeapNumbers are immutable, updating the seed requires allocating a new HeapNumber each time. The Math.random function runs on every random number generation, which can happen thousands of times per benchmark iteration. This causes a cascade of short-lived HeapNumber objects, increasing allocation rate and garbage collection pressure. The constant allocation overhead became the dominant cost, even though the actual arithmetic is simple. The immutable design was intended for safety but proved inefficient for this hot loop.

What is a mutable heap number and how did it solve the problem?

A mutable heap number is a variation where the value stored in the HeapNumber object can be changed in place without allocating a new object. Before this optimization, every update to a variable requiring a double-precision value created a fresh immutable HeapNumber. By making the HeapNumber mutable, V8 allows the same heap object to hold the new value after each update. The ScriptContext slot continues pointing to the same HeapNumber; its internal double field is simply overwritten. This eliminates allocation entirely for the seed variable in Math.random. The mutation is safe because the runtime ensures that the HeapNumber is not shared unexpectedly. After this change, the async-fs benchmark saw a 2.5x speedup due to reduced allocation and garbage collection. The optimization targeted a pattern found in the benchmark, but similar patterns appear in real-world code.

What impact did this optimization have on JetStream2 scores?

V8 engineers measured the effect of making heap numbers mutable on the JetStream2 benchmark suite. The async-fs sub-benchmark improved by a factor of 2.5x, which contributed to a noticeable boost in the overall score. While the fix was specifically motivated by the benchmark, it followed V8's general strategy of eliminating performance cliffs—situations where a seemingly small code pattern causes disproportionate slowdown. The overall JetStream2 score increased measurably, demonstrating that even niche optimizations can yield real-world gains. The improvement was achieved without changing the semantics of JavaScript; it was a purely internal V8 implementation change.

How does this optimization apply to real-world code?

Developers rarely write their own Math.random implementations, but the underlying pattern—a numeric variable updated in a tight loop—is common. Examples include physics simulations, game state updates, signal processing, and cryptographic hashing. In such code, if the variable is a double (because it exceeds SMI range or has fractional parts), V8 would previously allocate a new HeapNumber on each update. The mutable heap number optimization ensures that these patterns run faster without the developer needing to change their code. This is part of V8's broader effort to optimize hot paths automatically. While not every mutable number benefits (SMIs remain fast), the optimization targets cases where heap numbers are unavoidable, helping real-world applications that rely on heavy numeric computation.

What is the role of tagging in V8's value representation?

Tagging is a technique used by V8 to distinguish between different types of values stored in the same memory slot. Each 32-bit value (on 64-bit systems) carries a tag in the least significant bit(s). For numbers, bit 0 indicates SMI (0) or heap pointer (1). SMIs store the integer value directly, shifted left by one bit. Heap pointers point to objects like HeapNumbers, strings, or arrays. The compressed pointer uses the remaining 31 bits to index into a heap region. This system saves memory by fitting a wide range of values into a small fixed-size slot. However, it means that non-SMI numbers require an indirection to the heap. The mutable heap number optimization works within this framework: the pointer remains the same, but the target heap object's content changes. Tagging thus ensures type safety while allowing efficient in-place mutation when needed.