Optimizing JavaScript Performance: How V8 Turbocharged Async File Operations by Eliminating HeapNumber Allocation
<h2>Introduction</h2>
<p>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 <strong>async-fs</strong> benchmark, we achieved a stunning <strong>2.5× speedup</strong> 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.</p><figure style="margin:20px 0"><img src="https://v8.dev/_img/mutable-heap-number/script-context.svg" alt="Optimizing JavaScript Performance: How V8 Turbocharged Async File Operations by Eliminating HeapNumber Allocation" style="width:100%;height:auto;border-radius:8px" loading="lazy"><figcaption style="font-size:12px;color:#666;margin-top:5px">Source: v8.dev</figcaption></figure>
<h2 id="the-challenge">The Challenge: A Custom Math.random</h2>
<p>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 <code>Math.random</code>. For reproducibility, the benchmark uses a deterministic, custom pseudo-random number generator (PRNG) that updates a <code>seed</code> variable on every call. The code is roughly:</p>
<pre><code>let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
...
return (seed & 0xfffffff) / 0x10000000;
};
})();</code></pre>
<p>The critical variable here is <code>seed</code>. It is stored in a <strong>ScriptContext</strong>, 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 <strong>Small Integer (SMI)</strong>, while a tag of 1 means a compressed pointer to a heap object.</p>
<h2>How V8 Stores Numbers</h2>
<p>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 <strong>HeapNumber</strong> objects on the heap, with the slot holding a compressed pointer. HeapNumbers are <strong>immutable</strong> – 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 <code>seed</code> example.</p>
<h2 id="heapnumber-allocation">The Performance Bottleneck: HeapNumber Allocation</h2>
<p>Profiling the <code>Math.random</code> function revealed two major issues:</p>
<ul>
<li><strong>HeapNumber allocation on every call</strong>: Each time <code>seed</code> is assigned, V8 must allocate a new immutable HeapNumber object on the heap. This involves memory allocation and garbage collection overhead.</li>
<li><strong>Pointer indirection</strong>: Accessing <code>seed</code> requires dereferencing the pointer each time, adding extra CPU cycles.</li>
</ul>
<p>Given that the PRNG calls <code>Math.random</code> 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.</p>
<h2>The Optimization: Mutable Heap Numbers</h2>
<p>To eliminate the allocation overhead, the V8 team introduced a new internal representation: <strong>mutable heap numbers</strong>. 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 <code>seed</code>, V8 no longer allocates a fresh object – it simply overwrites the existing number.</p>
<p>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.</p>
<h2 id="results">Results: A 2.5× Speedup in async-fs</h2>
<p>The impact was dramatic. With the mutable heap number optimization, the async-fs benchmark’s execution time dropped by <strong>2.5 times</strong>. 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.</p>
<h2>Real‑World Relevance</h2>
<p>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.</p>
<p>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.</p>
<h2>Conclusion</h2>
<p>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.</p>
Tags: