Bytecode & interpreters
Learn why engines run bytecode first, how V8 Ignition reads it with an accumulator interpreter, and how other engines start executing.
- 01Explain why engines make bytecodeCompare walking the AST, running bytecode, and making machine code at once: how fast each starts and how much memory it uses.
- 02Read a V8 listingUse
--print-bytecodeoutput to find offsets, registers, feedback slots, constants, and jumps. - 03Trace an interpreterStep through a small model with an accumulator and registers, and see how handlers run each instruction.
What bytecode is
Before a JavaScript engine runs your code, it reads it. First it splits the text into words. Then it builds a tree that shows how the code fits together. This tree is called the AST, short for abstract syntax tree. Then it works out which variables belong to which function. That step is scope analysis.
A tree is good for describing code. It is slow to run directly. So the engine first turns the tree into a simpler form. That form is bytecode.
Bytecode is a list of short instructions that the engine makes from your code. Each instruction does one small job, like "load the number 1" or "add these two values". An interpreter is the part of the engine that reads the bytecode one instruction at a time and does what each one says. The result is the same as the JavaScript rules say it must be.
A cook does not reread the whole cookbook for every stir. She follows a short recipe card. She keeps the mixture she is working on in one bowl. She moves things in and out of labeled jars. You will meet the accumulator and registers properly in a moment.
- In real life: Recipe card
- In JavaScript: The bytecode: short instructions, in order
- In real life: Cook
- In JavaScript: The interpreter, which follows the instructions
- In real life: One mixing bowl
- In JavaScript: The accumulator: one place for the value being worked on
- In real life: Labeled jars
- In JavaScript: Registers: numbered slots for saved values, like
a0,r0, andr1
This lesson uses V8 as its main example. V8 is the engine inside Chrome and Node.js. Its interpreter is called Ignition. Near the end, you will compare it with the engines in Firefox and Safari.
Here is where this lesson fits among its neighbors:
- Before it: lazy parsing, which decides when each function is read.
- After it: startup snapshots and code caching, which save work between page loads.
- Later: tiered compilation, which makes busy code faster.
Why engines make bytecode
STARTUP + MEMORYMachine code is the CPU's own language. The CPU is the computer's processor. An engine has three ways to run the tree:
- Walk the tree itself. This is simple to build. But the tree is made of many small objects that point at each other. That uses a lot of memory, and moving through it again and again is slow.
- Turn every function into machine code at once. Machine code runs fast. But making it takes time and memory. Much of the code that runs while a page loads runs only once, so that work is often wasted.
- Turn the tree into bytecode and run that. Bytecode is small and quick to make. Later, the engine can turn only the busy code into machine code.
V8 picked the third way. Its 2016 post, Firing up the Ignition interpreter, says Ignition bytecode is about a quarter to a half the size of the simple machine code V8 made before. V8 first turned Ignition on for Android phones with little memory.
The table compares four ways to run code. Each row says what actually runs, and what is good and bad about it.
| Way to run code | What runs | Good and bad |
|---|---|---|
| Walk the AST | The interpreter moves through the tree itself. | Simple to build. But the tree uses a lot of memory, and moving through it again and again is slow. |
| Register bytecode | Short instructions that keep values in numbered slots, called registers. | Small and quick to make. V8's Ignition and Safari's JavaScriptCore work this way. |
| Stack bytecode | Short instructions that put values on a pile, called a stack, and take them off. | Small and quick to make. Firefox's SpiderMonkey works this way. |
| Machine code | The CPU's own instructions, made for one kind of CPU. | Fastest to run. But it is bigger, and slow to make while a page is starting. |
Bytecode is the first step, not the last. A tier is one level of running code, from simple to fast. V8 has four tiers: Ignition, Sparkplug, Maglev, and TurboFan. Firefox and Safari have their own. The bytecode tier starts small. While it runs, it notes what kinds of values each operation sees. These notes are called feedback. The faster tiers start from the bytecode and the feedback.
Ignition's accumulator and registers
a0, r0, accWhile Ignition runs a function, it keeps values in two kinds of places:
- Registers are numbered slots that belong to one call of the function. They are not the CPU's own registers. They are slots in memory that Ignition sets aside. Parameters print as
a0,a1, and so on. Local variables and in-between values print asr0,r1, and so on. - The accumulator is one special slot for the value being worked on right now. Most instructions read from it, write to it, or both.
So many instructions use the accumulator that the listing does not name it. That keeps each instruction short.
Use this table to decode a V8 listing. The left column is what you see. The right column says what it means.
| What you see | What it means |
|---|---|
a0, a1 | The function's parameters. V8 also counts this, so a function with one parameter shows Parameter count 2. |
r0 to rN | Registers: numbered slots for local variables and in-between values. |
| The accumulator | One slot for the value being worked on. Most instructions use it without naming it, which keeps them short. |
| Constant pool | A list of fixed values at the bottom of the listing, such as property names. Instructions point to an entry by its number. |
[n] after an opcode | Often a feedback slot: where V8 notes what kinds of values an add, a property read, a call, or a comparison sees. |
Here are three instructions, read in plain words:
Ldar a0means "copy parametera0into the accumulator".Star0means "copy the accumulator intor0".Add r0, [1]means "addr0to the accumulator". The[1]is not a number being added. It names feedback slot 1: the place where V8 notes what kinds of values this add sees.
The name of each instruction, like Ldar or Add, is called its opcode. The values after it are its operands. The table groups the opcodes in this lesson by the kind of job they do.
| Kind of job | Opcodes |
|---|---|
| Copy values in and out | LdaZero, LdaSmi, Ldar, Star0, Star1, LdaImmutableCurrentContextSlot |
| Do math and compare | Add, AddSmi, MulSmi, TestLessThan. Each has a feedback slot where V8 notes what kinds of values it sees. |
| Read properties and call | GetNamedProperty, SetNamedProperty, CallProperty1, CallUndefinedReceiver2. Older blog posts may show older names, such as LdaNamedProperty. |
| Jump and return | JumpIfFalse, JumpLoop, Return. Jumps go to an offset in the bytecode, not to a source line. |
| Make closures | CreateFunctionContext, StaCurrentContextSlot, CreateClosure, and the context reads. These keep variables that inner functions need. |
The listings in this lesson come from Node 22.23.1, which uses V8 12.4.254.21-node.56. Opcode names are not part of the JavaScript standard. Older blog posts may use older names. Newer V8 versions may print different instructions.
Read --print-bytecode
REAL NODE OUTPUTNode can print the bytecode V8 makes. The flag --print-bytecode turns printing on. The flag --print-bytecode-filter names one function, so only that function is printed.
This lesson's tests run the command below on the demo file. They check that every listing in this lesson still matches what Node prints. Memory addresses change on every run, so the listings show <addr> in their place.
node --print-bytecode --print-bytecode-filter=loopSum bytecode-demo.jsfunction arithmetic(a, b) { const total = a + b; return total * 2;} function propertyCall(user) { user.count = user.count + 1; return user.label(user.count);} function loopSum(n) { let total = 0; let i = 1; while (i < n) { total = total + i; i = i + 1; } return total;} function makeAdder(x) { return function add(y) { return x + y; };} function callFree(fn) { return fn(1, 2);} const user = { count: 1, label(value) { return "#" + value; } };console.log(arithmetic(2, 3));console.log(propertyCall(user));console.log(loopSum(5));console.log(makeAdder(4)(6));console.log(callFree((a, b) => a + b));Line by line: a loop
Here is the bytecode for loopSum, from lines 11 to 19 of the demo file. The first lines are facts about the function, not instructions:
Parameter count 2: the function'sthisvalue plus its one parameter,n. V8 countsthisas a parameter.Register count 2: two registers,r0fortotalandr1fori.
On each instruction line, the number after @ is the offset: how many bytes from the start of the bytecode the instruction sits. Jumps go to an offset, not to a source line.
[generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)]Bytecode length: 31Parameter count 2Register count 2Frame size 16 207 S> <addr> @ 0 : 0c LdaZero <addr> @ 1 : c9 Star0 220 S> <addr> @ 2 : 0d 01 LdaSmi [1] <addr> @ 4 : c8 Star1 234 S> <addr> @ 5 : 0b 03 Ldar a0 234 E> <addr> @ 7 : 71 f8 00 TestLessThan r1, [0] <addr> @ 10 : 9e 12 JumpIfFalse [18] (<addr> @ 28) 245 S> <addr> @ 12 : 0b f8 Ldar r1 259 E> <addr> @ 14 : 3b f9 01 Add r0, [1] <addr> @ 17 : c9 Star0 268 S> <addr> @ 18 : 0b f8 Ldar r1 274 E> <addr> @ 20 : 47 01 02 AddSmi [1], [2] <addr> @ 23 : c8 Star1 225 E> <addr> @ 24 : 8e 13 00 03 JumpLoop [19], [0], [3] (<addr> @ 5) 285 S> <addr> @ 28 : 0b f9 Ldar r0 298 S> <addr> @ 30 : ae ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 27)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Read the loop part in order:
@ 5:Ldar a0copiesninto the accumulator.@ 7:TestLessThan r1checks whetheri < n. The answer,trueorfalse, goes into the accumulator.@ 10:JumpIfFalsejumps out to offset 28 if the answer isfalse.@ 12to@ 17: copyiinto the accumulator, addtotal, and store the sum back inr0.@ 18to@ 23: add 1 toi. InAddSmi [1], [2], the[1]is the number to add, and[2]is a feedback slot. Smi is short for "small integer".@ 24:JumpLoopgoes back to offset 5 to test again.
JumpLoop goes back to offset 5 to test again, and JumpIfFalse leaves the loop by jumping to offset 28.Properties, calls, and closures
Reading and writing a property each have their own opcode in V8 12.4:
GetNamedPropertyreads a property, likeuser.count.SetNamedPropertywrites a property, likeuser.count = 2.CallProperty1calls a method with one argument, likeuser.label(user.count). It names the function, the object before the dot, the argument, and a feedback slot.
[generated bytecode for function: propertyCall (<addr> <SharedFunctionInfo propertyCall>)]Bytecode length: 27Parameter count 2Register count 3Frame size 24 124 S> <addr> @ 0 : 2f 03 00 01 GetNamedProperty a0, [0], [1] 130 E> <addr> @ 4 : 47 01 00 AddSmi [1], [0] 117 E> <addr> @ 7 : 35 03 00 03 SetNamedProperty a0, [0], [3] 149 S> <addr> @ 11 : 2f 03 01 05 GetNamedProperty a0, [1], [5] <addr> @ 15 : c9 Star0 160 E> <addr> @ 16 : 2f 03 00 01 GetNamedProperty a0, [0], [1] <addr> @ 20 : c7 Star2 149 E> <addr> @ 21 : 61 f9 03 f7 07 CallProperty1 r0, a0, r2, [7] 167 S> <addr> @ 26 : ae ReturnConstant pool (size = 2)<addr>: [TrustedFixedArray] - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2 0: <addr> <String[5]: #count> 1: <addr> <String[5]: #label>Handler Table (size = 0)Source Position Table (size = 18)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Line 6 reads user.count. In GetNamedProperty a0, [0], [1], the [0] points to entry 0 of the constant pool. That is the list of fixed values at the bottom of the listing, and entry 0 is the name count. Line 8 writes the new count. Line 13 calls label as a method.
Closures link back to scope analysis. A closure is a function that keeps using variables from the function around it. Here, the inner add uses x from makeAdder. So makeAdder keeps x in a context: a small object for variables that inner functions need.
[generated bytecode for function: makeAdder (<addr> <SharedFunctionInfo makeAdder>)]Bytecode length: 14Parameter count 2Register count 1Frame size 8 320 E> <addr> @ 0 : 88 00 01 CreateFunctionContext [0], [1] <addr> @ 3 : 1a f9 PushContext r0 <addr> @ 5 : 0b 03 Ldar a0 <addr> @ 7 : 25 02 StaCurrentContextSlot [2] 328 S> <addr> @ 9 : 85 01 00 02 CreateClosure [1], [0], #2 375 S> <addr> @ 13 : ae ReturnConstant pool (size = 2)<addr>: [TrustedFixedArray] - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2 0: <addr> <ScopeInfo FUNCTION_SCOPE> 1: <addr> <SharedFunctionInfo add>Handler Table (size = 0)Source Position Table (size = 10)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Line 6 makes the context. Line 9 stores x in slot 2 of that context. Line 10, CreateClosure, makes the inner add function.
[generated bytecode for function: add (<addr> <SharedFunctionInfo add>)]Bytecode length: 9Parameter count 2Register count 1Frame size 8 357 S> <addr> @ 0 : 17 02 LdaImmutableCurrentContextSlot [2] <addr> @ 2 : c9 Star0 <addr> @ 3 : 0b 03 Ldar a0 366 E> <addr> @ 5 : 3b f9 00 Add r0, [0] 370 S> <addr> @ 8 : ae ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 9)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Line 6 reads x back from the context, and line 7 stores it in r0. Line 8 copies y into the accumulator. Line 9 adds them.
x (4) in r0 and y (6) in the accumulator. Line 9 adds r0 to the accumulator, so it now holds 10.A plain call such as fn(1, 2) has no object before the dot. V8 calls the this value of a call its receiver. So for this call it prints CallUndefinedReceiver2: a call with no receiver and two arguments.
[generated bytecode for function: callFree (<addr> <SharedFunctionInfo callFree>)]Bytecode length: 12Parameter count 2Register count 3Frame size 24 26 S> <addr> @ 0 : 0d 01 LdaSmi [1] <addr> @ 2 : c8 Star1 <addr> @ 3 : 0d 02 LdaSmi [2] <addr> @ 5 : c7 Star2 33 E> <addr> @ 6 : 66 03 f8 f7 00 CallUndefinedReceiver2 a0, r1, r2, [0] 42 S> <addr> @ 11 : ae ReturnConstant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 8)<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>The opcode only says how the call is written. Inside the called function, the usual rules for this still apply. The code below shows both rules.
function sloppyReceiver() { return this === globalThis;} function strictReceiver() { "use strict"; return this === undefined;} console.log(sloppyReceiver());console.log(strictReceiver());Both lines print true. Outside strict mode, a plain call gets the global object, globalThis, as this. In strict mode, this stays undefined.
Build a small teaching interpreter
MODEL, NOT V8The experiments below use a small model, not V8. Here is what the model does:
- It reads a tiny function with
acorn, a library that builds the AST. - It turns the AST into toy bytecode that uses an accumulator and registers, like Ignition.
- It runs the toy bytecode with a table of small functions, one for each opcode.
The model knows only a few things. It has no objects, no errors, and none of V8's speed tricks. Its opcodes have names like V8's, but they are not real V8 bytecode.
Each toy instruction has a number, starting at 0. A counter called pc, short for program counter, holds the number of the next instruction to run. Most instructions add 1 to pc. Jumps set pc to another number.
This is a small model that works like Ignition. It is not Ignition itself. Step through the loop and watch pc, the accumulator, and the registers change.
script
const program = [ ["Star","r0"], ["LdaSmi",1], ["Star","r1"], ["Ldar","r1"], ["Star","r2"], ["Ldar","a0"], ["Star","r3"], ["LdaSmi",1], ["Add","r3"], ["TestLessThan","r2"], ["JumpIfFalse",23], ["Ldar","r0"], ["Star","r4"], ["Ldar","r1"], ["Add","r4"], ["Star","r0"], ["Ldar","r1"], ["Star","r5"], ["LdaSmi",1], ["Add","r5"], ["Star","r1"], ["Jump",4], ["Ldar","r0"], ["Return"],]; function run(bytecode, a0) { const registers = { a0: a0, r0: undefined, r1: undefined }; let pc = 0; let acc; const dispatch = { LdaSmi(value) { acc = value; pc += 1; }, Ldar(register) { acc = registers[register]; pc += 1; }, Star(register) { registers[register] = acc; pc += 1; }, Add(register) { acc = registers[register] + acc; pc += 1; }, Mul(register) { acc = registers[register] * acc; pc += 1; }, TestLessThan(register) { acc = registers[register] < acc; pc += 1; }, JumpIfFalse(target) { pc = acc ? pc + 1 : target; }, Jump(target) { pc = target; }, Return() { pc = bytecode.length; }, }; while (pc < bytecode.length) { const [op, operand] = bytecode[pc]; dispatch[op](operand); } return acc;} console.log(run(program, 4));Watch the loop. JumpIfFalse leaves the loop when the accumulator holds false. Jump sets pc back to 4, where the loop test starts. Back and Reset replay steps saved from a real run of the model.
A shorter run. Watch each value pass through the accumulator before the function returns.
script
const program = [ ["Star","r1"], ["LdaSmi",2], ["Mul","r1"], ["Star","r0"], ["Ldar","r0"], ["Star","r2"], ["LdaSmi",1], ["Add","r2"], ["Return"],]; function run(bytecode, a0) { const registers = { a0: a0, r0: undefined }; let pc = 0; let acc; const dispatch = { LdaSmi(value) { acc = value; pc += 1; }, Ldar(register) { acc = registers[register]; pc += 1; }, Star(register) { registers[register] = acc; pc += 1; }, Add(register) { acc = registers[register] + acc; pc += 1; }, Mul(register) { acc = registers[register] * acc; pc += 1; }, TestLessThan(register) { acc = registers[register] < acc; pc += 1; }, JumpIfFalse(target) { pc = acc ? pc + 1 : target; }, Jump(target) { pc = target; }, Return() { pc = bytecode.length; }, }; while (pc < bytecode.length) { const [op, operand] = bytecode[pc]; dispatch[op](operand); } return acc;} console.log(run(program, 5));This shorter example makes the accumulator easy to follow. Load a value. Store it in a register. Load the next value. Combine the two. Return the result. Real V8 has more kinds of opcodes, but the idea is the same: one value being worked on, plus numbered slots for saved values.
Now try your own. Pick an example, change the argument, or edit the function. Then compare the toy bytecode with a real V8 listing for a similar function.
0000 LdaSmi 0
0001 Star r0
0002 LdaSmi 1
0003 Star r1
0004 Ldar r1
0005 Star r2
0006 Ldar a0
0007 Star r3
0008 LdaSmi 1
0009 Add r3
0010 TestLessThan r2
0011 JumpIfFalse 23
0012 Ldar r0
0013 Star r4
0014 Ldar r1
0015 Add r4
0016 Star r0
0017 Ldar r1
0018 Star r5
0019 LdaSmi 1
0020 Add r5
0021 Star r1
0022 Jump 4
0023 Ldar r0
0024 Return10loopSum(n)A real V8 listing for a similar loop. It is not a compilation of the toy source you edit.
[generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)]
Bytecode length: 31
Parameter count 2
Register count 2
Frame size 16
207 S> <addr> @ 0 : 0c LdaZero
<addr> @ 1 : c9 Star0
220 S> <addr> @ 2 : 0d 01 LdaSmi [1]
<addr> @ 4 : c8 Star1
234 S> <addr> @ 5 : 0b 03 Ldar a0
234 E> <addr> @ 7 : 71 f8 00 TestLessThan r1, [0]
<addr> @ 10 : 9e 12 JumpIfFalse [18] (<addr> @ 28)
245 S> <addr> @ 12 : 0b f8 Ldar r1
259 E> <addr> @ 14 : 3b f9 01 Add r0, [1]
<addr> @ 17 : c9 Star0
268 S> <addr> @ 18 : 0b f8 Ldar r1
274 E> <addr> @ 20 : 47 01 02 AddSmi [1], [2]
<addr> @ 23 : c8 Star1
225 E> <addr> @ 24 : 8e 13 00 03 JumpLoop [19], [0], [3] (<addr> @ 5)
285 S> <addr> @ 28 : 0b f9 Ldar r0
298 S> <addr> @ 30 : ae Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 27)
<addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>The model turned the function into toy bytecode and ran it in 90 steps. It returned 10. The registers ended as a0=4, r0=10, r1=5, r2=5, r3=4, r4=6, r5=4.
Handlers and dispatch
OPCODE TO HANDLERThe interpreter does not understand opcode names by itself. Each opcode has its own small piece of code that does that opcode's job. That piece of code is called a handler. Running bytecode is a short cycle:
- Read the opcode at the current offset.
- Go to that opcode's handler. This step is called dispatch.
- The handler does its job. It changes the accumulator, a register, or the program counter.
- Repeat with the next opcode.
const handlers = { LdaSmi(state, value) { state.acc = value; state.pc += 1; }, Add(state, register) { state.acc = state.registers[register] + state.acc; state.pc += 1; }, JumpIfFalse(state, target) { state.pc = state.acc ? state.pc + 1 : target; },}; function dispatch(bytecode, state) { const [op, operand] = bytecode[state.pc]; return handlers[op](state, operand);}Lines 1 to 13 build handlers, an object with one function for each opcode. Line 6 starts the Add handler. Line 15 starts dispatch. Line 16 reads the instruction at pc: its opcode name and its operand. Line 17 picks the handler by that name and runs it.
pc. Line 17 uses its name, Add, to pick the matching function from handlers and run it.Ignition does the same thing in machine code. V8's docs call it threaded dispatch:
- Each handler is its own small block of machine code.
- At its end, a handler reads the next opcode. It looks up that opcode's handler in a table and jumps straight there.
V8 did not write these handlers by hand for every kind of CPU. Its first Ignition post says they were made with the back end of TurboFan, the compiler V8 uses for its fastest machine code. Later V8 tools, such as the CodeStubAssembler, use the same back end to build other small pieces of machine code.
The bytecode itself is kept small too. Copying the accumulator into a low-numbered register has its own one-byte opcode: Star0, Star1, Star2, and so on. You can see them in the Node 22 listings above. Like the unnamed accumulator, these short opcodes make the bytecode smaller.
Saving memory: flushing and lazy feedback
V8 LITEBytecode is small, but it still uses memory. V8's V8 Lite project measured real web pages. Bytecode and feedback were two of the big users of memory. Several V8 Lite ideas are now part of normal V8.
Two words first:
- A feedback vector is one function's list of feedback slots: its notes about what kinds of values each operation saw.
- Garbage collection (GC) is when the engine frees memory that nothing uses any more.
Each row of the table is one way V8 saves memory around bytecode.
| What V8 does | How it works |
|---|---|
| Flush old bytecode | Each time garbage collection runs, a function's bytecode gets older. Running the function makes it young again. Old bytecode can be thrown away. --flush-bytecode is on by default. |
| Make it again when needed | If a function whose bytecode was thrown away runs again, V8 makes its bytecode again before running it. |
| Make feedback late | V8 waits to make a function's feedback vector until the function has run enough. Node 22 has --lazy-feedback-allocation and a flag for one of the limits. |
| Find source positions late | V8 can wait to work out exact source positions until a stack trace or the debugger needs them. |
You can see these settings in Node today:
--flush-bytecodeis on by default.--lazy-feedback-allocationis on by default.--invocation-count-for-feedback-allocationsets one of the limits that decide when a function gets its feedback vector.
These are details inside the engine. Your app code should not depend on them.
SpiderMonkey and JavaScriptCore
SAME IDEA, DIFFERENT VMThe JavaScript standard does not say what bytecode looks like. Each engine makes its own. Firefox's engine, SpiderMonkey, and Safari's engine, JavaScriptCore (JSC), also run bytecode first. Their bytecode looks different, and they decide in their own ways when to move code to a faster tier.
Each row is one engine: the name of its first tier, and what its bytecode looks like.
| Engine | First tier | What its bytecode looks like |
|---|---|---|
| V8 (Chrome, Node.js) | Ignition | Registers plus one accumulator that most instructions use without naming it. Faster tiers start from this bytecode and its feedback. |
| SpiderMonkey (Firefox) | Baseline Interpreter | A stack: each opcode takes values off the pile and puts results back. The interpreter runs one opcode at a time and adds inline caches. |
| JavaScriptCore (Safari) | LLInt | Each instruction names its slots, such as loc7 and arg1. The LLInt is written in offlineasm and runs before any faster tier. |
A little more about each one:
- SpiderMonkey's first tier is the Baseline Interpreter. Firefox's docs call it a mix of an interpreter and a JIT. A JIT, or just-in-time compiler, makes machine code while the program runs. The Baseline Interpreter runs bytecode one opcode at a time. It also attaches inline caches: small saved shortcuts that make repeated operations faster.
- SpiderMonkey's bytecode uses a stack, a pile of values. Each opcode's description says how many values it takes off the pile and how many it puts back.
- JSC's bytecode names its slots inside each instruction, such as
loc7for a local variable andarg1for an argument. Its first tier is the LLInt, short for Low Level Interpreter. WebKit wrote it in offlineasm, a small assembly-like language of its own.
When this helps you
REAL WORKYou don't need bytecode for most speed problems. Start with these tools first:
- the browser's Performance panel
- speed numbers from real users
- a look at how big your JavaScript files are
- the call stack
Bytecode helps in a few cases. Each row of the table is a question you might have, and when bytecode can answer it.
| Your question | When bytecode can help |
|---|---|
| Startup | You want to see what runs first, before any faster tier has had time to start. |
| Memory | You want to learn why engines throw away old bytecode or wait to make feedback vectors. |
| Calls and properties | You want to see the exact steps: a property read, a method call, a plain call, or a closure reading its context. |
| Learning | You want a clear step from source code to what the interpreter does, before you study faster tiers. |
`Ldar a0``Star0``Add r0, [1]``TestLessThan r1, [0]``JumpLoop [19], [0], [3]``Return``CallProperty1 r0, a0, r2, [7]``CreateClosure [1], [0], #2`
Sort each V8 opcode by its main job. Then read why each one goes there.
Common misconceptions
- “Bytecode is JavaScript with shorter names.” It is a separate list of instructions. Each engine makes its own, after it has parsed your code and worked out its scopes.
- “Bytecode is machine code.” The CPU cannot run Ignition bytecode. Handlers carry out each instruction, or a compiler turns the bytecode into machine code.
- “One V8 listing teaches every browser.” SpiderMonkey and JavaScriptCore have their own bytecode, with different opcode names.
- “A number in brackets is always a value.” In
Add r0, [1], the[1]names a feedback slot. It is not the number 1. - “Flushing bytecode changes what my program does.” It only saves memory. If the function runs again, V8 makes its bytecode again.
Each row names two ideas that are easy to mix up, then says how they differ.
| Easy to mix up | How they differ |
|---|---|
| Bytecode and the AST | The AST shows how the code is written. Bytecode is the list of steps to run it. |
| Bytecode and machine code | The engine runs or compiles bytecode. The CPU runs machine code, which is different for each kind of CPU. |
| Ignition and all engines | Ignition is V8's interpreter. Firefox and Safari use other interpreters and other bytecode. |
| The toy model and V8 | The model shows how an accumulator and registers work. It is not how V8 is built. |
Practice exercises
5 EXERCISESPredict what this code prints for the accumulator.
const registers = { r0: 3 };
let acc = 4;
acc = registers.r0 + acc;
console.log(acc);The register r0 holds 3 and the accumulator holds 4. Line 3 adds them, so it prints 7.
What does the program counter, pc, print after the jump?
let pc = 5;
let acc = false;
pc = acc ? pc + 1 : 12;
console.log(pc);acc is false, so line 3 picks 12. The program counter prints 12.
Which exact call opcode does V8 print for return fn(1, 2)?
Node 22.23.1 (V8 12.4) prints CallUndefinedReceiver2 for this plain call with two arguments.
Which opcode reads user.count in the stored V8 listing?
The V8 12.4 listing uses GetNamedProperty to read a property by its name.
The table of handlers has LdaSmi, Add, and jumps. You want to multiply too. What name would you give the new handler?
const handlers = {
Mul(state, register) {
state.acc = state.registers[register] * state.acc;
state.pc += 1;
},
};A Mul handler reads a register and the accumulator. It puts their product in the accumulator and adds 1 to pc.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why did V8 add Ignition bytecode instead of making machine code for every function right away?
Choose an answer to see the explanation.
Question 2 of 7In an Ignition listing, what does
Add r0, [1]mean?Choose an answer to see the explanation.
Question 3 of 7What does this tiny accumulator model print?
Read the code, then predictconst registers = { r0: 3 }; let acc = 4; acc = registers.r0 + acc; console.log(acc);Choose an answer to see the explanation.
Question 4 of 7Which line in the loop listing shows that jumps go to bytecode offsets?
Read the code, then predictJavaScript[generated bytecode for function: loopSum (<addr> <SharedFunctionInfo loopSum>)] Bytecode length: 31 Parameter count 2 Register count 2 Frame size 16 207 S> <addr> @ 0 : 0c LdaZero <addr> @ 1 : c9 Star0 220 S> <addr> @ 2 : 0d 01 LdaSmi [1] <addr> @ 4 : c8 Star1 234 S> <addr> @ 5 : 0b 03 Ldar a0 234 E> <addr> @ 7 : 71 f8 00 TestLessThan r1, [0] <addr> @ 10 : 9e 12 JumpIfFalse [18] (<addr> @ 28) 245 S> <addr> @ 12 : 0b f8 Ldar r1 259 E> <addr> @ 14 : 3b f9 01 Add r0, [1] <addr> @ 17 : c9 Star0 268 S> <addr> @ 18 : 0b f8 Ldar r1 274 E> <addr> @ 20 : 47 01 02 AddSmi [1], [2] <addr> @ 23 : c8 Star1 225 E> <addr> @ 24 : 8e 13 00 03 JumpLoop [19], [0], [3] (<addr> @ 5) 285 S> <addr> @ 28 : 0b f9 Ldar r0 298 S> <addr> @ 30 : ae Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 27) <addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Choose an answer to see the explanation.
Question 5 of 7Which opcodes does Node 22.23.1 (V8 12.4) print for this property read, write, and method call?
Read the code, then predictJavaScript[generated bytecode for function: propertyCall (<addr> <SharedFunctionInfo propertyCall>)] Bytecode length: 27 Parameter count 2 Register count 3 Frame size 24 124 S> <addr> @ 0 : 2f 03 00 01 GetNamedProperty a0, [0], [1] 130 E> <addr> @ 4 : 47 01 00 AddSmi [1], [0] 117 E> <addr> @ 7 : 35 03 00 03 SetNamedProperty a0, [0], [3] 149 S> <addr> @ 11 : 2f 03 01 05 GetNamedProperty a0, [1], [5] <addr> @ 15 : c9 Star0 160 E> <addr> @ 16 : 2f 03 00 01 GetNamedProperty a0, [0], [1] <addr> @ 20 : c7 Star2 149 E> <addr> @ 21 : 61 f9 03 f7 07 CallProperty1 r0, a0, r2, [7] 167 S> <addr> @ 26 : ae Return Constant pool (size = 2) <addr>: [TrustedFixedArray] - map: <addr> <Map(TRUSTED_FIXED_ARRAY_TYPE)> - length: 2 0: <addr> <String[5]: #count> 1: <addr> <String[5]: #label> Handler Table (size = 0) Source Position Table (size = 18) <addr> <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>Choose an answer to see the explanation.
Question 6 of 7V8 threw away a function's old bytecode. What happens when that function runs again?
Choose an answer to see the explanation.
Question 7 of 7Which statement about different engines is true?
Choose an answer to see the explanation.
Key takeaways
- Bytecode is a short list of instructions. It sits between the AST and the CPU's machine code.
- Ignition keeps values in registers and in one accumulator. Most instructions use the accumulator without naming it.
--print-bytecodeshows offsets, registers, constants, and feedback slots for one V8 version.- An interpreter is a cycle: read an opcode, go to its handler, let the handler do its job, repeat.
- V8 can throw away old bytecode and make feedback vectors late to save memory. Your program behaves the same.
- SpiderMonkey and JavaScriptCore also run bytecode first, but their bytecode and first tiers are their own.
Remember the one-liner.
Bytecode lets an engine start running your code quickly, take notes as it goes, and save the slow work of making machine code for the code that runs most.
Next: Startup: snapshots & code caching. Later, Tiered compilation shows how busy bytecode moves on to faster tiers.