Baseline compilers
Learn how baseline compilers such as V8 Sparkplug turn bytecode into quick machine code, reuse interpreter frames, and trade speed for quality.
- 01Explain how Sparkplug worksDescribe how V8's Sparkplug turns bytecode straight into simple machine code, one step at a time, without first rebuilding the program in its own form.
- 02Compare baseline tiers in different enginesPut V8's Sparkplug next to SpiderMonkey's Baseline Interpreter and Baseline Compiler, and JavaScriptCore's Baseline JIT.
- 03Weigh the costWork out when a quick compile pays back by making each call faster, and when a stronger, slower compiler is worth it instead.
A quick compiler in the middle
A JavaScript engine is the program inside a browser or Node that runs your code. It does not run a function the same way forever. It has several tiers: steps that go from a quick start to fast code. A function starts on the first step. If it runs very often, it can move up. A function that runs very often is called hot.
On the first step, the interpreter runs bytecode. Bytecode is a simple list of small steps that the engine made from your code. The interpreter works like this, again and again:
- It reads the next bytecode step.
- It finds the piece of code that handles that kind of step. This is called dispatch.
- It runs that piece of code.
The reading and finding take time on every step of every call. That extra time is the interpreter's overhead.
To compile means to turn code into another form before running it. Machine code is the form the computer's processor runs directly. A baseline compiler turns bytecode into simple machine code, and it does this quickly. The result is not the fastest code the engine can make. But now nothing has to read and look up each bytecode step on every call.
A baseline compiler is a quick compiler that turns bytecode into simple machine code. It does not try to make the fastest code. It is a JIT ("just in time") compiler: it compiles while the program is already running. It removes the interpreter's overhead, while the slower, stronger compilers wait until a function has run many more times.
In the last lesson, inline caches, the engine saved facts about each operation in your code. Now the question is about cost. How much time should the engine spend compiling a function before that function has shown that it is hot? A baseline compiler's answer is "only a little."
This lesson stays on the baseline step. Tiered compilation showed all the steps. Optimizing compilers covers the strongest compilers, such as V8's TurboFan. A later lesson covers what happens when an optimizing compiler's guess turns out wrong.
Sparkplug reads bytecode directly
V8V8 is the engine in Chrome and Node. Its interpreter is called Ignition. Its baseline compiler is called Sparkplug. Sparkplug does not read your JavaScript source. It starts from the bytecode that Ignition already made.
Most compilers first rebuild the program in their own private form. That form is called an intermediate representation, or IR. The IR helps a compiler study the code and change it, but building it takes time. V8's public article on Sparkplug says Sparkplug skips this. It turns each bytecode step straight into machine code.
function stamp(total) { const fee = 2; return total + fee;} console.log(stamp(40));Line 1 starts a function called stamp. Line 2 makes a local value, fee, set to 2. Line 3 adds the argument total to fee and returns the sum. Line 6 calls stamp(40) and prints 42.
Before the function runs, Ignition turns it into bytecode. Here is the bytecode Node prints for stamp. The lesson tests check these lines.
[generated bytecode for function: stamp]
LdaSmi [2]
Star0
Ldar r0
Add a0, [0]
ReturnTwo words first. A register is a numbered spot where the interpreter keeps a value. The accumulator is one special spot that holds the current result. In plain words, line by line:
- Line 1 is a heading: the bytecode below belongs to
stamp. - Line 2,
LdaSmi [2], puts the small whole number 2 in the accumulator. "Smi" is short for "small integer". - Line 3,
Star0, copies the accumulator into register 0. Register 0 isfee. - Line 4,
Ldar r0, copies register 0 back into the accumulator. - Line 5,
Add a0, [0], adds the first argument,a0, which istotal. The[0]names feedback slot 0: the place where the engine keeps notes about the values this add has seen. - Line 6,
Return, returns the value in the accumulator.
Some steps do complicated work, like an add that might get strings instead of numbers. For those, Sparkplug's machine code calls a builtin: a ready-made piece of machine code inside the engine that does one common job. Ignition calls the same builtins. That keeps Sparkplug small and quick.
The table below sums up how Sparkplug is built. Read each row as one part and what Sparkplug does there.
| Part of the design | What Sparkplug does |
|---|---|
| What it starts from | It starts from Ignition's bytecode, which is already made from your code. |
| How it is built | It reads the bytecode steps in order, once, and builds no IR. |
| What code it makes | It makes simple machine code that often calls builtins for the complicated parts. |
| What it saves | Nothing has to read each bytecode step and look up how to run it on every call. |
A cook has made the same tea many times. They no longer read each line of the recipe. They do the usual steps quickly, from memory. When someone orders something unusual, they ask the kitchen for help. They never stop to write a new, better recipe.
- In real life: The tea recipe is already written
- In JavaScript: Ignition has already made the bytecode
- In real life: The cook knows each usual step by heart
- In JavaScript: Sparkplug has one fixed piece of machine code for each kind of bytecode step
- In real life: An unusual order gets help from the kitchen
- In JavaScript: Complicated steps call a builtin
- In real life: The tea is ready fast, with no new recipe
- In JavaScript: Baseline code is ready fast, but it is not the fastest code
A small template compiler, step by step
STEP THROUGHA template is a fixed piece of code with a blank to fill in. A template compiler keeps one template for each kind of bytecode step. For each step it reads, it copies the matching template and fills in the blank. Sparkplug works this way.
The code below is a small model in plain JavaScript. It does not look inside your browser's engine. The toy bytecode uses names like fee instead of register numbers, so it is easier to read. The output strings stand in for machine code.
const bytecode = [ ["LdaSmi", 2], ["Star", "fee"], ["Ldar", "fee"], ["Add", "total"], ["Return"],]; function compileTemplates(bytecodes) { const machine = []; for (const instruction of bytecodes) { const [op, operand] = instruction; if (op === "LdaSmi") machine.push(`load_smi ${operand}`); if (op === "Star") machine.push(`store ${operand}`); if (op === "Ldar") machine.push(`load ${operand}`); if (op === "Add") machine.push(`add ${operand}`); if (op === "Return") machine.push("return accumulator"); } return machine;} console.log(compileTemplates(bytecode).join(" | "));In plain words, line by line:
- Lines 1 to 7 hold the toy bytecode: five steps, the same five as the real bytecode for
stamp. - Line 9 starts the compiler function,
compileTemplates. - Line 10 makes an empty list,
machine, for the output. - Line 11 goes through the steps in order, one at a time.
- Line 12 splits each step into its name,
op, and its value,operand. - Lines 13 to 17 check the name and add the matching template to the list.
- Line 19 returns the list. Line 22 prints it with
|between the templates:load_smi 2 | store fee | load fee | add total | return accumulator.
This small model turns a short list of bytecode steps into templates that stand in for machine code. It does not look inside your browser's engine.
script
["LdaSmi", 2], ["Star", "fee"], ["Ldar", "fee"], ["Add", "total"], ["Return"],]; function compileTemplates(bytecodes) { const machine = []; for (const instruction of bytecodes) { const [op, operand] = instruction; if (op === "LdaSmi") machine.push(`load_smi ${operand}`); if (op === "Star") machine.push(`store ${operand}`); if (op === "Ldar") machine.push(`load ${operand}`); if (op === "Add") machine.push(`add ${operand}`); if (op === "Return") machine.push("return accumulator"); } return machine;} console.log(compileTemplates(bytecode).join(" | "));Notice what the compiler does not do. It never looks at the whole function at once. It does not join steps together or make guesses about the values. That is why it is fast. It is also why its code is not the fastest possible. The stronger compilers do that extra work later.
Frames that match the interpreter
FRAMESWhen a function is called, the engine sets up a stack frame for that call. A stack frame is a block of memory with fixed spots, called slots. Its slots hold:
- the function being run, and its context: the outer variables it can reach,
- the arguments,
- a few extra facts about the call, such as where it is in the bytecode,
- the registers,
- the way back to the code that made the call.
Sparkplug keeps its frames in the same shape as Ignition's frames. That matters, because many other parts of the engine read frames:
- the debugger, which shows you the variables when you pause,
- the profiler, which measures where the time goes,
- stack traces in error messages,
- on-stack replacement, or OSR: moving a function to another tier while it is still running, for example in the middle of a long loop.
Because the frames match, all of these keep working with Sparkplug code. They need little or no new code.
A notebook is easy to read when every page has the same headings. Someone who writes faster can keep those same headings. Under one heading they may write a different kind of note, but every reader still knows where to look.
- In real life: Each page has the same headings
- In JavaScript: The interpreter frame has slots for the function, context, extra facts, and registers
- In real life: A faster writer uses the same headings
- In JavaScript: Baseline code keeps its values in the same slots
- In real life: One heading holds a different note
- In JavaScript: Sparkplug keeps the feedback vector in the slot for the bytecode offset
- In real life: Anyone can still read the page
- In JavaScript: The debugger, stack traces, and OSR still understand the frame
const frame = { functionName: "stamp", context: "checkout", argument0: 40, bytecodeArray: "stamp bytecode", offsetOrFeedback: "bytecode offset 0", register0: undefined,}; function runInIgnition(total) { frame.argument0 = total; frame.offsetOrFeedback = "bytecode offset 0"; frame.register0 = 2; return frame.argument0 + frame.register0;} function runInSparkplug(total) { frame.argument0 = total; frame.offsetOrFeedback = "feedback vector"; frame.register0 = 2; return frame.argument0 + frame.register0;} console.log(runInIgnition(40));console.log(runInSparkplug(40));console.log(frame.register0);This is a small model of a frame. In plain words, line by line:
- Lines 1 to 8 make one
frameobject. Each property stands for one slot. - Line 6,
offsetOrFeedback, is the slot that the two tiers use in different ways. - Lines 10 to 15,
runInIgnition, fill the slots the way the interpreter does. Line 12 puts a bytecode offset in the special slot. A bytecode offset is the position of the current step in the bytecode. - Lines 17 to 22,
runInSparkplug, fill the same slots. Line 19 puts thefeedback vectorin that slot instead. The feedback vector is the list of notes the engine keeps for this function. - Lines 24 to 26 print
42,42, and2.
Sparkplug can still find the bytecode position when it needs it. It keeps a map from each place in its machine code back to the matching bytecode offset.
feedback vector there.Compare a frame filled by the interpreter with one filled by Sparkplug. This is a small model based on V8's public Sparkplug article. It does not look at a real stack.
script
functionName: "stamp", context: "checkout", argument0: 40, bytecodeArray: "stamp bytecode", offsetOrFeedback: "bytecode offset 0", register0: undefined,}; function runInIgnition(total) { frame.argument0 = total; frame.offsetOrFeedback = "bytecode offset 0"; frame.register0 = 2; return frame.argument0 + frame.register0;} function runInSparkplug(total) { frame.argument0 = total; frame.offsetOrFeedback = "feedback vector"; frame.register0 = 2; return frame.argument0 + frame.register0;} console.log(runInIgnition(40));console.log(runInSparkplug(40));console.log(frame.register0);The table below shows why matching frames help. Read each row as one frame idea and what it means.
| Frame idea | What it means |
|---|---|
| Interpreter frame | It holds the function, the context, the number of arguments, the bytecode, the bytecode offset, and the registers. |
| Sparkplug frame | It keeps the same slots, but puts the feedback vector where the bytecode offset was. A map from its machine code finds the offset when needed. |
| Why it helps | The debugger, stack traces, OSR, and moving between tiers can reuse the interpreter's code with few changes. |
Baseline tiers in other engines
ENGINESOther engines have baseline tiers too. SpiderMonkey is the engine in Firefox. Its documents list these tiers, in order:
- a plain interpreter, written in C++,
- the Baseline Interpreter,
- the Baseline Compiler,
- Warp and Ion, which together make the optimizing compiler.
The Baseline Interpreter is an interpreter that the engine itself made as machine code. Both baseline tiers use inline caches, or ICs: the small saved shortcuts for operations from the last lesson. The Baseline Compiler is a simple template compiler, like Sparkplug. It makes machine code for each script, and it uses the same ICs.
JavaScriptCore is the engine in Safari. Its public overview names four tiers in order: LLInt, Baseline JIT, DFG, and FTL. LLInt is the low-level interpreter. Baseline JIT is the quick compiler. DFG and FTL are the optimizing compilers; the next lesson covers them.
The table below puts the three engines side by side. Read each row as one engine, from its interpreter to its baseline tier.
| Engine | Interpreter tier | Baseline tier | What to remember |
|---|---|---|---|
| V8 | Ignition | Sparkplug | Sparkplug turns bytecode straight into machine code and keeps frames that match the interpreter. |
| SpiderMonkey | A C++ interpreter, then the Baseline Interpreter | Baseline Compiler | Both baseline tiers share the same inline caches. The compiler makes machine code for each script. |
| JavaScriptCore | LLInt | Baseline JIT | Its public overview lists the tiers as LLInt, Baseline JIT, DFG, and FTL. |
The facts here come from V8's Sparkplug article, Mozilla's "How SpiderMonkey Optimizes" and CacheIR documents, and WebKit's article on speculation in JavaScriptCore. Exact numbers and inner details can change from one version to the next. So this lesson teaches what each tier is for, not details that stay the same everywhere.
- Read the next bytecode step and look up how to run it, every time.
- Read the bytecode steps in order and copy a simple template for each, without building an IR.
- Call a ready-made builtin for a complicated step.
- Use the notes about values to copy a small, often-called function into its caller.
- Keep the bytecode offset in the frame, so the current position is known.
- Rebuild the whole function as an SSA graph and improve it in several passes.
Put each card in the tier that does that work: the interpreter, the baseline compiler, or the optimizing compiler.
Quick to compile, or fast to run
TRADE-OFFA baseline compiler picks compile speed over code quality. Its code is not bad. It just skips slow, careful work until a function has shown that it is hot. Here are three kinds of work it usually skips:
- Inlining: copying a called function's body into the caller, so the call itself goes away.
- SSA form: rebuilding the whole function as a graph where each value is set only once, so it is easier to study. SSA stands for "static single assignment".
- Special code for each kind of value, such as code that only handles small whole numbers.
Its code still runs faster than the interpreter when a function runs again and again.
The table below compares the two kinds of compiler. Read each row as one question, answered for each one.
| Question | Baseline compiler | Optimizing compiler |
|---|---|---|
| What is it trying to do? | Remove the interpreter's overhead, quickly. | Make the fastest code it can, using notes and guesses about the values. |
| How long does it take to compile? | Very little time, so it can start soon. | More time, so it must win that time back on code that runs very often. |
| How fast is its code? | Fast enough: simple steps and calls to builtins. | Faster: it copies small functions into their callers, picks the best form for each value, and studies the whole function. |
| Where can I learn more? | In this lesson. | In the lessons on optimizing compilers and on what happens when their guesses are wrong. |
So baseline compilers fit the middle. Some code runs too often for the interpreter to be a good choice, but not often enough to pay for an optimizing compiler. If a function keeps running even more, the next lesson shows what the optimizing compilers do.
Try the cost trade-off
PLAYGROUNDThe playground below changes one input: how many times a function runs. The costs are made-up units, not real V8 numbers. Each tier has two costs:
- a compile cost, paid once: 0 for the interpreter, 12 for baseline, and 80 for the optimizer,
- a cost per call: 5 for the interpreter, 2 for baseline, and 0.7 for the optimizer.
The total is the compile cost plus the cost per call times the number of calls. The playground starts at 20 calls.
function total(compileCost, callCost, calls) { return compileCost + callCost * calls;} for (const calls of [3, 20, 200]) { console.log( calls, total(0, 5, calls), total(12, 2, calls), total(80, 0.7, calls).toFixed(1), );}100.0Nothing to compile, but every call pays the interpreter's overhead.
- compile
- 0.0
- calls
- 100.0
52.0A small compile cost removes the interpreter's overhead, without deep work.
- compile
- 12.0
- calls
- 40.0
94.0A big compile cost only pays back when the function runs very often.
- compile
- 80.0
- calls
- 14.0
Baseline compiler costs the least for 20 calls in this model: 12.0 to compile plus 40.0 for the calls makes 52.0 in total. Real engines also use notes about values, memory limits, and background threads. This model only shows the shape of the trade-off.
The buttons for 3, 20, and 200 calls are just samples. With these exact costs, baseline first costs less than the interpreter at 5 calls. The optimizer first costs less than baseline at 53 calls. Real engines decide with more facts: call counters, inline caches, notes about types, memory limits, and compiling on a background thread.
Checking the facts in Node
TESTEDYou can check some of these facts in Node, because Node uses V8. The lesson tests run Node 22, which comes with V8 12.4. They don't depend on exact version numbers, memory addresses, or timings. They check four things:
- the Sparkplug options exist,
--print-bytecodeprints the bytecode shown earlier,%CompileBaselineexists,- after
%CompileBaseline, V8's status for the function has thekBaselinebit turned on.
Words that start with %, like %CompileBaseline, are V8 native syntax. They are special test commands. They only work when Node starts with the --allow-natives-syntax option, and they are not normal JavaScript.
// Run in Node with --allow-natives-syntax.const bits = { kIsFunction: 1 << 0, kBaseline: 1 << 15 };function stamp(total) { const fee = 2; return total + fee;} %CompileBaseline(stamp);console.log(stamp(40));const status = %GetOptimizationStatus(stamp);console.log(Boolean(status & bits.kIsFunction));console.log(Boolean(status & bits.kBaseline));In plain words, line by line:
- Line 1 says how to run the file.
- Line 2 names two status bits. A status bit is one yes-or-no flag inside a number.
kIsFunctionis bit 0, andkBaselineis bit 15. - Lines 3 to 6 are the same
stampfunction as before. - Line 8 asks V8 to compile
stampwith Sparkplug right now. - Line 9 prints
42. - Line 10 reads V8's status number for
stamp. - Lines 11 and 12 use
&to test one bit each. Both printtrue.
The next commands run the same kind of checks from a terminal.
node --v8-options | grep -E -- "--sparkplug|--always-sparkplug|--trace-baseline-batch-compilation|--print-bytecode"
node --print-bytecode --print-bytecode-filter=stamp stamp-probe.mjs
node --allow-natives-syntax --always-sparkplug baseline-status.mjs
node --allow-natives-syntax --no-sparkplug baseline-status.mjsLine 1 lists V8's options and keeps only the four this lesson needs. Line 2 prints the bytecode for stamp. Line 3 runs a status check with Sparkplug used for every function. Line 4 runs the same check with Sparkplug turned off.
In Node 22 with V8 12.4, --sparkplug is on by default. The options --always-sparkplug, --no-sparkplug, --print-bytecode, and --trace-baseline-batch-compilation all exist. %CompileBaseline(stamp) turns on the kBaseline bit. These are facts about V8 only, for testing. They are not browser JavaScript.
Using this in real code
PRACTICALIn day-to-day work, you rarely need to know whether a function runs in Sparkplug or in another engine's baseline tier. What helps is knowing three things:
- The first few runs of a function can be slower than later runs, because the engine has not compiled it yet. The later runs are called warm runs.
- A function that runs a medium amount can get faster in baseline code, without ever reaching the optimizer.
- Objects that keep the same shape, from the last lesson, still help the later tiers.
The table below turns this into advice. Read each row as one situation, what baseline compilers mean for it, and what to do.
| Situation | What baseline compilers mean here | What to do |
|---|---|---|
| Short helpers on a page | They may only ever run in the interpreter or the baseline tier. | Make the page start fast and keep the code clear, before you chase top speed. |
| Helpers that run a medium amount | Baseline code can remove the interpreter's overhead without waiting for a strong compiler. | Keep object shapes and value types the same, so the engine can see a pattern. |
| Small timing tests | The first runs can include bytecode, a baseline compile, and a later move up a tier. | Run the code a few times first, take several timings, and say exactly what you timed. |
| Real app code | Tier names and limits are inner details of each engine. | Don't use V8 native syntax, and don't write code for one compiler. |
Measure first. If something a user sees is slow, use a profiler, a tool that shows where the time goes, in the browser or Node process that runs it. Keep the code easy to read. Keep the data in hot code consistent. Use V8 native syntax only for experiments, never in real code.
Common misconceptions
“Baseline code is low quality and useless.”
No. Baseline code can save a lot of time, because it quickly removes the interpreter's overhead.
“Sparkplug optimizes like TurboFan.”
No. Sparkplug is simple on purpose. TurboFan, V8's top compiler, and the other optimizing compilers study the code much more deeply.
“Frames that match the interpreter are a JavaScript rule.”
No. They are a choice V8 made inside its engine. The rules of JavaScript come from the language specification, not from how V8 lays out its frames.
“All engines use Sparkplug.”
No. Sparkplug is V8's name. SpiderMonkey and JavaScriptCore have their own baseline tiers, with their own names and code.
“I should force baseline compilation in real code.”
No. Native syntax needs a special Node option and only works in V8. Measure what users really do instead.
The table below sums up ideas that are easy to mix up. Read each row as an idea, what it means, and what it does not mean.
| Idea | What it means | What it does not mean |
|---|---|---|
| Baseline compiler | A quick compiler that makes machine code and removes the interpreter's overhead. | The strongest compiler, which studies and rewrites the whole function. |
| Bytecode | The short list of simple steps that a baseline compiler reads. | Machine code that the processor can already run. |
| Frames that match the interpreter | Frame slots laid out so tiers can share the debugger and OSR code. | A promise that every engine uses V8's exact slots. |
| V8 native syntax | Test commands that only work with a special Node option. | Normal JavaScript that you can use in an app. |
Practice exercises
6 EXERCISESWhat is the first template the toy compiler makes?
const bytecode = [["LdaSmi", 2], ["Return"]];
console.log("load_smi 2");console.log("load_smi 2");The first step is LdaSmi 2, so the first template the toy compiler makes is load_smi 2.
Which bytecode line adds the argument total to fee?
The line is Add a0, [0]. It adds the first argument to the accumulator and keeps its notes in feedback slot 0.
What number does the cost model print for baseline at 20 calls?
function total(compileCost, callCost, calls) {
return compileCost + callCost * calls;
}
console.log(total(12, 2, 20));console.log(12 + 2 * 20);12 to compile plus 2 times 20 for the calls gives 12 + 40 = 52.
In the Node code, which V8 status bit shows that stamp was compiled by Sparkplug?
The status bit is kBaseline. The test checks that %CompileBaseline turns it on in Node 22's V8.
In the Sparkplug frame model, what sits in the slot where the interpreter kept the bytecode offset?
In the Sparkplug frame, the feedback vector sits where the interpreter frame kept the bytecode offset.
A checkout helper feels slow the first time someone clicks. What should you do before you rewrite it for one compiler?
// A checkout helper feels slow the first time someone clicks.
// What should you do before you rewrite it for one compiler?Measure first. Profile the checkout path that the user sees. In timing tests, run the code a few times first with realistic inputs. Then improve the clear code that really shows up as slow.
Check your understanding
Answer these without guessing from the tier names. For a code question, work out what the code prints first. Then link it back to the idea from the lesson.
Question 1 of 8What is the main job of a baseline compiler?
Choose an answer to see the explanation.
Question 2 of 8What does this code print?
Read the code, then predictfunction stamp(total) { const fee = 2; return total + fee; } console.log(stamp(40));Choose an answer to see the explanation.
Question 3 of 8Which choice keeps Sparkplug's compile quick?
Choose an answer to see the explanation.
Question 4 of 8What does this cost code print?
Read the code, then predictfunction total(compileCost, callCost, calls) { return compileCost + callCost * calls; } console.log(total(12, 2, 20));Choose an answer to see the explanation.
Question 5 of 8Why is it useful that Sparkplug's frames match the interpreter's?
Choose an answer to see the explanation.
Question 6 of 8Which sentence is true about SpiderMonkey's baseline tiers?
Choose an answer to see the explanation.
Question 7 of 8In JavaScriptCore's public overview, which tier comes after LLInt?
Choose an answer to see the explanation.
Question 8 of 8Which rule for real code follows from this lesson?
Choose an answer to see the explanation.
Key takeaways
- Sparkplug is V8's quick baseline compiler. It turns Ignition's bytecode straight into machine code, without building an IR first.
- Sparkplug's frames have the same slots as the interpreter's frames. So switching tiers, OSR, and the debugger keep working.
- SpiderMonkey and JavaScriptCore have their own baseline tiers. They do the same job, with different names and inner details.
- A baseline compiler gives up top speed to compile quickly. Measure real code before you change it for any compiler.
A baseline compiler is the quick middle step: it turns bytecode into simple machine code fast, so code skips the interpreter's overhead long before the stronger compilers are worth their time.
Next, Optimizing compilers shows what engines do when a function runs so often that it is worth spending more time on faster machine code. You'll meet Maglev, TurboFan, Warp, DFG, and FTL.