Viscosity
Review
Part V · Inside real JavaScript engines · Day 18

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

These flags are V8's, not yours

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.

A map from V8 flags and natives to what they show and the chapter that explains itFLAG OR NATIVESHOWSCH.--print-bytecodeIgnition bytecode61--trace-opttiering up to TurboFan61--trace-deoptfailed guesses66--trace-generalizationfield representations22, 63%DebugPrint(o)map, fields, elements kind63, 65%HasFastProperties(o)dictionary mode?63--trace-gcscavenges, mark-compacts68
One flag per question you want to ask the engine. Each flag or native shows one part of V8, explained in an earlier chapter. All of them were run for this chapter on Node v22.23.4.

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:

Step throughReading one --trace-deopt line
Node v22.23.4, V8 12.4.254.21 (split to fit)
[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]
kind: deopt-eager. In V8’s words, an eager deopt means a check failed in the optimized code and deoptimization happens immediately. (A lazy one is triggered later, when an assumption checked elsewhere breaks.)
1/6
Definition

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).

Definition

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.

Under the hood

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

ExerciseParse a --trace-deopt line

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;
}
Extra practiceParse a --trace-gc line

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-generalization and --trace-gc report 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-bytecode listing.
  • Natives syntax (--allow-natives-syntax) gives %DebugPrint and %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. 1.

    Which flag shows a function’s Ignition bytecode?

  2. 2.

    add was optimized for integers and then called with 0.5. Which reason does --trace-deopt give?

  3. 3.

    What does kind: deopt-eager mean?

  4. 4.

    You call %HasFastProperties(o) in a normal node file.js run. What happens?

  5. 5.

    In a --trace-gc line, what is the difference between Scavenge and Mark-Compact?

  6. 6.

    Why should a tool not depend on parsing V8’s trace output?

References

  1. [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. [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. [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. [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. [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. [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)