Boosting JavaScript Performance: Optimizing V8's Handling of Mutable Heap Numbers

From Usahobs, the free encyclopedia of technology

Introduction

Performance tuning is at the heart of V8's development. Our team continually examines benchmarks to uncover hidden inefficiencies. A recent deep dive into the JetStream2 suite revealed a specific opportunity: by refining how we manage mutable heap numbers, we achieved a remarkable 2.5× speedup in the async-fs test, contributing to a notable overall score improvement. While this optimization was inspired by a benchmark, the underlying patterns are common in real-world JavaScript code.

Boosting JavaScript Performance: Optimizing V8's Handling of Mutable Heap Numbers
Source: v8.dev

Understanding the async-fs Benchmark

The async-fs benchmark simulates a JavaScript-based file system with asynchronous operations. Surprisingly, its performance bottleneck wasn't in I/O but in the custom implementation of Math.random.

A Custom Math.random Implementation

To ensure deterministic results across runs, the benchmark uses a custom pseudo-random number generator (PRNG). The code updates a seed variable on each 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;
  };
})();

Here, seed is stored in a ScriptContext—a storage area for variables accessible within a script. In V8's default 64-bit configuration, each slot in the ScriptContext is a 32-bit tagged value. The least significant bit indicates whether the slot holds a 31-bit Small Integer (SMI) (tag bit = 0) or a compressed pointer to a heap object (tag bit = 1).

This tagging system allows V8 to store small integers directly in the ScriptContext, while larger numbers or floats require indirection via an immutable HeapNumber object on the managed heap. The ScriptContext then holds a pointer to that HeapNumber.

Identifying the Bottleneck

Profiling Math.random exposed two critical performance issues:

  • HeapNumber Allocation: The seed variable’s slot points to an immutable HeapNumber. Every time the PRNG updates seed, a new HeapNumber must be allocated on the heap. This allocation overhead accumulates significantly—each call creates a new floating-point object.
  • Tagging Overhead: Because the ScriptContext stores tagged values, reading and writing a double requires additional operations to convert between the tagged representation and the actual 64-bit floating-point value.

Together, these issues caused the benchmark to spend a disproportionate amount of time in memory management, slowing down the entire asynchronous file system simulation.

The Optimization: Mutable Heap Numbers

Our solution was to introduce mutable HeapNumbers. Instead of creating a new immutable HeapNumber on every update, we now allow a HeapNumber’s value to be changed in place when it is stored in a ScriptContext slot that is known to hold a double. This eliminates allocation and reduces garbage collection pressure.

Technically, we modified the V8 compiler (TurboFan) to recognize patterns where a ScriptContext slot is repeatedly written with a double value. When such a slot already contains a HeapNumber, the compiler emits code that directly updates the double inside that object, rather than allocating a new one. The tag bit remains unchanged, ensuring compatibility with the existing pointer infrastructure.

How It Works Under the Hood

For the seed variable, the ScriptContext slot initially points to an immutable HeapNumber. After our optimization, the first write still allocates a new HeapNumber (since the slot may be uninitialized or contain an SMI), but subsequent writes mutate the same HeapNumber object. The key is that the HeapNumber is now mutable only when held by a ScriptContext slot—other references remain immutable for safety.

To implement this, we added a new mutable heap number representation in the compiler’s intermediate graph. The lowering pass identifies stores to ScriptContext slots where the stored value is a double and the slot already holds a HeapNumber. It then replaces the allocation with a direct memory write to the existing object’s value field.

Results and Impact

The change yielded a 2.5× improvement in the async-fs benchmark, cutting execution time from 180 ms to 72 ms in our tests. The overall JetStream2 score increased by approximately 3%, as the async-fs test is one of several components. The fix also benefited other benchmarks and real-world applications that frequently update numeric variables in script context, such as physics simulations or animation loops.

Internal Anchor Links for Further Reading

Conclusion

This optimization illustrates how even small changes in the runtime’s memory management can lead to large performance gains. By enabling mutable HeapNumbers for script context slots, V8 reduces allocation overhead and speeds up code that relies on frequently updated numeric variables. The pattern is common enough that we expect this improvement to help many real-world applications, not just synthetic benchmarks. As always, we continue to refine V8’s inner workings to deliver faster JavaScript execution for everyone.