Viscosity
Review
Part VII · Building Viscosity · Day 28

110. viscosity explain

An optimizer that won’t say what it did leaves you guessing. Viscosity writes down every decision and the evidence behind it, and because the planner writes the reasons while it decides, the explanation can’t drift from what actually happened.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Read every line of a `viscosity explain` report and say which analysis produced it.
  • Explain why the report is built from the same data as the decision, not computed afterwards.
  • Use the reason for a kept collection to change a program so Viscosity can transform it.
  • Say how the report is itself tested, byte for byte.

The silent optimizer

You run Viscosity on your program. It gets no faster. Was there nothing to do? Did it try and give up? Did it transform the wrong array? An optimizer that only hands back code leaves you reading generated code to find out.

So Viscosity has a second output. Here is a corpus program, and what viscosity explain says about it:

const ps = [];
for (let i = 0; i < 100; i++)
  ps.push({ x: i, y: i });
for (let i = 0; i < ps.length; i++)
  ps[i].x += 1;
console.log(ps[3]);
ps (line 2) {x, y} → kept as array of
  objects
  · unsafe: line 5: an element is passed
    to a method call

(Line numbers refer to the real file, which has a header comment on line 1 and each loop on one line. The reports in this chapter are real CLI output, with long lines wrapped.) One line, and you know what to change: console.log receives a real element object, so the object has to exist.

Intuition

A mechanic who says “fixed it” is less useful than one who hands you an itemized list: what they replaced, what they checked and left alone, and why. You can check the list, and next time you know what to ask for.

Anatomy of a report

A transformed collection has a longer report. This one is for mixed_columns.js, which has strings, numbers, null and objects in its fields:

es (line 2) {id, hp, x, tag} → struct of
  arrays
  · loop at line 5 in <module>() touches
    3 of 4 fields (hp, tag, x)
  · loop at line 12 in <module>() touches
    2 of 4 fields (hp, x)
  · typed Float64Array columns for hp, x;
    plain array columns for id, tag (may
    hold non-numbers)
  · about 300 elements
  · 4 of 9 element accesses proven in
    bounds

Each line comes from a different part of the compiler you built in this part of the book. Step through it:

Step throughReading an explain report
The explain report for mixed_columns.js, one line highlighted at a time, with the analysis that produced that linees (line 2) {id, hp, x, tag} → struct of arrays· loop at line 5 in <module>() touches 3 of 4 fields (hp, tag, x)· loop at line 12 in <module>() touches 2 of 4 fields (hp, x)· typed Float64Array columns for hp, x; plain array columns for id, tag (may hold non-numbers)· about 300 elements· 4 of 9 element accesses proven in boundsWRITTEN BY (CHAPTER)step through to see
The whole report for one collection. Each collection gets one verdict line, then one “·” line per reason. Step through to see which part of the compiler wrote each line.
1/7

The order is always the same: the verdict, the hot loops, the columns, the size, the bounds proofs. A report is meant to be read in a few seconds, so it says which loops (with line numbers), which fields, and how many checks the safety guarantees cost.

The planner writes the reasons

Here is the whole of explain() from core/src/plan.ts, simplified:

function explain(p) {
  const lines = [];
  for (const cp of p.collections) {
    const c = cp.collection;
    lines.push(`${c.name} ` +
      `(line ${c.line}) ` +
      `{${c.fields.join(', ')}} → ` +
      VERDICT[cp.layout]);
    for (const r of cp.reasons) {
      lines.push(`  · ${r}`);
    }
  }
  return lines.join('\n');
}

It analyzes nothing. It prints cp.reasons, a list of strings the planner filled in at the moment it decided. Every place the planner keeps a collection passes the reason to keep(...), so a kept collection always says why:

if (c.numericFields.size === 0) {
  out.push(keep('no field is provably ' +
    'numeric, so there are no typed ' +
    'columns to gain'));
  continue;
}

A report computed afterwards, by a separate function guessing what the planner probably did, could go out of date the first time someone changed the planner. One computed from the decision itself can’t.

Definition

An optimization report is a compiler output, alongside the code, that explains every decision it made: what it changed, what it left alone, and the evidence for each. It is most trustworthy when the decision procedure records its own reasons as it runs.

Why it was kept

The planner tries its checks in a fixed order, and a kept collection’s report names the check that stopped it. Safety comes first, and lists every unsafe use found, so you can fix them all at once. After that the first failing check is the reason:

  1. unsafe: an escape, an unknown index, a shape mismatch (chapters 99–100)
  2. annotation: @viscosity aos, or an aosoa request with non-numeric fields (chapter 102)
  3. opt-in mode: not annotated
  4. Python only: CPython columns measured slower, so opt in (chapter 111)
  5. no numeric field: no typed columns to gain
  6. no loop over the fields: nothing to pay for the change
  7. too small: below the 64-element threshold, estimated or measured

Four real reasons, one from each of four corpus files:

· unsafe: line 6: indexes the array with
  something that may not be a number
· no field is provably numeric, so there
  are no typed columns to gain
· no loop reads or writes its fields, so
  a new layout cannot pay off
· about 2 elements: below the 64-element
  threshold, where the benefit is too
  small

Each one points at what you could change: the index, the field’s values, the missing loop, the size. Try it on the real compiler. This program is kept because of the last line. Change it to print ps[3].x and watch the verdict flip. Then make one y a string, and see which column becomes a plain array:

PlaygroundMake it transformable

A report is a contract

Because the report is text, it can be tested like code. Three places in the repository do:

  • The differential test passes r.explain as the failure message when a decision is wrong, so a failing test says why Viscosity decided what it did.
  • The mini-parser test compiles every corpus and benchmark program with both parsers and requires byte-identical reports, not just code.
  • For the Rust port (Part XI), the reference’s report and compiled code are stored for every test program in both bounds modes. A parity test requires the port to reproduce them byte for byte, and it does: all 122 fixture pairs matched when this chapter was written, and all 190 do today.

So changing a word in a reason is a visible diff in a review. That’s deliberate: if people read the reports, the reports are part of the interface.

Under the hood

Engines explain themselves too, though to engine developers rather than users. In debug builds, V8’s --trace-elements-transitions flag prints a line whenever an array’s elements kind changes, with the script location[1]. This book’s own benchmark findings used a similar flag, --trace-generalization, to see why a TypeScript class field became slow (chapter 22). Those are logs of events, written as the program runs. Viscosity’s report is written once, before the program runs, about every collection.

Your turn

ExerciseFormat an explain report

Write formatReport(ds). ds is a list of decisions { name, line, fields, layout, blockSize, reasons }. For each, print a verdict line NAME (line LINE) {F1, F2} → VERDICT, where VERDICT is kept as array of objects for aos, struct of arrays for soa, and AoSoA, blocks of N for aosoa (N is blockSize). Then one line per reason: two spaces, ·, a space, the reason. Join all lines with \n.

function formatReport(ds) {
  const lines = [];
  for (const d of ds) {
    lines.push(d.name + ' ' + d.layout);
  }
  return lines.join('\n');
}
Extra practiceWhy was it kept?

Write whyKept(c, min), a mini planner. c is { unsafe, numeric, loops, size }: a list of unsafe uses (strings), the number of numeric fields, the number of loops over its fields, and the size (a number or null). Return the list of reasons to keep it, or null to transform it. Check in order, stopping at the first that applies: any unsafe uses give 'unsafe: ' + u for each one; no numeric field gives ['no numeric field']; no loops gives ['no loop over its fields']; a known size below min gives ['too small: SIZE < MIN'].

function whyKept(c, min) {
  if (c.size < min) {
    return ['too small: ' + c.size +
      ' < ' + min];
  }
  return null;
}

What you learned

  • An optimization report explains every decision: what changed, what was kept, and the evidence. viscosity explain prints one verdict line per collection, then one · line per reason.
  • A transformed collection lists its hot loops, its columns, its size and its bounds proofs. A kept one names what stopped it, every unsafe use or the first failed check.
  • The planner records the reasons as it decides, and explain() only prints them, so the report can’t drift from the decision.
  • The report is tested byte for byte, as part of Viscosity’s interface.

Check your understanding

0/6
  1. 1.

    What does explain() in plan.ts compute?

  2. 2.

    A report says · 7 of 9 element accesses proven in bounds. What follows?

  3. 3.

    A collection is passed to console.log and is also too small. What does its report say?

  4. 4.

    Why is it valuable that keep(...) always receives the reason?

  5. 5.

    Which statement about testing the report is true?

  6. 6.

    How is V8’s --trace-elements-transitions different from Viscosity’s report?

References

  1. [1] Mathias Bynens (2017). Elements kinds in V8. V8 blog (v8.dev), 12 September 2017 (with an update note dated 28 February 2025). v8.dev/blog/elements-kinds (checked 2026-10-31)