72. Watching the engine work
Eight chapters told you what V8 does. Now make it tell you itself: print its bytecode, watch it optimize and give up, inspect an object’s hidden class, and count its garbage collections, with real output from the Node this book used.
- Print a function’s Ignition bytecode and find its feedback slots.
- Read --trace-opt and --trace-deopt output, and connect a deopt to the bytecode that caused it.
- Inspect shapes, elements kinds and dictionary mode with natives syntax.
- Read a --trace-gc line and tell a scavenge from a mark-compact.
Make the engine talk
Chapters 61 to 68 described V8’s tiers, shapes, elements kinds, deoptimization and garbage collector. You don’t have to take any of it on trust. V8 has flags that print what it is doing, and Node passes them through. Every output in this chapter is real, from this book’s machine, trimmed only to fit your phone.
They are defined inside V8 for its own developers, in one file per V8
version[1]. They are not part of
JavaScript or of Node’s stable interface, and names, output formats and
defaults can change in any release. Everything below was run on
Node v22.23.4, V8 12.4.254.21. Addresses (0x…) differ on every
run. Run node --v8-options to see what your own version offers.
Bytecode
Ignition is a register machine with an accumulator[6]. Ask for one function’s bytecode, using the filter so you don’t get Node’s own thousands of functions:
$ node --print-bytecode \
--print-bytecode-filter=add bc.js
For function add(a, b) { return a + b; }:
# Node v22.23.4, V8 12.4.254.21 (trimmed)
[generated bytecode for function:
add (0x… <SharedFunctionInfo add>)]
Bytecode length: 6
Parameter count 3
Register count 0
@ 0 : 0b 04 Ldar a1
@ 2 : 3b 03 00 Add a0, [0]
@ 5 : ae Return
Ldar a1 loads the second argument into the accumulator. Add a0, [0]
adds the first argument to it, and [0] is a feedback slot: where
this + records the types it sees (chapter 66)[3].
@ 2 is its offset. Remember it.
Optimizing, and giving up
This program makes add hot with integers, then hands it a double:
function add(a, b) {
return a + b;
}
for (let i = 0; i < 1e5; i++) add(i, 1);
add(0.5, 1); // a double!
$ node --trace-opt --trace-deopt opt.js
# Node v22.23.4, V8 12.4.254.21 (trimmed)
[marking … <JSFunction add …> for
optimization to TURBOFAN, …,
reason: hot and stable]
[compiling method … add …
(target TURBOFAN), mode:
ConcurrencyMode::kConcurrent]
[compiling method … <JSFunction …>
(target TURBOFAN) OSR, …]
[completed optimizing … add …
(target TURBOFAN)]
[bailout (kind: deopt-eager, reason:
Insufficient type feedback for call):
… <JSFunction (sfi = …)>, …
bytecode offset 41, …]
[bailout (kind: deopt-eager, reason:
not a Smi): … <JSFunction add …>,
… bytecode offset 2, …]
add is “hot and stable”, so TurboFan compiles it, concurrently, on
another thread. The nameless function is the top-level script. Its
loop never returns, so V8 uses on-stack replacement
(OSR)[1]: it switches the loop to
optimized code while the loop is still running. You won’t see Maglev:
in this Node build, --v8-options lists it as off by default.
Then two bailouts. The script’s optimized code reaches the call
add(0.5, 1), which had never run, so it had no feedback. And add
itself fails its integer check at bytecode offset 2: the Add above.
Here is that last line in full:
[bailout (kind: deopt-eager,reason: not a Smi): begin.deoptimizing 0x207bb9a23eb1<JSFunction add(sfi = 0x1a9fac5db7e1)>,0x083907e81fa9 <Code TURBOFAN>,opt id 0,bytecode offset 2,deopt exit 0, FP to SP delta 32,caller SP 0x7ffffc8cc8d0,pc 0x7f12c0005d75]
Engine tracing means running a program with an engine’s diagnostic
flags so that the engine reports its own decisions as it makes them.
In V8 these include --print-bytecode (Ignition bytecode),
--trace-opt (functions marked and compiled by the optimizing tiers),
--trace-deopt (every deoptimization, with its kind, reason and
bytecode offset), --trace-generalization (hidden-class fields
becoming more general) and --trace-gc (one line per garbage
collection)[1]. Their output is for
diagnosis, not for parsing in production: it varies between versions.
Looking inside objects
With --allow-natives-syntax, V8 lets your source call some of its
internal runtime functions with a % prefix. Without the flag, % is a
syntax error. %DebugPrint and %HasFastProperties are among the
functions V8 lists for testing[2]:
const p = { x: 1, y: 2 };
console.log(%HasFastProperties(p));
delete p.x;
console.log(%HasFastProperties(p));
%DebugPrint({ x: 1.5, y: 2 });
# Node v22.23.4, V8 12.4.254.21 (trimmed)
true
false
DebugPrint: 0x…: [JS_OBJECT_TYPE]
- map: 0x… <Map[40](HOLEY_ELEMENTS)>
[FastProperties]
- All own properties …: {
#x: 0x… <HeapNumber 1.5>
(const data field 0), in-object
#y: 2 (const data field 1),
in-object
}
0x…: [Map] in OldSpace
- instance size: 40
- inobject properties: 2
Deleting x drops p into dictionary mode (chapter 63). The map is
the object’s shape. y holds a Smi directly, while 1.5 lives in a
HeapNumber (chapter 22). For an array, %DebugPrint([1, 2, 3]) shows
PACKED_SMI_ELEMENTS, and after push(1.5) it shows
PACKED_DOUBLE_ELEMENTS (chapter 65).
Natives syntax is V8’s %Name(args) syntax for calling its internal
runtime functions directly from JavaScript, enabled only by
--allow-natives-syntax. Functions such as %DebugPrint (print an
object’s map, fields and elements) and %HasFastProperties are meant
for V8’s own tests: they are not JavaScript, and they can change or
disappear between versions[2].
A field that changes kind
FINDINGS.md section 4 used --trace-generalization to explain why a
TypeScript-style class was slow. You can reproduce it: a class with a
field declaration, filled with doubles. Node prints many lines for its
own code, so keep only yours:
class P {
x;
constructor(x) { this.x = x; }
}
const ps = [];
for (let i = 0; i < 10; i++) {
ps.push(new P(i + 0.5));
}
$ node --trace-generalization gen.js \
| grep gen.js
[generalizing]x:h{Any;const}->
t{Any;mutable} (uninitialized field)
[new ~P+16 at …/gen.js:3]
The field starts as a heap-object field (it holds undefined before
the constructor runs), and storing a number generalizes it to a generic
tagged field, so every double is boxed. Without the x; line, no
such line appears for x.
Garbage collection
Keep three million small objects alive and watch the collector:
# Node v22.23.4, V8 12.4.254.21 (trimmed)
[…] 24 ms: Scavenge 4.4 (5.1) ->
3.9 (6.1) MB, … 1.03 / 0.00 ms …
allocation failure;
[…] 214 ms: Mark-Compact 53.7 (86.2)
-> 41.0 (74.2) MB, … 44.16 / 0.00
ms … GC in old space requested
[…] 577 ms: Scavenge 166.9 (185.5)
-> 166.9 (201.5) MB, … 32.16 / 0.00
ms … allocation failure;
3000000
[…] 617 ms: Mark-Compact 205.6
(235.1) -> 3.6 (36.9) MB, …
Each line gives the time since start, the collector, the size of the
objects before and after (with the heap’s memory size in brackets), the
time it took, and the reason[5]. Scavenges are
young-generation collections, mark-compacts old-generation ones
(chapter 68). At 577 ms a scavenge freed nothing: every object was
still in keep. Once the script had finished, the last mark-compact
found almost everything dead.
These flags are how this book’s findings were made, and how you should
check them on your own code. Viscosity’s output is ordinary
JavaScript, so you can run its Float64Array columns under
--trace-deopt and --trace-opt exactly like any other program, and
compare with the original. Just remember what you are reading: one
engine’s private diary, written for its own developers, in a format
that will change.
Your turn
Write parseDeopt(line). If line is a V8 bailout line (it contains (kind: and reason: ), return { kind, reason, fn, tier, offset }: the kind (e.g. 'deopt-eager'), the reason text up to its closing ), the function name after <JSFunction ('' for the nameless top-level script), the tier in <Code …> (e.g. 'TURBOFAN'), and the bytecode offset as a number. For any other line return null. Regular expressions with line.match(/…(…)…/) are the easiest tool: the part in brackets is captured as m[1].
function parseDeopt(line) {
return null;
}Write parseGc(line) for one line of V8 12.4’s --trace-gc output. Return { ms, type, before, after, pause }: the time since start (the number before ms: ), the collector ('Scavenge' or 'Mark-Compact'), the object size before and after in MB (the numbers outside the brackets, around ->), and the pause in ms (the first number after MB, pooled: … MB, ). All numbers must be numbers. Return null for any other line.
function parseGc(line) {
return null;
}What you learned
- Engine tracing: V8’s
--print-bytecode(with a filter),--trace-opt,--trace-deopt,--trace-generalizationand--trace-gcreport its decisions as it makes them. - A deopt line gives the kind (eager or lazy), the reason, the
function, the tier, and the bytecode offset, which you can match
to the
--print-bytecodelisting. - Natives syntax (
--allow-natives-syntax) gives%DebugPrintand%HasFastProperties: maps, fields, elements kinds and dictionary mode, live. - All of it is V8-internal and version-specific: label your output with the version, and expect it to change.
Check your understanding
0/6- 1.
Which flag shows a function’s Ignition bytecode?
- 2.
addwas optimized for integers and then called with0.5. Which reason does--trace-deoptgive? - 3.
What does
kind: deopt-eagermean? - 4.
You call
%HasFastProperties(o)in a normalnode file.jsrun. What happens? - 5.
In a
--trace-gcline, what is the difference between Scavenge and Mark-Compact? - 6.
Why should a tool not depend on parsing V8’s trace output?
References
- [1] The V8 project authors (2024). src/flags/flag-definitions.h (V8 12.4.254.21). V8 source repository (GitHub mirror github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/flags/flag-definitions.h (checked 2026-10-31)
- [2] The V8 project authors (2024). src/runtime/runtime.h (V8 12.4.254.21): the runtime functions behind %-syntax. V8 source repository (GitHub mirror github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/runtime/runtime.h (checked 2026-10-31)
- [3] The V8 project authors (2024). src/interpreter/bytecodes.h and src/interpreter/interpreter-generator.cc (V8 12.4.254.21). V8 source repository (GitHub mirror github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/interpreter/interpreter-generator.cc (checked 2026-10-31)
- [4] The V8 project authors (2024). src/common/globals.h (DeoptimizeKind) and src/deoptimizer/deoptimize-reason.h (V8 12.4.254.21). V8 source repository (GitHub mirror github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/deoptimizer/deoptimize-reason.h (checked 2026-10-31)
- [5] The V8 project authors (2024). src/heap/gc-tracer.cc (V8 12.4.254.21): the --trace-gc line. V8 source repository (GitHub mirror github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/heap/gc-tracer.cc (checked 2026-10-31)
- [6] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)