Boosting V8 Performance: Rethinking Heap Numbers for Mutable Variables

By ● min read

In the quest for JavaScript performance, V8 engineers recently discovered a surprising bottleneck in the JetStream2 benchmark suite. The async-fs test, a simulated file system, revealed that a custom Math.random implementation caused excessive memory allocations. By analyzing how V8 stores mutable numeric variables, the team achieved a remarkable 2.5x speedup in that benchmark. This Q&A explores the inner workings of V8's type system and the optimization that turbocharged mutable heap numbers.

Why did the async-fs benchmark become a focus for V8 optimization?

The async-fs benchmark is part of JetStream2, a suite designed to stress real-world JavaScript performance. It implements a file system using asynchronous operations, but profiling revealed an unexpected hotspot: the Math.random function. This function used a deterministic pseudo-random number generator to ensure consistent results across runs. However, its implementation caused V8 to allocate a new heap object every time the random seed was updated. This allocation turned into a performance cliff, making the benchmark slower than expected. By addressing this, V8 not only improved the async-fs score but also gained insights for optimizing similar patterns in production code.

Boosting V8 Performance: Rethinking Heap Numbers for Mutable Variables
Source: v8.dev

How does the custom Math.random implementation work?

The benchmark's Math.random replaces the built-in random function with a deterministic algorithm. It maintains a single variable, seed, inside a closure. Every call performs a series of bitwise operations—like shifts, XORs, and additions—on the seed, updating it for the next call. The final value is masked and divided to produce a floating-point result between 0 and 1. Although the algorithm is compact, its frequent mutations of the seed variable exposed inefficiencies in V8's storage model. Because the seed is stored in a ScriptContext (a container for script-level variables), each update forces a new heap allocation, as we'll see next.

What is a ScriptContext and how does it store values?

A ScriptContext is an internal V8 structure that holds values accessible within a given script. It behaves like an array of tagged values. On 64-bit systems, each slot is 32 bits wide. The least significant bit acts as a tag: 0 indicates a small integer (SMI) of up to 31 bits, while 1 indicates a compressed pointer to a heap object. Constants and most local variables fit neatly into these slots. However, when a value cannot be represented as a SMI—like a large number or a double—V8 stores it indirectly via a heap object, notably an immutable HeapNumber. The ScriptContext then holds a compressed pointer to that HeapNumber, not the number itself.

Why are HeapNumbers immutable and what problem does that cause?

HeapNumbers in V8 are designed to be immutable—once created, their numeric value cannot change. This immutability simplifies many internal optimizations, but it creates a problem for mutable variables like the seed. When the seed variable (stored in a ScriptContext) is updated, V8 cannot modify the existing HeapNumber. Instead, it must allocate a new HeapNumber on the heap with the updated value, then update the pointer in the ScriptContext. In the hot loop of Math.random, this allocation happens on every call, generating significant memory churn and garbage collection pressure. This was the primary bottleneck identified during profiling.

How did V8 fix the performance bottleneck?

The solution was to introduce mutable heap numbers. V8 modified the internal representation so that certain heap numbers allocated for variables in a ScriptContext could be updated in place, without allocating new objects. This change required careful adjustments to the garbage collector and type system to ensure correctness. By eliminating the per-call allocation, the async-fs benchmark saw a 2.5x improvement. The pattern of mutating a numeric variable inside a hot function is not limited to synthetic benchmarks; it appears in real-world code, such as stateful random number generators, counters, and accumulators. This optimization thus benefits many JavaScript applications.

What lessons does this optimization hold for V8 and JavaScript developers?

This case underscores the importance of identifying performance cliffs in benchmark suites—and in production. For V8 engineers, it highlights how seemingly minor details (like the immutability of heap numbers) can cause large overheads in specific patterns. For JavaScript developers, it serves as a reminder that stateful closures with frequent numeric updates can be optimized by V8; writing natural code is the best approach, as engine teams continually improve performance. The mutable heap numbers feature is now part of V8's default configuration, silently accelerating code that fits this pattern. Ultimately, this work exemplifies V8's commitment to turning benchmark insights into lasting improvements for the web.

Tags:

Recommended

Discover More

Breaking: Superbad Director Greg Mottola in Talks to Direct DC’s Deathstroke and Bane FilmMaster Your Overstimulation: A Step-by-Step Guide to Regaining Calm During a Hectic Day5 Things You Need to Know About Diablo 4's Next Big Bad (And Why Diablo Himself Might Never Appear)AI Uncovers Over a Hundred Exoplanets in NASA Data, Including Rare Extreme WorldsSecretlab Unveils Limited-Edition Mandalorian Gaming Chair for Star Wars Day