Maglev: V8's Middle Tier Compiler
Maglev sits between Sparkplug and TurboFan: it builds a real SSA graph from bytecode, uses the feedback vector to specialise property loads and calls, and is fast enough to compile that it can afford to be wrong.
TL;DR: Maglev is the tier between Sparkplug and TurboFan. Unlike Sparkplug, it does not compile bytecode one-for-one:
MaglevGraphBuilderwalks the bytecode and builds an SSA graph ofValueNodes, then the compiler runs inlining, truncation, LICM, phi-untagging, aStraightForwardRegisterAllocatorand a code generator. It does use the feedback vector - a property load with a monomorphic feedback becomes a map check plus a field load, not a generic IC call - but it uses it conservatively, withKnownNodeAspectstracking the set of possible maps and invalidating them on any side effect.
V8’s tiering ladder has three rungs above the interpreter: Sparkplug, Maglev and TurboFan. Sparkplug removes the dispatch. TurboFan does everything. Maglev is the awkward middle, and it exists because TurboFan is too expensive to be the first thing that happens to a function that turns out to be hot.
The design goal of Maglev is: be about as fast as TurboFan for the common case, but compile in a small fraction of the time. That goal constrains everything. It is why Maglev has its own graph IR rather than reusing TurboFan’s, why it uses a simple linear-scan-style register allocator, and why it bails out to the interpreter on anything it does not understand.
The tiering thresholds
The flags are the whole policy:
// src/flags/flag-definitions.h:585
DEFINE_BOOL(maglev, true, "enable the maglev optimizing compiler")and further down, the invocation counts that decide when a function is “hot enough”:
// src/flags/flag-definitions.h:1125
#if defined(ANDROID)
DEFINE_INT(invocation_count_for_maglev, 1000,
"invocation count required for optimizing with Maglev")
#else
DEFINE_INT(invocation_count_for_maglev, 400,
"invocation count required for optimizing with Maglev")
#endif // ANDROID
DEFINE_INT(invocation_count_for_maglev_osr, 100,
"invocation count required for maglev OSR")Compare with TurboFan’s:
// src/flags/flag-definitions.h:1146
DEFINE_INT(invocation_count_for_turbofan, 3000,
"invocation count required for optimizing with TurboFan")So the ladder is roughly: Sparkplug almost immediately, Maglev at 400 invocations, TurboFan at 3000. A function that runs 500 times gets Maglev’s specialisations but never TurboFan’s.
Maglev builds a graph, not a translation
The structural difference from Sparkplug is that Maglev has an IR. The
opcode set is an explicit enum in src/maglev/maglev-ir.h:
// src/maglev/maglev-ir.h:552
#define NODE_BASE_LIST(V) \
NODE_LIST(V) \
CONTROL_NODE_LIST(V)
// Define the opcode enum.
#define DEF_OPCODES(type) k##type,
enum class Opcode : uint16_t { NODE_BASE_LIST(DEF_OPCODES) };
#undef DEF_OPCODESand the node list includes both generic and specialised operations:
// src/maglev/maglev-ir.h:320
V(LoadContextSlot) \
V(LoadFloat64) \
V(LoadInt32) \
V(LoadTaggedFieldByFieldIndex) \
V(LoadFixedArrayElement) \
...
V(LoadGlobal) \
V(LoadNamedGeneric) \
V(LoadNamedFromSuperGeneric) \
...
V(Phi) \
V(RegisterInput) \
LoadNamedGeneric is the fallback: “call the IC”. A specialised load - a
map check followed by a field load - is not a different opcode but the same
LoadTaggedFieldByFieldIndex node with a different input (a CheckMaps
guard instead of the object). The graph builder decides which to emit.
The builder itself starts by materialising the function’s parameters and then walks the bytecode:
// src/maglev/maglev-graph-builder.cc:16527
bool MaglevGraphBuilder::Build() {
DCHECK(!is_inline());
if (should_abort_compilation_) return false;
compilation_unit_->InitializeSpecializationContextForTopLevel();
DCHECK_EQ(inlining_id_, SourcePosition::kNotInlined);
reducer_.SetBytecodeOffset(entrypoint_);
reducer_.SetStartSourcePosition(inlining_id_);
StartPrologue();
for (int i = 0; i < parameter_count(); i++) {
// TODO(v8:7700): Consider creating InitialValue nodes lazily.
ValueNode* v = AddNewNodeNoInputConversion<InitialValue>(
{}, interpreter::Register::FromParameterIndex(i), NodeType::kUnknown);
...InitialValue is the bridge between the register world of the bytecode and
the value world of the graph: a bytecode register holding undefined at
that point becomes a graph node, and from then on the register is a name
for a graph value.
The frame state is the other half of that bridge. InterpreterFrameState is
a map from bytecode register to ValueNode*, plus a slot for the
accumulator:
// src/maglev/maglev-interpreter-frame-state.h:35
class InterpreterFrameState {
public:
InterpreterFrameState(const MaglevCompilationUnit& info,
KnownNodeAspects* known_node_aspects)
: frame_(info), known_node_aspects_(known_node_aspects) {
frame_[interpreter::Register::virtual_accumulator()] = nullptr;
}
...
void set_accumulator(ValueNode* value) {
// Conversions should be stored in known_node_aspects/NodeInfo.
frame_[interpreter::Register::virtual_accumulator()] = value->Unwrap();
}This is the conceptual heart of Maglev: the accumulator, which Ignition treats as a mysterious extra register and Sparkplug treats as a machine register, is just another SSA value in Maglev. There is no accumulator concept at all once the graph exists.
Feedback: what Maglev does and does not do
Maglev does consume the feedback vector, unlike Sparkplug. Take a
property load. The bytecode GetNamedProperty carries a feedback slot,
and the builder asks the broker what the IC has learned:
// src/maglev/maglev-graph-builder.cc:6519
template <typename GenericAccessFunc>
MaybeReduceResult MaglevGraphBuilder::TryBuildLoadNamedProperty(
ValueNode* receiver, ValueNode* lookup_start_object, compiler::NameRef name,
compiler::FeedbackSource& feedback_source,
GenericAccessFunc&& build_generic_access) {
const compiler::ProcessedFeedback& processed_feedback =
broker()->GetFeedbackForPropertyAccess(
feedback_source, compiler::AccessMode::kLoad, name, true);
switch (processed_feedback.kind()) {
case compiler::ProcessedFeedback::kInsufficient:
return reducer_.EmitUnconditionalDeopt(
DeoptimizeReason::kInsufficientTypeFeedbackForGenericNamedAccess);
case compiler::ProcessedFeedback::kNamedAccess: {
RETURN_IF_DONE(TryReuseKnownPropertyLoad(lookup_start_object, name));
return TryBuildNamedAccess(
receiver, lookup_start_object, processed_feedback.AsNamedAccess(),
feedback_source, compiler::AccessMode::kLoad, build_generic_access);
}
case compiler::ProcessedFeedback::kHomomorphicPropertyAccess: {
RETURN_IF_DONE(TryReuseKnownPropertyLoad(lookup_start_object, name));
return TryBuildHomomorphicNamedAccess(
receiver, lookup_start_object,
processed_feedback.AsHomomorphicPropertyAccess(), feedback_source,
compiler::AccessMode::kLoad, build_generic_access);
}Five cases, and the first one is the interesting one: if the feedback is insufficient - never run, or the IC has been cleared - Maglev does not fall back to a generic call. It deoptimises unconditionally. The function is not worth compiling yet, so the compiler throws the whole attempt away and lets the interpreter keep running.
The generic fallback exists as a builder lambda, and the builder picks between the specialised and the generic form:
// src/maglev/maglev-graph-builder.cc:6566
ReduceResult MaglevGraphBuilder::VisitGetNamedProperty() {
// GetNamedProperty <object> <name_index> <slot>
ValueNode* object = LoadRegister(0);
compiler::NameRef name = GetRefOperand<Name>(1);
FeedbackSlot slot = GetSlotOperand(2);
compiler::FeedbackSource feedback_source{feedback(), slot};
PROCESS_AND_RETURN_IF_DONE(
TryBuildLoadNamedProperty(object, name, feedback_source), SetAccumulator);
// Create a generic load in the fallthrough.
ValueNode* context = GetContext();
return SetAccumulator(
AddNewNode<LoadNamedGeneric>({context, object}, name, feedback_source));
}Calls are handled the same way, but Maglev is much more conservative about them. The call feedback tells it the target function, and Maglev checks that the target is still the same function:
// src/maglev/maglev-graph-builder.cc:11633
ReduceResult MaglevGraphBuilder::BuildCallWithFeedback(
ValueNode* target_node, CallArguments& args,
const compiler::FeedbackSource& feedback_source) {
const compiler::ProcessedFeedback& processed_feedback =
broker()->GetFeedbackForCall(feedback_source);
if (processed_feedback.IsInsufficient()) {
return reducer_.EmitUnconditionalDeopt(
DeoptimizeReason::kInsufficientTypeFeedbackForCall);
}
...
if (call_feedback.target().has_value()) {
if (call_feedback.target()->IsJSFunction()) {
CallFeedbackContent content = call_feedback.call_feedback_content();
compiler::JSFunctionRef feedback_target =
call_feedback.target()->AsJSFunction();
...
RETURN_IF_ABORT(BuildCheckValueByReference(
target_node, feedback_target, DeoptimizeReason::kWrongCallTarget));So a call target is checked by reference equality against the function the IC saw, and if the check fails the code deoptimises. What Maglev does not do is inline the callee at the call site based on the feedback - that is TurboFan’s job, and doing it in Maglev would blow the compile-time budget.
Known maps: the set, not the singleton
The interesting part of Maglev’s type handling is that it does not track a single map. It tracks a set of possible maps, because the code may be reachable from several places with different shapes, and it tracks whether that set is still trustworthy.
KnownNodeAspects is the structure that holds this. Each graph value gets a
NodeInfo, and NodeInfo has a possible_maps_ field:
// src/maglev/maglev-known-node-aspects.h:290
bool possible_maps_are_known() const { return possible_maps_are_known_; }
const PossibleMaps& possible_maps() const {
// If !possible_maps_are_known_ then every map is possible and using the
// (probably empty) possible_maps_ set is definitely wrong.
CHECK(possible_maps_are_known_);
return possible_maps_;
}and the crucial bit is how the set goes stale:
// src/maglev/maglev-known-node-aspects.h:274
void OnSideEffect() {
if (any_map_is_unstable_) maps_are_stale_ = true;
}Only unstable maps go stale on a side effect. A map is unstable if the
object can still change shape - MigrateMapIfNeeded and friends. A stable
map is different: if it transitions, the transition invalidates a
compilation dependency, which causes V8 to throw the compiled code away
and recompile, rather than causing a deoptimisation at run time.
The consumer of all this is MapInference, a small wrapper that decides
whether map checks need to be emitted:
// src/maglev/maglev-map-inference.h:62
ReduceResult InsertMapChecks(Zone* zone) {
if (variant_ == kOnlyFresh) {
DCHECK(all_maps_are_fresh());
return ReduceResult::Done();
}
if (!node_info_) return ReduceResult::Done();
if (!node_info_->maps_are_stale()) return ReduceResult::Done();
// maps_are_stale implies the presence of unstable maps.
DCHECK(node_info_->any_map_is_unstable());
// We've recorded stale unstable maps. Insert map checks.
const PossibleMaps& maps = node_info_->possible_maps();
if (!maps.is_empty()) {
TRACE(TraceColor::kInfo << " * MapInference emitting map checks for "
<< PrintNodeLabel(object_));
...
RETURN_IF_ABORT(
reducer_->BuildCheckMaps(object_, base::VectorOf(maps_vector)));
}
// Maps are now fresh.
node_info_->MarkFresh();
// We have unstable maps and must re-enable invalidation tracking.
reducer_->known_node_aspects().MarkSideEffectsRequireInvalidation();
return ReduceResult::Done();
}The kOnlyFresh versus kAll distinction is the --maglev-use-unreliable-maps
flag in disguise. The MapInference constructor picks the variant:
// src/maglev/maglev-map-inference.h:37
MapInference(ReducerT* reducer, ValueNode* object)
: MapInference(reducer, object,
V8_LIKELY(v8_flags.maglev_use_unreliable_maps)
? Variant::kAll
: Variant::kOnlyFresh) {}kOnlyFresh means “trust the possible-map set completely, emit no checks,
because compilation dependencies will catch any change”. kAll means “the
set may contain maps that have since gone unstable, so emit a check”. The
flag defaults to kAll, i.e. the safe choice.
The pipeline
The compiler proper is a sequence of phases, each of which is a graph processor:
// src/maglev/maglev-compiler.cc:159
{
TRACE_EVENT(TRACE_DISABLED_BY_DEFAULT("v8.compile"),
"V8.Maglev.GraphBuilding");
SYNCHRONIZATION_POINT("MaglevGraphBuilding");
MaglevGraphBuilder graph_builder(
local_isolate, compilation_info->toplevel_compilation_unit(), graph);
if (!graph_builder.Build()) return false;
PrintAndVerify(graph, v8_flags.print_maglev_graphs,
MaglevPhase::kMaglevGraphBuilding);
}
if (v8_flags.maglev_non_eager_inlining) {
TRACE_EVENT(TRACE_DISABLED_BY_DEFAULT("v8.compile"),
"V8.Maglev.Inlining");
SYNCHRONIZATION_POINT("MaglevInlining");
MaglevInliner inliner(graph);
if (!inliner.Run()) return false;followed by truncation, LICM (still behind maglev_licm), and phi
representation selection. Then the post-hoc cleanups, then the register
allocation:
// src/maglev/maglev-compiler.cc:268
{
TRACE_EVENT(TRACE_DISABLED_BY_DEFAULT("v8.compile"),
"V8.Maglev.RegisterAllocation");
SYNCHRONIZATION_POINT("MaglevRegisterAllocation");
StraightForwardRegisterAllocator allocator(compilation_info, graph,
®alloc_info);StraightForwardRegisterAllocator is the “simple” in the name taken
seriously. It is a single-pass allocator with a per-value location, not a
graph-colouring algorithm with a fixed-point iteration. Maglev’s whole
philosophy is visible in the name: get a register allocation, do not spend
the compile-time budget making it optimal.
The node set is also deliberately small. BranchIfSmi,
BranchIfInt32ToBooleanTrue, BranchIfFloat64ToBooleanTrue,
BranchIfReferenceEqual - the branches are typed, so a comparison
against undefined is not a generic truthiness test but a compare against
a known constant.
What Maglev does not do
Three things are conspicuously absent, and each is a deliberate choice about where the tier boundary goes.
It does not exploit type feedback exhaustively. Maglev uses the feedback to decide one map check and one field access. It does not build polymorphic access trees, it does not do field-representation analysis beyond the fixed representations the node set allows, and it does not speculate on the value of a load.
It does not aggressively transform the graph. There is no full
load-elimination pass, no escape analysis, no speculative
representation selection driven by range analysis. The maglev_* feature
flags are a long list of things that are either off or experimental:
maglev_licm, maglev_loop_peeling, maglev_range_analysis,
maglev_truncation, maglev_escape_analysis.
It does not inline aggressively. max_maglev_inline_depth defaults to
1 and max_maglev_hard_inline_depth to 10, but the bytecode size limit
is what really binds:
// src/flags/flag-definitions.h:699
DEFINE_INT(max_maglev_inlined_bytecode_size, 100,
"maximum size of bytecode for a single inlining")A hundred bytecodes. TurboFan will happily inline thousands.
OSR from Maglev to TurboFan goes through the interpreter
This is the part that surprises everyone who reads the tiering diagram. When a function running in Maglev code gets hot enough for TurboFan, the OSR does not jump straight into TurboFan code. It deoptimises back to the interpreter first.
The runtime function that does it spells out the reason:
// src/runtime/runtime-compiler.cc:737
Tagged<Object> CompileOptimizedOSRFromMaglev(Isolate* isolate,
DirectHandle<JSFunction> function,
BytecodeOffset osr_offset) {
// This path is only relevant for tests (all production configurations enable
// concurrent OSR). It's quite subtle, if interested read on:
if (V8_UNLIKELY(!isolate->concurrent_recompilation_enabled() ||
!v8_flags.concurrent_osr)) {
// - Synchronous Turbofan compilation may trigger lazy deoptimization (e.g.
// through compilation dependency finalization actions).
// - Maglev (currently) disallows marking an opcode as both can_lazy_deopt
// and can_eager_deopt.
// - Maglev's JumpLoop opcode (the logical caller of this runtime function)
// is marked as can_eager_deopt since OSR'ing to Turbofan involves
// deoptimizing to Ignition under the hood.
// - Thus this runtime function *must not* trigger a lazy deopt, and
// therefore cannot trigger synchronous Turbofan compilation (see above).
//
// We solve this synchronous OSR case by bailing out early to Ignition, and
// letting it handle OSR. How do we trigger the early bailout? Returning
// any non-null InstructionStream from this function triggers the deopt in
// JumpLoop.
Read the third bullet again: “OSR’ing to Turbofan involves deoptimizing to
Ignition under the hood”. The Maglev JumpLoop node is marked as
can_eager_deopt precisely because that is what it does.
The reason is a genuine circularity. Maglev’s JumpLoop cannot be both
lazy-deopt-able and eager-deopt-able at the same time, and a synchronous
TurboFan compilation can trigger a lazy deoptimisation through compilation
dependency finalisation. If the lazy deopt fired in the middle of the OSR
request, you would get a deopt loop. So the design routes around it: bail
out to Ignition, and let Ignition’s own OSR machinery - the one from the
Ignition article - do the
TurboFan OSR properly.
The strategy is configurable, because there are real costs to the safe path:
// src/flags/flag-definitions.h:1134
DEFINE_INT(osr_from_maglev, 3,
"bitset mode for OSR from Maglev to Turbofan (0=off, 1=on OSR "
"compile, 2=if loop was OSR'd, 4=always)")and the builder decides where to put the interrupt-budget check that triggers the whole thing:
// src/maglev/maglev-graph-builder.cc:16861
bool MaglevGraphBuilder::ShouldEmitOsrInterruptBudgetChecks(
FeedbackSlot feedback_slot, BytecodeOffset osr_offset) {
if (!v8_flags.turbofan || !v8_flags.use_osr || !v8_flags.osr_from_maglev) {
return false;
}The deopt path back down
A deoptimisation from Maglev code rebuilds an interpreter frame and jumps
back into Ignition. The frame is described by the deopt info, and
DeoptInfoVisitor walks it - including the virtual objects that escape
analysis may have materialised:
// src/maglev/maglev-deopt-frame-visitor.h:17
template <typename DeoptInfoT>
class DeoptInfoVisitor {
public:
template <typename Function>
static void ForEager(DeoptInfoT* deopt_info, Function&& f) {
DeoptInfoVisitor<DeoptInfoT> visitor(deopt_info);
visitor.Visit(deopt_info->top_frame(), f);
}Note the two entry points: ForEager and ForLazy. An eager deopt
happens at the point of the check, so the frame state is the current one; a
lazy deopt happens at a safepoint some time later, so the visitor has to
use the frame state that was recorded at the deopt point, not the current
one. The parent() chain is walked first, so the frames are visited
outermost-first and the deopt data can be written in the right order.
Trade-offs
What it buys. Real specialisation, quickly. A monomorphic property load
becomes a map check and a field load. A branch on undefined becomes a
compare against a constant. Values that live in registers stay in
registers. And it costs a small fraction of TurboFan’s compile time, so the
“function became hot” moment is not delayed by minutes of compilation.
What it costs.
- It can be wrong. Every specialisation is a guess backed by a map check. If the guess goes wrong, the function deoptimises, goes back to the interpreter, and has to be recompiled. Maglev’s whole design is about making that deopt cheap rather than making it impossible.
- Insufficient feedback is fatal. A function whose ICs have not warmed up cannot be compiled at all - it deoptimises immediately. This is why Maglev’s tier-up threshold is an invocation count and not a “compilable” test.
- The register allocator is crude.
StraightForwardRegisterAllocatoris fast, not good. Code that TurboFan would keep entirely in registers may get spills. - The compiler is a work in progress. The flag list is long and the features are mostly experimental.
The bottom line
Maglev is a cheap, real optimiser. It takes the feedback vector that Sparkplug filled in, turns it into map checks and typed branches, and emits code for a graph rather than for a bytecode stream - but it does it with a register allocator that gives up early and a type system that tracks a set of possible maps rather than a single one.
The mental model to keep: Maglev is TurboFan with the expensive parts removed. Every design decision is a question of “would this cost more compile time than it saves run time?” - and the answer is usually no, which is why Maglev tracks possible-map sets, why it inlines only tiny functions, and why OSR’ing out of it goes back through the interpreter rather than straight to TurboFan.
The thing people get wrong
The common belief is that Maglev is “a stripped-down TurboFan” - the same optimisations, just fewer of them. It is not. Maglev is a different compiler with a different IR, a different register allocator and a different philosophy. TurboFan’s graph is a typed graph with a scheduling phase and a graph-colouring register allocator; Maglev’s graph has explicit representation in the node types, and its register allocator is a single forward pass.
The second misconception is that Maglev is a stepping stone that will
eventually be removed. It will not, as long as the gap between “Sparkplug
at 0 invocations” and “TurboFan at 3000 invocations” is a gap somebody has
to fill. On a phone, where invocation_count_for_maglev is 1000 rather
than 400, that gap is even wider.
Related reading: