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.
- 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.
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:
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.
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:
- unsafe: an escape, an unknown index, a shape mismatch (chapters 99–100)
- annotation:
@viscosity aos, or anaosoarequest with non-numeric fields (chapter 102) - opt-in mode: not annotated
- Python only: CPython columns measured slower, so opt in (chapter 111)
- no numeric field: no typed columns to gain
- no loop over the fields: nothing to pay for the change
- 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:
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.explainas 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.
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
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');
}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 explainprints 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.
What does
explain()in plan.ts compute? - 2.
A report says
· 7 of 9 element accesses proven in bounds. What follows? - 3.
A collection is passed to
console.logand is also too small. What does its report say? - 4.
Why is it valuable that
keep(...)always receives the reason? - 5.
Which statement about testing the report is true?
- 6.
How is V8’s
--trace-elements-transitionsdifferent from Viscosity’s report?
References
- [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)