Boosting V8 Performance: How Mutable Heap Numbers Eliminate Allocation Bottlenecks

By

Introduction

At V8, we continuously refine JavaScript performance to meet the demands of modern web applications. Recently, we turned our attention to the JetStream2 benchmark suite, aiming to eliminate performance cliffs. This article details a specific optimization—replacing immutable heap numbers with mutable ones—that yielded a remarkable 2.5× improvement in the async-fs benchmark, significantly boosting the overall score. While inspired by this benchmark, the optimization targets patterns that appear in real-world code.

Boosting V8 Performance: How Mutable Heap Numbers Eliminate Allocation Bottlenecks
Source: v8.dev

Inside the async-fs Benchmark

The async-fs benchmark is a JavaScript file system implementation focused on asynchronous operations. Surprisingly, its main bottleneck turned out to be a custom, deterministic implementation of Math.random. The implementation uses a seed variable updated on every call:

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 seed variable resides in a ScriptContext, which acts as a storage container for values accessible within a script. Internally, the ScriptContext is an array of V8's tagged values. On 64-bit systems with default configuration, each tagged value occupies 32 bits. The least significant bit acts as a tag:

This tagging system efficiently handles different numeric types. SMIs are stored inline. Larger numbers or numbers with decimal parts are stored indirectly as immutable HeapNumber objects on the heap—a 64-bit double—with the ScriptContext holding a compressed pointer to them.

The Bottleneck: HeapNumber Allocation

Profiling Math.random revealed two major performance issues:

  1. HeapNumber allocation: The slot for seed in the ScriptContext points to a standard, immutable HeapNumber. Each time the function updates seed, a new HeapNumber must be allocated on the heap, causing significant allocation overhead.
  2. The allocation occurs repeatedly in a tight loop, overwhelming the garbage collector and degrading performance.

Even though the seed value fits in a 32-bit signed integer after masking, V8 stores it as a HeapNumber because the initial assignment and updates produce values that exceed the SMI range during intermediate calculations. Once an object becomes a HeapNumber, it remains one, and each subsequent write to seed triggers a new allocation.

The Optimization: Mutable Heap Numbers

To eliminate these allocations, we introduced the concept of mutable heap numbers. The idea is simple: allow a HeapNumber object to be mutated in-place, rather than requiring a new object for every update. When V8 detects a pattern where a variable in a ScriptContext (or other context) is repeatedly assigned new numbers that do not fit in an SMI, it can promote the existing HeapNumber to a mutable state.

With mutable heap numbers, the update to seed no longer allocates a new HeapNumber. Instead, the double value stored inside the existing HeapNumber is overwritten directly. This significantly reduces memory allocation and garbage collection pressure.

Results and Impact

The optimization yielded a 2.5× speedup in the async-fs benchmark. This improvement contributed noticeably to the overall JetStream2 score. The fix is particularly effective because Math.random is called in a hot loop, and the custom implementation's six-step seed update sequence amplifies the allocation cost when each step must create a new HeapNumber.

Real-World Relevance

While the immediate trigger was a benchmark, the pattern of storing mutable numeric state in script or function contexts appears in real production code. Examples include counters, accumulators, custom random number generators, incremental hashes, and state machines. Eliminating allocation cliffs for such variables can improve performance in data processing pipelines, simulations, and game loops.

Technical Implementation Details

V8's existing object model already supports mutation for other object types (e.g., JavaScript objects). Heap numbers were originally immutable to simplify optimization and memory management. The key change involved:

The change is safe because the new value is of the same type (double). If the variable later transitions to an SMI, V8 can revert to storing it directly in the context slot. The mutable HeapNumber is only used when frequent numeric updates are observed.

Summary

By replacing immutable HeapNumber allocations with mutable in-place updates, V8 eliminated a major performance bottleneck in the async-fs benchmark. The resulting 2.5× improvement shows how targeted optimizations based on real usage patterns can yield substantial gains. Developers can benefit from this enhancement transparently, without changing their code.

For further reading, see the bottleneck analysis and results sections above.

Tags:

Related Articles

Recommended

Discover More

Building a Smart Research Assistant with Groq and LangGraph: A Comprehensive Guide10 Key Building Blocks for Your AI Conference App Using .NET's Composable AI StackThe Unseen Trade-Off of AI Efficiency: Losing the 'Bugs' That Foster Team CohesionCEO of Brazilian DDoS Protection Firm Denies Role in Attacks, Blames Breach and RivalsHow to Track the Debut of Toyota's Three‑Row Electric SUV and Its Lexus Counterpart