V8's Mutable Heap Numbers: Turbocharging JavaScript Performance

By

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:

Related Articles

Recommended

Discover More

Unlocking Nature's Secrets: AI Revolutionizes the Solving of Inverse Partial Differential EquationsGroundbreaking Discovery in Fat Metabolism: A Protein's Dual Role in ObesityGoogle's Pixel Laptop and 'Pixel Glow' Notification System Leak via Android 17 Beta 45 Surprising Ways the BOOX Tappy Bluetooth Remote Transforms Your Reading Experience8 Key Takeaways on Agentic AI for Robot Teams