Sparkplug: V8's One-Bytecode-to-Machine-Code Compiler
Sparkplug compiles Ignition bytecodes one-for-one into machine code with no optimisation at all, keeping the same stack frame the interpreter used so that OSR can reuse it in place.
TL;DR: Sparkplug is a 1:1 bytecode-to-machine-code compiler. It walks the bytecode array and emits roughly one machine instruction sequence per bytecode, with no inlining, no type specialisation and no register allocation of its own - the bytecode registers stay where they were, in the stack frame. The cleverest bit is not the code generation but the frame:
InterpreterFrameConstants::kBytecodeOffsetFromFpandBaselineFrameConstants::kFeedbackCellFromFpare the same offset, so an OSR out of the interpreter rewrites one word of the existing frame instead of building a new one.
Ignition is fast enough to be a fallback, but it is still an interpreter: every bytecode costs a dispatch. Sparkplug exists to remove that dispatch and nothing else. It is the tier that V8 reaches for when a function is warm enough to matter but not warm enough to be worth optimising.
The design constraint that shapes everything is that Sparkplug must be fast to compile. It is not allowed to think. If compiling Sparkplug code took as long as compiling TurboFan code, the whole tiering scheme would collapse, because the middle tier would become the expensive one.
One bytecode, one instruction sequence
The compiler is a single loop over the bytecode array. GenerateCode does a
pre-pass to mark exception handlers as indirect jump targets (so that CFI
does not reject jumps into the code), then emits the prologue and walks the
bytecodes:
// src/baseline/baseline-compiler.cc:323
void BaselineCompiler::GenerateCode() {
{
RCS_BASELINE_SCOPE(PreVisit);
// Mark exception handlers as valid indirect jump targets. This is required
// when CFI is enabled, to allow indirect jumps into baseline code.
HandlerTable table(*bytecode_);
for (uint32_t i = 0; i < table.NumberOfRangeEntries(); ++i) {
MarkIndirectJumpTarget(table.GetRangeHandler(i));
}
for (; !iterator_.done(); iterator_.Advance()) {
PreVisitSingleBytecode();
}
iterator_.Reset();
}
...
{
RCS_BASELINE_SCOPE(Visit);
Prologue();
AddPosition();
for (; !iterator_.done(); iterator_.Advance()) {
VisitSingleBytecode();
AddPosition();
}
}
}Two passes, because the first one has to know where all the labels are
before the second one can bind them. There is no IR, no graph, no
scheduling, no register allocator. VisitSingleBytecode is a giant switch
over the opcode that calls a Visit<Name> method:
// src/baseline/baseline-compiler.cc:544
void BaselineCompiler::VisitSingleBytecode() {
#ifdef DEBUG
effect_state_.clear();
#endif
int offset = iterator().current_offset();
if (IsJumpTarget(offset)) __ Bind(&labels_[offset]);
// This is required when CFI is enabled.
if (IsIndirectJumpTarget(offset)) {
__ JumpTarget();
}
...
switch (bytecode) {
#define BYTECODE_CASE(name, ...) \
case interpreter::Bytecode::k##name: \
Visit##name(); \
break;
BYTECODE_LIST(BYTECODE_CASE, BYTECODE_CASE)
#undef BYTECODE_CASE
}A typical visitor is four lines:
// src/baseline/baseline-compiler.cc:1047
void BaselineCompiler::VisitLdar() {
LoadRegister(kInterpreterAccumulatorRegister, 0);
}
void BaselineCompiler::VisitStar() {
StoreRegister(0, kInterpreterAccumulatorRegister);
}Ldar is “load accumulator from register”, Star is “store accumulator into
register”. In Sparkplug these are a mov each. The accumulator lives in a
fixed machine register - rax on x64, x0 on arm64 - declared per
architecture in src/codegen/*/register-*.h.
The bytecode registers do not move
Here is the part that makes Sparkplug cheap. The bytecode register file
stays in the stack frame, at exactly the same offsets the interpreter used.
The baseline assembler’s notion of “a bytecode register” is a MemOperand
off the frame pointer:
// src/baseline/x64/baseline-assembler-x64-inl.h:64
MemOperand BaselineAssembler::RegisterFrameOperand(
interpreter::Register interpreter_register) {
return MemOperand(rbp, interpreter_register.ToOperand() * kSystemPointerSize);
}ToOperand() is the same negative-offset encoding the interpreter uses
(see the Ignition article), so a bytecode register is at
rbp - 8*k for some small k. A Star becomes a single mov to a
frame-relative address.
The consequence is that Sparkplug has no register allocator at all. It never has to decide what lives in a machine register, because almost nothing does: the accumulator is one fixed register, a scratch register is borrowed from a small scratch pool, and everything else is memory. The prologue even has to fill the frame with garbage, because the interpreter’s frame contents are not meaningful until the bytecode writes them:
// src/baseline/x64/baseline-compiler-x64-inl.h:37
void BaselineCompiler::PrologueFillFrame() {
ASM_CODE_COMMENT(&masm_);
// Inlined register frame fill
interpreter::Register new_target_or_generator_register =
bytecode_->incoming_new_target_or_generator_register();and then it pushes kInterpreterAccumulatorRegister (whatever happens to be
in it) once per register, in a loop, because the frame has to be full of
something before the GC can scan it.
The frame is shared with the interpreter
The frame constants are the clearest statement of the design. All unoptimized frames - interpreted or baseline - share a layout, and the only difference between them is what sits in one particular slot:
// src/execution/frame-constants.h:786
class UnoptimizedFrameConstants : public StandardFrameConstants {
public:
// FP-relative.
static constexpr int kBytecodeArrayFromFp =
STANDARD_FRAME_EXTRA_PUSHED_VALUE_OFFSET(0);
static constexpr int kBytecodeOffsetOrFeedbackCellFromFp =
STANDARD_FRAME_EXTRA_PUSHED_VALUE_OFFSET(1);
static constexpr int kFeedbackVectorFromFp =
STANDARD_FRAME_EXTRA_PUSHED_VALUE_OFFSET(2);
DEFINE_STANDARD_FRAME_SIZES(3);
...
static constexpr int kRegisterFileFromFp =
-kFixedFrameSizeFromFp - kSystemPointerSize;Note the slot name: kBytecodeOffsetOrFeedbackCellFromFp. One slot, two
meanings.
// src/execution/frame-constants.h:818
class InterpreterFrameConstants : public UnoptimizedFrameConstants {
public:
static constexpr int kBytecodeOffsetExpressionIndex =
kBytecodeOffsetOrFeedbackCellExpressionIndex;
static constexpr int kBytecodeOffsetFromFp =
kBytecodeOffsetOrFeedbackCellFromFp;
};
// Sparkplug frames are unoptimized frames that are being executed by
// sparkplug-compiled baseline code. base. In this case, the "offset or cell"
// slot contains the closure feedback cell.
class BaselineFrameConstants : public UnoptimizedFrameConstants {
public:
static constexpr int kFeedbackCellExpressionIndex =
kBytecodeOffsetOrFeedbackCellExpressionIndex;
static constexpr int kFeedbackCellFromFp =
kBytecodeOffsetOrFeedbackCellFromFp;
}The interpreter puts the bytecode offset there, so it can resume at the right instruction after a call. Baseline code puts the feedback cell there, because it has no bytecode offset to speak of. The slot is the same.
OSR into Sparkplug rewrites the frame in place
This is the payoff. When the interpreter decides to OSR into Sparkplug, it does not build a new frame - it reinterprets the existing one. Look at the x64 OSR builtin:
// src/builtins/x64/builtins-x64.cc:5707
// Save bytecode offset from the stack frame.
__ SmiUntagUnsigned(
kInterpreterBytecodeOffsetRegister,
MemOperand(rbp, InterpreterFrameConstants::kBytecodeOffsetFromFp));
// Replace bytecode offset with feedback cell.
static_assert(InterpreterFrameConstants::kBytecodeOffsetFromFp ==
BaselineFrameConstants::kFeedbackCellFromFp);
__ movq(MemOperand(rbp, BaselineFrameConstants::kFeedbackCellFromFp),
feedback_cell);
feedback_cell = no_reg;
// Update feedback vector cache.
static_assert(InterpreterFrameConstants::kFeedbackVectorFromFp ==
BaselineFrameConstants::kFeedbackVectorFromFp);
__ movq(MemOperand(rbp, InterpreterFrameConstants::kFeedbackVectorFromFp),
feedback_vector);Two static_asserts pin the layouts together, and then it is three
instructions: read the offset, overwrite the slot with the feedback cell,
overwrite the feedback vector cache. The frame’s register file, its
parameters, its context and its function are all still exactly where they
were, because Sparkplug uses them in exactly the same places.
Then it has to find the machine PC that corresponds to the bytecode offset it was running. That is what the bytecode offset table is for.
The bytecode offset table: a VLQ-compressed PC map
Sparkplug code does not know its own bytecode offsets at runtime. To
support stack traces, the debugger, and OSR, V8 needs a mapping from
machine PC back to bytecode offset, and from bytecode offset forward to
machine PC. The mapping is stored in the Code object as a table of
variable-length quantities - the delta in machine bytes between the end
of one bytecode’s code and the end of the next:
// src/baseline/baseline-compiler.h:34
class BytecodeOffsetTableBuilder {
public:
void AddPosition(size_t pc_offset) {
size_t pc_diff = pc_offset - previous_pc_;
DCHECK_GE(pc_diff, 0);
DCHECK_LE(pc_diff, std::numeric_limits<uint32_t>::max());
base::VLQEncodeUnsigned(&bytes_, static_cast<uint32_t>(pc_diff));
previous_pc_ = pc_offset;
}and the table is attached to the code object at build time:
// src/baseline/baseline-compiler.cc:357
MaybeHandle<Code> BaselineCompiler::Build() {
RCS_BASELINE_SCOPE(Build);
CodeDesc desc;
__ GetCode(local_isolate_, &desc);
// Allocate the bytecode offset table.
Handle<TrustedByteArray> bytecode_offset_table =
bytecode_offset_table_builder_.ToBytecodeOffsetTable(local_isolate_);
Factory::CodeBuilder code_builder(local_isolate_, desc, CodeKind::BASELINE);
code_builder.set_bytecode_offset_table(bytecode_offset_table);Reading it back is a linear walk over the VLQ deltas:
// src/baseline/bytecode-offset-iterator.h:31
inline void Advance() {
DCHECK(!done());
current_pc_start_offset_ = current_pc_end_offset_;
current_pc_end_offset_ += ReadPosition();
current_bytecode_offset_ = bytecode_iterator_.current_offset();
bytecode_iterator_.Advance();
}Because the deltas are relative, the table is tiny: the compiler reserves
16 + bytecode_size/4 bytes on the assumption that a bytecode averages
about four bytes of machine code.
Feedback: Sparkplug needs the slots, but does not read them
This is the second surprising thing. Sparkplug does consume feedback - the inline caches it calls are the real ICs, and they really do update the feedback vector - but it does not consume it at compile time. It never looks at a map and specialises the code on it.
The compile-time view of a property load is a call to a generic IC builtin:
// src/baseline/baseline-compiler.cc:1071
void BaselineCompiler::VisitGetNamedProperty() {
if (allow_sparkplug_plus_) {
CallBuiltin<Builtin::kLoadICUninitializedBaseline>(
RegisterOperand(0), // object
Constant<Name>(1), // name
FeedbackSlotAsTagged(2)); // slot
} else {
CallBuiltin<Builtin::kLoadICGenericBaseline>(
RegisterOperand(0), // object
Constant<Name>(1), // name
FeedbackSlotAsTagged(2)); // slot
and the builtin runs the ordinary monomorphic/polymorphic IC logic:
// src/ic/accessor-assembler.cc:4984
void AccessorAssembler::GenerateLoadICGenericBaseline() {
using Descriptor = LoadBaselineDescriptor;
auto receiver = Parameter<JSAny>(Descriptor::kReceiver);
auto name = Parameter<Object>(Descriptor::kName);
auto slot = Parameter<TaggedIndex>(Descriptor::kSlot);
TNode<FeedbackVector> vector = LoadFeedbackVectorFromBaseline();
TNode<Context> context = LoadContextFromBaseline();
LoadICParameters p =
MakeLoadICParameters(context, receiver, name, slot, vector);
LoadIC(&p);
}So the feedback vector is populated by baseline code, exactly as it would be by the interpreter. That is the point: the feedback vector is the handoff between the tiers. Sparkplug is not a dead end - it is the tier that fills in the data that Maglev and TurboFan will later read.
But the feedback vector must exist before the code runs, which is why
Compiler::CompileBaseline allocates it as a hard prerequisite:
// src/codegen/compiler.cc:3239
// static
bool Compiler::CompileBaseline(Isolate* isolate,
DirectHandle<JSFunction> function,
ClearExceptionFlag flag,
IsCompiledScope* is_compiled_scope) {
Handle<SharedFunctionInfo> shared(function->shared(), isolate);
if (!CompileSharedWithBaseline(isolate, shared, flag, is_compiled_scope)) {
return false;
}
// Baseline code needs a feedback vector.
JSFunction::EnsureFeedbackVector(isolate, function, is_compiled_scope);The comment is one line and it is the whole requirement: Sparkplug does not need to understand feedback, but the slots have to be there.
Embedded feedback: a type hint stored in the bytecode
There is a second, smaller feedback mechanism, and it is the cleverest thing
in this tier. Some bytecodes - Add, Subtract, TestEqual and friends -
carry an extra one-byte operand inside the bytecode array itself, which
records the type the operation last saw:
// src/interpreter/bytecode-array-iterator.cc:461
int BytecodeArrayIterator::GetEmbeddedFeedbackOffset(int operand_index) const {
DCHECK_EQ(Bytecodes::GetOperandType(current_bytecode(), operand_index),
OperandType::kEmbeddedFeedback);
return BytecodeArray::kHeaderSize - kHeapObjectTag + current_offset() +
prefix_size_ + current_operand_offset(operand_index);
}The bytecode array is mutable, and the interpreter writes to that byte after every binary operation:
// src/interpreter/interpreter-generator.cc:991
void BinaryOpWithEmbeddedFeedback_WithoutDispatch(
BinaryOpGenerator generator) {
TNode<Object> lhs = LoadRegisterAtOperandIndex(0);
TNode<Object> rhs = GetAccumulator();
TNode<Context> context = GetContext();
TNode<BytecodeArray> bytecode_array = BytecodeArrayTaggedPointer();
TNode<IntPtrT> feedback_offset =
BytecodeOperandOffset(kEmbeddedFeedbackOperandIndex);
BinaryOpAssembler binop_asm(state());
TNode<Object> result = (binop_asm.*generator)(
[=] { return context; }, lhs, rhs,
binop_asm.MakeEmbeddedFeedbackUpdater(bytecode_array, feedback_offset),
/*rhs_known_smi=*/false);
SetAccumulator(result);
}One byte, written in place, right next to the opcode. The point is that it is shared mutable state between the interpreter and the compiler: when Sparkplug later compiles this function, it can read the byte and see “the last time this ran, both operands were Smis”.
Ordinary Sparkplug ignores it and calls the generic builtin. But the experimental Sparkplug+ mode uses it to call a typed builtin instead:
// src/baseline/baseline-compiler.cc:1265
#define VISIT_TYPED_BINARY_OPERATION(TypedStubList, Name, GenericName, Lhs, \
Rhs) \
using Feedback = BinaryOperationFeedback; \
auto feedback_index_offset = \
iterator().GetEmbeddedFeedbackOffset(kEmbeddedFeedbackOperandIndex); \
if (allow_sparkplug_plus_) { \
switch (Feedback::DecodeTypeIndex(static_cast<Feedback::TypeIndex>( \
EmbeddedFeedback(kEmbeddedFeedbackOperandIndex)))) { \
TypedStubList(TYPED_BINOP_CASE, Name, Lhs, Rhs) default \
: CallBuiltin<Builtin::k##GenericName##_Generic_Baseline>( \
Lhs, Rhs, feedback_index_offset); \
break; \
} \
} else { \
CallBuiltin<Builtin::k##GenericName##_Generic_Baseline>( \
Lhs, Rhs, feedback_index_offset); \
}The sparkplug_plus flag is “enable dynamic patching on JS baseline code”
and sparkplug_inline_smi is “inline the Smi fast path of embedded feedback
operations into baseline code”. This is Sparkplug creeping towards
specialisation - but note that it is still not optimising: the choice of
builtin is made from a single byte, at compile time, with no speculation and
no deoptimisation.
Interrupt budget: the tier-up trigger
Sparkplug code has to know when to ask for a better tier. It uses the same interrupt-budget mechanism as the interpreter, subtracted from the function’s feedback cell at each backward branch:
// src/baseline/baseline-compiler.cc:674
void BaselineCompiler::UpdateInterruptBudgetAndJumpToLabel(
int weight, Label* label, Label* skip_interrupt_label,
StackCheckBehavior stack_check_behavior) {
if (weight != 0) {
ASM_CODE_COMMENT(&masm_);
__ AddToInterruptBudgetAndJumpIfNotExceeded(weight, skip_interrupt_label);
DCHECK_LT(weight, 0);
CallRuntime(stack_check_behavior == kEnableStackCheck
? Runtime::kBytecodeBudgetInterruptWithStackCheck_Sparkplug
: Runtime::kBytecodeBudgetInterrupt_Sparkplug,
__ FunctionOperand());
}
if (label) __ Jump(label);
}When the budget hits zero, a runtime call fires, and the tiering manager decides what to do next.
The tier-up to Sparkplug itself is decided in
TieringManager::OnInterruptTick. The condition is short:
// src/execution/tiering-manager.cc:602
const bool compile_sparkplug =
CanCompileWithBaseline(isolate_, function->shared()) &&
function->ActiveTierIsIgnition(isolate_) && !maybe_had_optimized_osr_code;Three conditions: the function is eligible (it must have a bytecode
array, not be a builtin, not be in debug mode), it is currently running in
the interpreter, and it does not already have optimized OSR code. The
eligibility test lives in CanCompileWithBaseline, and the flag that
enables the whole tier is --sparkplug:
// src/flags/flag-definitions.h:1271
DEFINE_BOOL(sparkplug, ENABLE_SPARKPLUG_BY_DEFAULT,
"enable Sparkplug baseline compiler")
DEFINE_BOOL(always_sparkplug, false, "directly tier up to Sparkplug code")Batching: Sparkplug is compiled in the background, in groups
The last piece is how the compilation is scheduled. baseline_batch_compilation
defaults to true, and the batch compiler accumulates functions and compiles
them together - optionally on a background thread:
// src/baseline/baseline-batch-compiler.cc:312
void BaselineBatchCompiler::CompileBatch(DirectHandle<JSFunction> function) {
{
IsCompiledScope is_compiled_scope(
function->shared()->is_compiled_scope(isolate_));
Compiler::CompileBaseline(isolate_, function, Compiler::CLEAR_EXCEPTION,
&is_compiled_scope);
}
for (uint32_t i = 0; i < last_index_; i++) {
Tagged<MaybeObject> maybe_sfi = compilation_queue_->get(i);
MaybeCompileFunction(maybe_sfi);
compilation_queue_->set(i, kClearedWeakValue);
}
ClearBatch();
}Batching is not just about throughput. The tiering manager’s own comment explains why a strictly linear tiering system is not possible:
// src/execution/tiering-manager.cc:629
// TODO(jgruber): Consider integrating this into a linear tiering system
// controlled by TieringState in which the order is always
// Ignition-Sparkplug-Turbofan, and only a single tierup is requested at
// once.
// It's unclear whether this is possible and/or makes sense - for example,
// batching compilation can introduce arbitrary latency between the SP
// compile request and fulfillment, which doesn't work with strictly linear
// tiering.
Trade-offs
What it buys. The dispatch overhead disappears. A bytecode that cost an
indirect branch plus a table load now costs a direct jump or a mov. For
code that runs “a few hundred times” - the overwhelming majority of
functions in a real program - that is most of the available win, and it
arrives in a fraction of the time a real optimiser would take.
What it costs.
- No optimisation whatsoever. Every property load is a generic IC call.
Every
a + bgoes through the generic binary-op builtin. The interpreter and Sparkplug do roughly the same work; Sparkplug just does it with better instruction selection. - Everything lives in memory. The bytecode register file is in the frame, so almost every bytecode is a load or a store to the stack. A real optimiser would keep hot values in registers.
- Code size. The code is roughly
kAverageBytecodeToInstructionRatiotimes the bytecode size (5 on ia32, 7 elsewhere) and it is not compressible, because it is not a pattern. - The frame has to be filled with garbage before the function can run, because the GC may scan it at any point.
- It is a dead end for code that never gets hot. A function that runs twice pays for a compilation it never uses.
The bottom line
Sparkplug is a bytecode-to-machine-code transliterator with the same memory model as the interpreter. It does not change where values live - the bytecode registers are still frame slots, the accumulator is one architecturally-fixed register - so it can be entered from the interpreter by rewriting a single frame word, and the bytecode offset table is enough to find the right instruction.
The mental model: Sparkplug removes the interpreter’s dispatch, and nothing else. Its job is to make the second tier of the tiering ladder cheap enough to be worth having, and to keep the feedback vector warm for the tiers above.
The thing people get wrong
The common belief is that “baseline” means “a weaker version of the optimising compiler” - that Sparkplug does some analysis and produces mediocre optimised code. It does not. Sparkplug does no analysis. It emits code whose shape is determined entirely by the bytecode, and the “optimisation” it performs over the interpreter is limited to two things: replacing the dispatch with a jump, and choosing better instructions for the same operations.
The other misconception is that Sparkplug needs type feedback to work. It does not - it is feedback-independent in the tiering manager’s own words. It needs the feedback vector to exist, because its inline caches write into it, and that data is what the tiers above will read.
Related reading: