docs/parsing/hole-checks-optimized.md
This document covers Step 3 of the Hole Check Elision process in V8, focusing on how the optimizing compilers (Maglev and Turboshaft/TurboFan) track variables and eliminate Temporal Dead Zone (TDZ) hole checks.
Maglev, V8's mid-tier optimizing compiler, adopts a specialized, high-level approach to hole check elimination during its graph optimization phase.
ThrowReferenceErrorIfHole node.ValueNode::IsTheHole() to inspect the source of a value recursively (unwrapping identities, checking phi inputs, etc.). This returns a Tribool state:
kTrue: The value is definitely TheHole.kFalse: The value is definitely NOT TheHole.kMaybe: The analysis cannot be certain.MaglevGraphOptimizer, when visiting a ThrowReferenceErrorIfHole node:
IsTheHole() returns kFalse, the check node is completely removed from the graph.kTrue, it is replaced with an unconditional throw.kMaybe, the check is retained.This approach allows Maglev to quickly and effectively drop checks on variables it knows are safe without running heavy, generalized analysis passes.
Because Maglev builds a graph and tracks the current value of variables in its environment, it can naturally elide hole checks when branches are pruned, even without explicit flow-sensitive tracking of "holiness".
The Scenario: Consider a case where the Bytecode Generator (Step 2) had to retain a hole check because one of the paths failed to initialize the variable:
function runExampleTest() {
function example(cond) {
const getX = () => x; // Closure capturing x
if (cond) {
// ...
}
// TDZ check is ambiguous here because we don't know when example is called!
console.log(getX()); // Check REQUIRED!
}
let x = 10;
}
If Maglev specializes this function and proves that cond is always true (e.g., through type feedback), it prunes the else branch from the graph.
In the baseline bytecode, the access after the if/else is a merge point, so it must load whatever value resulted from the merge. But in Maglev, because the else branch is gone, the merge disappears! The value of x at the final console.log(x) is now directly mapped to the constant 10 from the then branch.
When MaglevGraphOptimizer visits the check for this load, ValueNode::IsTheHole() looks at the definition (the constant 10) and returns kFalse. The check is successfully elided!
maybe_assignedMaglev leverages a powerful combination of hole check elision and the maybe_assigned flag to enable constant tracking for context-allocated variables:
try/catch block, passing the hole check implies that the variable is safely initialized. If it were a hole, execution would have aborted.maybe_assigned, Maglev knows that no code in the closure can mutate it after initialization.The Combo: By combining these two facts, Maglev can safely assume that after the first successful access/check, the variable's value is permanently fixed. This allows Maglev to specialize the load to a constant in the graph, eliding both the context load and any subsequent hole checks for the remainder of the function!
try / catchThis reasoning becomes more complex inside try/catch blocks because execution does continue after an exception (in the catch block).
try throws because a variable was a hole, the catch block executes.catch block, we might actually know that the variable must be the hole (because that's why it threw!).catch block (which would lead to graph bloat). Instead, it implicitly merges the environment states of all potentially throwing instructions inside the try block at the entry of the catch block. As a result, the "holiness" state of each variable in the catch block is a conservative combination (intersection) of the states at all possible throwing sites.function testTryCatch() {
// let x = hole
try {
console.log(x); // Throws ReferenceError (TDZ)
x = 10; // Skipped!
} catch (e) {
// We arrive here because the access above threw.
// In this block, 'x' is guaranteed to STILL be the hole!
console.log(x); // Accessing 'x' here will throw AGAIN.
}
let x;
}
If Maglev were to blindly assume that a successful access implies initialization for the rest of the function, it might unsafely elide the second check inside the catch block. By forcing spills and merging states conservatively at the catch entry, Maglev avoids this trap.
Note on Single-Throw Paths: If there is only one potential throwing node in the
tryblock, the merge into thecatchblock is indeed trivial. In this specific scenario, Maglev does bypass the intersection and simply clones the exact frame state from the throwing node directly into thecatchblock. However, it still does not specialize the variable to "the hole constant". It just carries over the state from just before the throw (e.g., that it was maybe a hole). Maglev lacks the path-sensitive logic to deduce that "because we reached the catch block, this specific variable must have been the hole."
The "Inverted Generator" pattern breaks the assumption that linear execution implies initialization. Consider this scenario:
function* f() {
yield function g() {
try {
return test; // 2. Accesses outer variable 'test' -> Throws TDZ
} catch (e) {
gen.next(); // 3. Resumes the generator!
return test; // 4. Accesses 'test' again -> Succeeds!
}
};
let test = 10; // 1. Declared AFTER the yield
}
var gen = f();
var g = gen.next().value; // Gets the function 'g'
g(); // Invokes 'g'
The Execution Flow:
f() is paused at the yield, returning the function g. At this point, let test = 10 has not been executed yet.g(). Inside, the attempt to read test throws a ReferenceError because it is still in the TDZ.catch block intercepts the error and calls gen.next(), which resumes the generator and executes let test = 10.test is initialized! The catch block returns test, successfully reading 10.Here, the access inside g actually causes the variable to be initialized via the catch block!
To prevent Maglev from incorrectly assuming that execution is linear or that test cannot change state from "hole" to "initialized" across the yield, V8's Parser detects this pattern (a variable captured from an outer generator scope accessed inside a try/catch block) and forcibly marks it as maybe_assigned. This disables aggressive constant-tracking in Maglev, forcing conservative fallback behavior.
A particularly powerful scenario for hole check elision in Maglev occurs during function inlining.
The Scenario: Consider an inner function accessing a variable declared in its outer function:
function outer() {
let x = 10;
function inner() {
console.log(x); // Cross-closure access
}
inner();
}
During parsing (Step 1), the access to x inside inner is cross-closure. Because the parser cannot statically guarantee the execution order of closures, it conservatively marks the access as requiring a hole check (kRequired). In the baseline bytecode for inner, a ThrowReferenceErrorIfHole instruction is emitted.
How Inlining Changes the Game:
When Maglev decides to inline inner() into outer(), the closure boundary effectively disappears. The graph builder imports the bytecode of inner directly into the graph of outer.
This inlining converts a context-sensitive cross-closure analysis into a standard local (intraprocedural) analysis. Now, the access to x originating from inner and the initialization of x to 10 in outer are part of the exact same graph. Maglev's local ValueNode::IsTheHole() analysis can trace directly from the inlined access back to the definition (the constant 10). Since it is a known non-hole constant, the check is completely elided in the optimized code!
While theoretically one could perform context-dependent interprocedural analysis or employ function splitting to carry information from call sites into the callee, inlining is the practical and elegant mechanism V8 uses to unlock these optimizations by leveraging its highly optimized local compiler graph pipelines.
Turboshaft (and historically TurboFan) adopts a very different philosophy. Instead of maintaining specialized high-level nodes for TDZ checks, it lowers them early into standard machine operations and relies on powerful, general-purpose optimization reducers to clean them up.
RootEqual(value, RootIndex::kTheHoleValue). This feeds into a BranchOp where the true branch calls the runtime to throw the error, and the false branch continues execution.BranchEliminationReducer): This is the primary engine for eliding hole checks in Turboshaft. These are the checks that Maglev (Turbolev) did not manage to elide. The reducer processes the graph in dominator order and maintains a map of known_conditions_. If execution passes a branch or a DeoptimizeIf that proves a value is not TheHole (i.e., RootEqual(...) == false), that condition is recorded. Subsequent checks on the same value dominated by this path look up the map and are immediately removed.MachineOptimizationReducer): If other optimizations (like store-forwarding) reveal that the value is a specific heap constant that is not the hole, this reducer folds the TaggedEqual comparison directly to false, eliminating the branch.Type system that understood TheHole as a specific type, Turboshaft's type system operates on machine types (ranges of Word32, Word64, etc.). It does not track JS objects or special values like TheHole in its types. Thus, Turboshaft relies entirely on the path-sensitive condition tracking of Branch Elimination rather than type inference to drop these checks.Just like in Maglev (see The Power of Inlining for Hole Checks above), function inlining performed by TurboFan (which feeds into Turboshaft) removes closure boundaries. Once an inner function is inlined, the variable access and its definition become part of the same localized graph. This allows Turboshaft's powerful general-purpose passes (like MachineOptimizationReducer for constant folding and BranchEliminationReducer) to trace directly from the access to the initialization, eliminating checks that the Parser and Interpreter had to retain conservatively.
In optimizing compilers (both Maglev and Turboshaft), Loop Peeling and Loop Unrolling provide a significant advantage for hole check elision.
In the baseline bytecode, loop entries are merge points where the analyzed state from the loop entry and the back-edge (the end of the loop) must converge. This often forces conservatism because the compiler cannot be sure what state was produced in previous iterations.
By peeling the first iteration of a loop (duplicating the first iteration's body before the actual loop header):