V8's Mutable Heap Numbers: Turbocharging JavaScript Performance

By ✦ min read

In the relentless pursuit of faster JavaScript execution, the V8 team constantly analyzes benchmark suites to identify performance cliffs. A recent deep dive into JetStream2 uncovered a remarkable optimization opportunity in the async-fs benchmark that led to a 2.5x speed improvement and a noticeable overall score boost. This article dissects the problem — and the elegant solution centered on mutable heap numbers — that turned a routine benchmark pattern into a substantial win.

The async-fs Benchmark and a Math.random Surprise

Despite its name, the async-fs benchmark simulates an asynchronous JavaScript file system. Surprisingly, its performance bottleneck wasn't I/O-related but stemmed from a custom, deterministic implementation of Math.random. This custom function ensures consistent results across runs and relies on a single mutable variable — seed — updated on every call:

V8's Mutable Heap Numbers: Turbocharging JavaScript Performance
Source: v8.dev
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 is stored in a ScriptContext — an internal array of tagged values accessible within a script. On 64-bit V8, each slot occupies 32 bits, using a tagging system that differentiates between small integers (SMIs) and pointers to heap objects.

How V8 Tags and Stores Numbers

V8 uses the least significant bit as a tag: 0 indicates a 31-bit SMI (stored directly, shifted left by one bit), while 1 indicates a compressed pointer to a heap object (incremented by one). This allows efficient handling of common integer values without heap allocation. However, numbers that don't fit in the SMI range — or have fractional parts — must be stored as HeapNumber objects, each a 64-bit double residing on the garbage-collected heap. The ScriptContext then holds only a pointer to that immutable heap object.

In the async-fs benchmark, the seed variable is an integer outside the 31-bit SMI range after the first arithmetic operation, so it is stored as a HeapNumber. And because HeapNumbers are immutable, every update to seed requires allocating a new HeapNumber on the heap.

The Performance Bottleneck

Profiling Math.random in the benchmark revealed two intertwined issues:

Together, these issues turned a simple variable update into an expensive operation, effectively creating a hidden performance cliff in an otherwise well-optimized benchmark.

The Fix: Mutable Heap Numbers

The V8 team's insight was straightforward: if the seed variable is always used as a numeric value that changes often, why force it through immutable heap objects? The optimization involved making the HeapNumber slot in the ScriptContext mutable — allowing the double value to be updated in place without allocating a new object each time.

This required changes to V8's internal representation of ScriptContext slots. Instead of always storing a pointer to an immutable HeapNumber, V8 now recognizes when a slot is used for a frequently mutated numeric value and converts it to a mutable heap number representation. The side effect: the slot now contains the double directly (as an untagged value), bypassing the heap allocation entirely.

The result was dramatic. The async-fs benchmark saw a 2.5x speedup, directly contributing to an improvement in JetStream2's overall score. While the optimization was inspired by the benchmark, similar patterns — such as counters or accumulators updated in tight loops — appear in real-world JavaScript applications.

Conclusion

This optimization demonstrates how careful attention to the interaction between language semantics and internal representation can yield substantial performance gains. By replacing immutable HeapNumber allocations with a mutable in-place update mechanism for frequently changed numeric values, V8 eliminated a hidden bottleneck that affected both allocation and garbage collection. The async-fs benchmark served as an ideal testing ground, but the same technique can benefit real-world code that repeatedly mutates numeric variables stored in the ScriptContext. V8's mutable heap numbers are a perfect example of how small, targeted changes can turbocharge JavaScript performance.

Tags:

Recommended

Discover More

Debian's New Stance: Making All Packages ReproducibleKubernetes v1.36 Debuts Tiered Memory Protection to Prevent OOM ThrashingAI Cyber Threat: Anthropic’s Mythos Pushes Security to a Breaking PointSafari Technology Preview 243: Key Fixes and New Features ExplainedHow to Apply Fred Brooks’s Timeless Software Management Lessons