71. Columnar arrays at run time: the research
Viscosity’s central idea came from researchers who taught a JavaScript engine to turn arrays of objects into columns while the program runs. Here is what they did, what they report, and why Viscosity does the same job in a completely different place.
- Define columnar storage and relate it to struct-of-arrays.
- Describe the run-time approach of Makor et al. and Kloibhofer et al., as far as their abstracts state it.
- Explain control-flow duplication and why a run-time transformation needs it.
- Contrast run-time transformation with Viscosity’s ahead-of-time, proof-based rewrite, using the repo’s results.
Where the idea came from
Back in chapter 23 you measured struct-of-arrays beating an array of objects whenever a loop touches a few fields of many elements. Doing that rewrite by hand is tedious and easy to get wrong. So why doesn’t the engine just do it for you?
Lukas Makor, Sebastian Kloibhofer and colleagues, whose papers appear in the GraalVM team’s publication list, asked exactly that, and built it into GraalJS, the Truffle-based engine from chapter 70[1][3]. Their work is the closest prior work to Viscosity, and this chapter is a reading of it.
The two papers themselves weren’t reachable when this chapter was written. Everything below about them sticks to what their abstracts and the GraalVM team’s publication list say. For the details (which operations are supported, how storage changes back, the exact benchmarks), read the papers.
Columnar storage
A spreadsheet can be saved row by row, each person’s name, age and city together. Or column by column: all the names, then all the ages, then all the cities. To average the ages you only want the second file.
Columnar is the database word for the layout you know as SoA. The
research describes it as a layout used in analytical applications,
because it speeds up read-heavy work[1]. A loop
over particles that reads x and vx of every element is exactly that
kind of work.
Columnar storage keeps each field of a collection of records in its own contiguous array (a column), indexed by element number, instead of keeping each record’s fields together. It is struct-of-arrays under a database name. Reading one field of many records touches only that field’s column; rebuilding a whole record (materialization) needs one read from every column.
Columns while the program runs
Makor and colleagues transform arrays of JavaScript objects into columnar arrays automatically, at run time, inside the GraalJS engine. The transformation has a cost, which they amortize over the later accesses[1]. Running inside a speculating JIT gives them two things a compiler reading source doesn’t have: the real sizes and values of the arrays, and a way to recover from a wrong guess, deoptimization (chapters 66 and 70).
The follow-up paper starts from a weakness. By its abstract’s account, the first approach had performance issues on smaller workloads and on more complex, nested data[2]. Its answer has two parts. First, control-flow duplication in the Graal compiler: loops that may touch a columnar array are duplicated, and a check decides which copy runs:
// the shape of the idea, not their code
if (isColumnar(ps)) {
// fast copy: column accesses only
} else {
// the original loop, which must cope
// with either kind of storage
}
Second, a multi-level transformation for arrays of objects that
contain objects, demonstrated with JavaScript Date objects, whose
values become a column of their own. Their abstract reports TPC-H
data-analytics queries running up to 10× faster for particular
queries, with the multi-level transformation adding around 2×
more[2].
A run-time transformation changes a program’s data layout while the program is running, using what the engine observes (sizes, types, access counts) to decide when it pays. Because the layout can change under compiled code, the code must check which layout it has (with guards, or duplicated paths) and fall back when a check fails.
The same job, somewhere else
Viscosity asks the research’s question in a different place: in a compiler that reads your source before anything runs. Step through the two side by side:
The differences follow from where each one stands:
- Safety. The engine can guess and deoptimize. Viscosity can’t, so every use of the array must be one it can prove it rewrites correctly. If not, it bails out and the array stays as objects.
- Cost. The run-time version pays for converting during the run.
Viscosity’s output allocates columns from the first
push. - Information. The engine knows the real sizes. Viscosity has to estimate them, leaves small arrays alone (64 elements by default), or uses a profile you record.
- Reach. The run-time version needs a modified engine. Viscosity’s output is plain JavaScript that runs anywhere.
Try it. This is the real compiler. Then delete the line that adds
.tag to one element, and watch the decision change:
With .tag there, explain keeps ps as an array of objects and says
why: line 5 “adds a new property .tag to an element”. An engine would
see that write happen and handle it at run time. Viscosity sees it in
the source and doesn’t transform at all.
Here is what the ahead-of-time version achieved on this book’s five end-to-end programs, in one recorded run:
Table view
| program | layout | original ms | Viscosity ms | speed-up |
|---|---|---|---|---|
| ledger | tx=soa | 237 | 46 | 5.11× |
| particles | ps=soa | 405 | 94 | 4.3× |
| heat | cells=soa | 364 | 110 | 3.3× |
| boids | fl=soa | 84 | 71 | 1.18× |
| nbody | bs=soa | 67 | 61 | 1.09× |
From engine/ts/packages/bench/results/e2e-node-2026-10-31.json: Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. Median of N fresh-process runs of each program’s own timed section. Shared cloud VM.
Notice the same weakness the follow-up paper started from. In this book’s microbenchmarks, gains shrink for small collections (FINDINGS.md, section 3), and Zap’s TypeScript experiments found SoA losing when whole objects must be rebuilt[5]. Moving the decision earlier doesn’t make that go away; it only changes who has to decide, and with what information.
Both approaches need the same three things: a guarantee nobody can observe the old layout, specialized loops, and a reason to believe it pays. The research gets the first from speculation and deoptimization, the second from the Graal compiler’s control-flow duplication, and the third from run-time observation[2]. Viscosity gets the first from static analysis with bail-outs, the second for free (its loops only ever see one layout), and the third from a cost model. Neither is strictly better. A run-time system adapts to data it sees; an ahead-of-time one is predictable, needs no special engine, and can explain every decision before you ship.
Your turn
A toy run-time transformation (not the papers’ design). HotArray starts as rows of objects and can columnarize() and rowify(). Write sum(k): count one use; when there are no columns yet and uses have reached HOT, call columnarize(); then return the sum of field k. Write set(i, k, v): in columns mode, if k has a column and v is a number, write it into the column; otherwise the guard has failed, so rowify() first. In rows mode, just set rows[i][k] = v.
const HOT = 3;
class HotArray {
constructor(objs) {
this.rows = objs;
this.cols = null;
this.n = objs.length;
this.uses = 0;
}
get mode() {
return this.cols
? 'columns' : 'rows';
}
// rows -> one Float64Array per field,
// only if every row has the same
// numeric fields
columnarize() {
const rs = this.rows;
const ks = Object.keys(rs[0]);
const m = ks.length, n = rs.length;
const ok = rs.every((r) =>
Object.keys(r).length === m &&
ks.every((k) =>
typeof r[k] === 'number'));
if (!ok) return;
this.cols = {};
for (const k of ks) {
const c = new Float64Array(n);
for (let i = 0; i < n; i++) {
c[i] = rs[i][k];
}
this.cols[k] = c;
}
this.rows = null;
}
// columns -> objects again
rowify() {
const ks = Object.keys(this.cols);
this.rows = [];
for (let i = 0; i < this.n; i++) {
const r = {};
for (const k of ks) {
r[k] = this.cols[k][i];
}
this.rows.push(r);
}
this.cols = null;
}
get(i, k) {
return this.cols
? this.cols[k][i]
: this.rows[i][k];
}
sum(k) {
return 0;
}
set(i, k, v) {
this.rows[i][k] = v;
}
}An array a is either { n, cols } (columns: an object of Float64Arrays) or { n, rows } (an array of objects); the other property is null. Write scale(a, k, f), which multiplies field k of every element by f, with two copies of the loop: one that uses only a.cols[k], and one that uses only a.rows. Check which storage a has once, before the loop.
function scale(a, k, f) {
for (let i = 0; i < a.n; i++) {
a.rows[i][k] *= f;
}
}What you learned
- Columnar storage is struct-of-arrays by its database name: one contiguous column per field.
- Run-time transformation (Makor et al.) converts arrays of objects
into columns inside GraalJS while the program runs, amortizing the
cost; the follow-up adds control-flow duplication and
multi-level columns (for
Date), and reports large speed-ups on particular TPC-H queries. - Viscosity does it ahead of time: safety by proof, a bail-out that leaves the array untouched, no guards, no deoptimization.
- Both pay off on large arrays and few fields, and struggle with small ones and whole-object use.
Check your understanding
0/6- 1.
What is columnar storage?
- 2.
Where did Makor et al. implement their columnar transformation?
- 3.
Why does the 2023 follow-up duplicate loops?
- 4.
Viscosity can’t prove an array is safe to transform. What happens?
- 5.
What can a run-time transformation know that Viscosity can’t?
- 6.
In this book’s end-to-end run, why might
nbodygain much less thanledger?
References
- [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] 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] Oracle and the GraalVM contributors (2026). GraalVM Academic Publications (docs/Publications.md). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/docs/Publications.md (checked 2026-10-31)
- [4] Oracle and the GraalJS contributors (2026). GraalJS: JSAddNode.java and JSAddNormalNode.java (the JavaScript + operator). GraalJS source repository (github.com/oracle/graaljs), master branch. github.com/oracle/graaljs/blob/master/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/nodes/binary/JSAddNode.java (checked 2026-10-31)
- [5] Bui Huy Giap (“Zap”, ziap) (2025). Struct of Arrays (SoA) in TypeScript with metaprogramming. Zap’s website (blog post, 17 September 2025). ziap.github.io/blog/parallel-array/ (checked 2026-10-31)
- [6] Oracle and the GraalVM contributors (2026). Partial Evaluation in Truffle (truffle/docs/PartialEvaluation.md). GraalVM source repository (github.com/oracle/graal), master branch. github.com/oracle/graal/blob/master/truffle/docs/PartialEvaluation.md (checked 2026-10-31)