Viscosity
Review
Part VII · Building Viscosity · Day 23

89. The architecture of Viscosity

Part VII builds the real thing. Before any single piece, here is the whole machine: what goes in, what comes out, which parts know about JavaScript, and why the output is just another JavaScript file.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Name the four steps of Viscosity’s compile function and say what each one consumes and produces.
  • Say which steps belong to a language plugin and which belong to the core, and why.
  • Explain what a source-to-source compiler is, and contrast it with changing layouts inside a running engine.
  • Run the real compiler on a small particle program and read its decisions.

Twenty-five lines

You have spent six parts collecting pieces: arrays of objects and why they are slow to scan, parsers, IRs, SSA, dominators, escape analysis, differential tests. Part VII puts them together into Viscosity, the compiler in this repository’s engine/ts folder.

The surprise is how small the top of it is. The file packages/core/src/index.ts is 25 lines long, and this is the part that matters:

function compile(source, plugin, opts) {
  const lowered = plugin.lower(source);
  const analysis =
    analyze(lowered.module);
  const p = plan(analysis, opts);
  const code =
    plugin.emit(source, lowered, p);
  return { code, plan: p, analysis,
    explain: explain(p) };
}

(Trimmed: the real one merges opts with defaults and passes along the @viscosity annotations the plugin found.) Every chapter from here to 112 lives inside one of those four calls.

Intuition

Think of a removal company re-packing a kitchen. A local crew reads the labels on your boxes, in whatever language you wrote them. Head office never opens a box: it only gets a list (“12 plates, 40 forks, all the same size”) and decides how to pack them. Then the local crew re-packs your kitchen to that plan, leaving everything it wasn’t told about exactly where it was.

Four steps, two owners

Step through one program. Watch the label above each stage: some steps belong to the language plugin, the rest to the core.

Step throughOne program through the pipeline
const ps = [];
for (let i = 0; i < 1000; i++)
ps.push({ x: i, vx: 1 });
for (let i = 0; i < ps.length; i++)
ps[i].x += ps[i].vx;
console.log(ps[3].x);
The six stages of the Viscosity pipeline, with the current stage highlightedsourceyour filelowerpluginVIRcoreanalyzecoreplancoreemitpluginan ordinary array of objects
The input is plain JavaScript text. Nothing in it mentions Viscosity. The pipeline’s job is to decide whether ps may be stored differently, and if so, to write a new file.
1/6

The steps, with what each consumes and produces:

  • lower (plugin): source text → a VIR module. For JavaScript that means parsing with acorn into an ESTree tree, resolving scopes, then lowering the tree to VIR.
  • analyze (core): VIR → facts. Which bindings are collections, which fields are numbers, which loops touch which fields, and the reasons it isn’t safe, if any.
  • plan (core): facts → a layout per collection, with reasons.
  • emit (plugin): the original source, plus the plan → new source.
Definition

The Viscosity pipeline is lower → analyze → plan → emit. A language front-end lowers source code to VIR, Viscosity’s IR. The analyses and the layout planner work on VIR only, and choose a layout for each collection (aos, soa or aosoa). The same front-end then emits new source code by applying the plan. Only lowering and emitting know the language.

Notice what emit does not do. It doesn’t print the program from VIR. It takes your original text and replaces only the ranges the plan requires, such as ps[i].x becoming ps.x[i]. VIR instructions keep a pointer back to the source range they came from, which is what makes that possible. Your comments, formatting and every unrelated line come through exactly as you wrote them.

Output that is source code

Here are the last lines that viscosity build really wrote for the program in the diagram. A class __Vsc_ps with one Float64Array per field is generated above them (chapter 103 builds it), along with a bounds-check helper:

const ps = new __Vsc_ps();
for (let i = 0; i < 1000; i++)
  ps.push(i, 1);
for (let i = 0; i < ps.length; i++)
  ps.x[i] += ps.vx[i];
console.log(ps.x[__vsc_bc(ps, 3, 'x',
  false)]);

The input was JavaScript, and so is the output. You can read it, diff it, ship it, and run it on any engine.

Definition

A source-to-source compiler (sometimes called a transpiler) is a compiler whose output is source code in a high-level language, often the same language it reads. The output is then compiled or run by an ordinary tool. Viscosity reads JavaScript (or Python) and writes JavaScript (or Python).

Try itBefore and after: same answer
// Before: an array of objects.
const ps = [];
for (let i = 0; i < 4; i++)
  ps.push({ x: i, vx: 1 });
for (let i = 0; i < ps.length; i++)
  ps[i].x += ps[i].vx;
console.log(ps[3].x);

// After: what a source-to-source
// compiler could write instead.
const qs = { length: 0,
  x: new Float64Array(4),
  vx: new Float64Array(4) };
for (let i = 0; i < 4; i++) {
  qs.x[i] = i; qs.vx[i] = 1;
  qs.length++;
}
for (let i = 0; i < qs.length; i++)
  qs.x[i] += qs.vx[i];
console.log(qs.x[3]);

Ahead of time, or inside the engine

Viscosity is not the first system to turn arrays of JavaScript objects into columns. Makor and colleagues did it at run time, inside a JavaScript engine built on Truffle and Graal[1][3], and a follow-up generated specialized loop code for the converted arrays[2].

Two places a layout change can happen: before the program runs, or inside the engine while it runsAHEAD OF TIME (Viscosity)app.jsviscosityout.jsany engineDecides once, from the code alone. Output is readable JS.AT RUN TIME (inside the engine)app.jsa modified enginewatches the running program,converts arrays it sees are suitableSees real values, but only that engine benefits.
Ahead of time works with any engine but must prove safety from the code alone. The same idea in two places. Viscosity changes the program before it runs; the run-time approach changes the engine.

The trade is real in both directions. A run-time transformation sees the actual values and can speculate, then back out with deoptimization if a guess fails. But it only helps programs run on that engine. Viscosity’s output runs anywhere, adds no compile work at start-up, and can be read by a person. In exchange it gets one look at the code, with no values and no way to back out. Every decision must be proved from the text alone, and when the proof fails the collection stays as it was.

Try it

This is the real compiler, running in your browser. Change the program and press the buttons: see the decision, the compiled code, and “Run both”, which checks the two versions print the same thing. Then try console.log(ps[3]) on the last line, and see what changes.

PlaygroundParticles
Under the hood

The repository’s packages follow the pipeline. packages/core holds VIR, its builder (SSA construction from chapter 78), the CFG utilities, the analyses and the planner. packages/frontend-js and packages/frontend-python are the two plugins. packages/cli wraps compile as viscosity build and viscosity explain, and packages/playground bundles core plus the JavaScript plugin for this page. Because every step is a plain function of its input, each can be tested alone, and the whole can be tested differentially, as chapter 88 showed.

Your turn

ExerciseA toy emitter

Emit is where a source-to-source compiler writes its output. Write rewrite(src, name, fields), which turns every name[INDEX].field in src into name.field[INDEX], but only when field is one of fields. INDEX is any text without a ]. Leave everything else alone: other arrays (qs[i].x, aps[i].x), other fields, and longer field names (ps[i].xs when only x is listed).

function rewrite(src, name, fields) {
  // your code here
  return src;
}

console.log(rewrite(
  'ps[i].x += ps[i].vx;',
  'ps', ['x', 'vx']));
Extra practiceWire up the pipeline

Write compile(src, plugin, core) the way packages/core/src/index.ts does. plugin has lower(src) and emit(src, lowered, plan). core has analyze(module) and plan(analysis), and explain(plan). lower returns { module }. Return { code, explain }.

function compile(src, plugin, core) {
  // your code here
  return { code: src, explain: '' };
}

What you learned

  • The Viscosity pipeline is lower → analyze → plan → emit, in a 25-line compile function.
  • Lower and emit belong to a language plugin; analyze and plan belong to the core and only see VIR.
  • Emit rewrites the original text range by range, so untouched code survives exactly.
  • Viscosity is a source-to-source compiler: JavaScript in, JavaScript out, runnable on any engine.
  • Doing the change ahead of time trades run-time knowledge and deoptimization for portability and readable output, so every decision must be proved from the code.

Check your understanding

0/6
  1. 1.

    Which step of Viscosity’s pipeline decides that ps should become a struct of arrays?

  2. 2.

    A new language plugin is added. Which parts must it provide?

  3. 3.

    Why does Viscosity’s output keep your comments and formatting?

  4. 4.

    What makes Viscosity a source-to-source compiler?

  5. 5.

    Compared with a run-time columnar transformation inside the engine, what does Viscosity give up?

  6. 6.

    Viscosity can’t prove that a collection’s elements never escape. What happens?

References

  1. [1] 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)
  2. [2] Sebastian Kloibhofer, Lukas Makor, David Leopoldseder, Daniele Bonetta, Lukas Stadler, Hanspeter Mössenböck (2023). Control Flow Duplication for Columnar Arrays in a Dynamic Compiler. The Art, Science, and Engineering of Programming, vol. 7, issue 3, article 9. doi:10.22152/programming-journal.org/2023/7/9 (checked 2026-10-31)
  3. [3] Thomas Würthinger, Christian Wimmer, Andreas Wöß, Lukas Stadler, Gilles Duboscq, Christian Humer, Gregor Richards, Doug Simon, Mario Wolczko (2013). One VM to Rule Them All. Onward! 2013 (SPLASH 2013), ACM, pp. 187–204. doi:10.1145/2509578.2509581 (checked 2026-10-31)