73. What an IR is for
Source code is written for people, and bytecode for a machine that runs it. A compiler that wants to reason about a program needs a third form, built for asking questions. That form is the IR.
- Explain why an optimizer works on an intermediate representation instead of source text, syntax trees or bytecode.
- Describe lowering, and show how it turns many ways of writing the same thing into one.
- Read a short listing of VIR, Viscosity’s real IR.
- Write a small lowering pass in JavaScript.
Too many ways to say one thing
Viscosity wants to turn your arrays of objects into struct-of-arrays storage, the layout that won in Part II. Before it can, it has to answer questions about your program. Which fields of a particle are read? Is any particle passed to code we can’t see? Is this loop hot?
Try answering the first one by looking at JavaScript source. All of
these add vx to x:
p.x += p.vx;
p.x = p.x + p.vx;
p["x"] = p["x"] + p.vx;
const { vx } = p; p.x += vx;
An analysis written over source text, or even over the syntax tree, needs a case for every one of them, and for the next spelling someone invents. Each new question would need all those cases again. Compilers don’t do that. They translate the program, once, into a form with far fewer cases, and then ask every question there.
A form built for questions
A recipe written by a chatty cook: “pop the oven on, fairly hot, and while it warms you could chop the onions”. A kitchen running fifty orders rewrites it as a card: 1. oven 200°C. 2. chop onions. Every step does one thing, in the order it happens. Nobody eats the card, but it is the version the kitchen plans with.
Step through one small function in four forms. Watch what each form makes easy, and what it hides.
function dist(a, b) {if (a > b) return a - b;return b - a;}
The source is for people. The tree is for the parser and the
tree-walker: it keeps the program’s nesting. The IR listing and its
graph are for the compiler. Each instruction does one small thing and
gives its result a name, %2 or %3. And the control flow, which in
the tree was implied by what an If node means, is now written down
as jumps between labelled blocks.
An intermediate representation (IR) is the form a compiler keeps a program in while it analyses and transforms it. It sits between the source language and the target. A good IR is explicit (values have names, control flow is spelled out), small (few kinds of instruction, so each analysis has few cases), and independent of how the source happened to be written.
Lowering
Turning the tree into that listing is called lowering: the program is
translated into a lower-level form, closer to the machine and further
from the syntax. Here is what Viscosity’s JavaScript front end makes of
p.x += p.vx, inside function m(p):
func m(%0):
b0:
%0 = param
%1 = field.get %0 .x
%2 = field.get %0 .vx
%3 = binop + %1, %2
field.set %0, %3 .x
return
The compound += is gone. It became a read, an add and a write, the
same three steps that p.x = p.x + p.vx lowers to, instruction for
instruction. Two spellings, one meaning, one form. (Not every spelling
collapses this far: p["x"] becomes index.get with the constant
"x", and the destructuring line is opaque, which you’ll meet below.)
Try another pair: x = x + 1 and x += 1 lower to
identical VIR, %2 = binop + %0, %1.
But x++ doesn’t. It lowers with one extra instruction:
%1 = unop + %0
%2 = const 1
%3 = binop + %1, %2
That unop + is not a mistake. x++ converts x to a number first,
and x += 1 doesn’t, so with a string they really do differ:
let a = "5"; a += 1; let b = "5"; b++; console.log(a, b);
This is what lowering is for. It erases differences that don’t matter, and it makes the ones that do matter visible as instructions, so no analysis has to remember a corner of the language.
Lowering is translating a program into a lower-level form: fewer
kinds of construct, more explicit steps. Compound assignment, ++ and
loops become reads, writes, arithmetic and jumps. Lowering can happen in
several stages, each to a lower IR. Chapter 38’s desugaring is lowering
inside the tree; Viscosity’s front end lowers the tree all the way to
VIR.
What VIR looks like inside
VIR’s instructions are a discriminated union, tagged by op. Here is a
trimmed version of the list in packages/core/src/vir.ts:
type Op =
| 'const' | 'param' | 'phi'
| 'binop' | 'unop'
| 'field.get' | 'field.set'
| 'index.get' | 'index.set'
| 'record.new' | 'push' | 'length'
| 'call' | 'closure'
| 'opaque';
Notice what is there: records, fields, indexing and push. Viscosity
cares about arrays of objects, so they are first-class instructions
instead of patterns an analysis would have to recognise. Notice too
what is not there: nothing says “JavaScript”. The front end for a
language does the lowering; every analysis after it only sees VIR. (A
phi merges values where paths join. It gets a chapter of its own, 77.)
And the last op is the honest one. Anything the lowering doesn’t model,
like delete o.x, becomes opaque, with every variable it mentions as
an argument:
%1 = opaque(delete) %0
Opaque means “might do anything with these”. The analyses treat its arguments as lost to unknown code. That keeps them safe without understanding all of JavaScript: when in doubt, Viscosity leaves the program alone.
An IR that is too low loses facts you need. Drip’s bytecode is lower than VIR, but a stack machine’s instructions don’t say which earlier instruction produced the value on top of the stack. To ask “where does this value come from?” you would have to replay the stack in your head. An IR for analysis names every value.
Engines make the same trade. V8 first compiles each function to Ignition bytecode, a form built for running[4]. Sparkplug, its quick baseline compiler, turns that bytecode into machine code in one pass, with no IR of its own, leaning on work the bytecode compiler already did[3]. That is why it compiles fast, and also why it can’t do much optimizing. The analyses and optimizations of a compiler’s middle end, which the rest of Part VI builds, all run on an IR[1]. Viscosity’s whole idea, changing the layout of arrays of objects automatically, comes from research done inside an optimizing JIT[2]; VIR is where Viscosity asks the same questions ahead of time.
Your turn
A tiny tree has assignment in three spellings. Write lower(node) so only one is left.
{ type: "compound", name, op, value }, whereopis"+=","-="or"*=", becomes{ type: "assign", name, value: { type: "bin", op: "+", left: { type: "var", name }, right: value } }(with the matching operator).{ type: "update", name, op }, whereopis"++"or"--", becomes anassignwhose value is{ type: "bin", op: "+", left: { type: "tonum", arg: { type: "var", name } }, right: { type: "lit", value: 1 } }. Thetonumis the number conversion++does.- Any other node is returned unchanged (the same object).
Treat these as statements: ignore what the expression itself would return.
function lower(node) {
return node;
}Write lowerFor(node) for { type: "for", init, test, update, body }, where body is an array of statements. Return an array of two statements: init, then { type: "while", test, body: [...body, update] }. If test is null (as in for (;;)), use { type: "lit", value: true }. Don’t worry about continue here.
function lowerFor(node) {
return [node];
}What you learned
- An IR is the compiler’s working form of a program, designed for analysis: values are named, control flow is explicit, and there are few kinds of instruction.
- Source and trees are too varied, and stack bytecode hides where values come from. An IR sits in between.
- Lowering translates to a lower-level form. It erases spellings that
mean the same (
+=and= x +) and makes real differences visible (x++converts to a number first). - VIR is language-neutral, with records and arrays as first-class
instructions, and
opaquefor anything it doesn’t model.
Next: the shape of a single VIR instruction, three-address code.
Check your understanding
0/6- 1.
Why does an optimizing compiler translate the program into an IR before analysing it?
- 2.
In VIR, how do
x += 1andx = x + 1compare? - 3.
let s = "5"; s++;What issnow, and why does it matter to lowering? - 4.
What does Viscosity’s front end do with
delete o.x, which VIR has no instruction for? - 5.
Why is a stack-machine bytecode, like Drip’s, a poor IR for analysis?
- 6.
Which statement about lowering is true?
References
- [1] Keith D. Cooper, Linda Torczon (2022). Engineering a Compiler (3rd edition). Morgan Kaufmann (Elsevier). shop.elsevier.com/books/engineering-a-compiler/cooper/978-0-12-815412-0 (checked 2026-10-31)
- [2] Lukas Makor, Sebastian Kloibhofer, David Leopoldseder, Daniele Bonetta, Lukas Stadler, Hanspeter Mössenböck (2022). Automatic Array Transformation to Columnar Storage at Run Time. Proceedings of the 19th International Conference on Managed Programming Languages and Runtimes (MPLR ’22), ACM, pp. 16–28. doi:10.1145/3546918.3546919 (checked 2026-10-31)
- [3] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)
- [4] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)