Viscosity
Review
Part VII · Building Viscosity · Day 26

101. The cost model

Proving a layout change safe only says Viscosity may make it. Whether it should is a separate question, and Viscosity answers it with three simple rules, measured where possible and honest about what they leave out.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why a safe layout change can still be a bad one, and what a cost model is for.
  • Walk a collection down the planner’s rules in order and predict the reason `viscosity explain` will print.
  • Connect each rule to this book’s benchmark results, and say what the model does not consider.
  • Describe how a profile replaces the static size estimate.

Safe is not the same as worth it

Here is one of the test programs in Viscosity’s corpus, small_auto.js:

// viscosity: pts=aos
const pts = [{ x: 1, y: 2 },
  { x: 3, y: 4 }];
let sx = 0;
for (let i = 0; i < pts.length; i++)
  sx += pts[i].x;
console.log(sx);

The first line is the corpus header from chapter 88: the test expects pts to stay an array of objects. Yet every check from the last four chapters passes. The records have one stable shape, both fields are numbers, the loop reads x, and nothing can observe the layout. Viscosity could turn pts into two Float64Array columns. It doesn’t:

$ viscosity explain small_auto.js
pts (line 2) {x, y} → kept as array
  of objects
  · about 2 elements: below the
    64-element threshold, where the
    benefit is too small

(Explain output is wrapped to fit the page throughout this chapter.)

A layout change has costs: a generated class, rewritten accesses, and a program that is harder to read in a debugger. For two points the loop takes nanoseconds either way, so nothing pays those costs back.

Intuition

Reorganizing a kitchen is worth it for the drawer you open twenty times a day, not for the box of party decorations you open once a year. Before you move anything, you guess how often you will reach for it. That guess is all a cost model is.

Definition

A cost model is the part of a compiler that estimates whether a transformation will pay off, so that it only makes the safe changes that are also profitable. It works from facts the compiler can see (sizes, loops, types) or has measured, and its estimates can be wrong.

The ladder

The planner in core/src/plan.ts decides one collection at a time. It asks a fixed series of questions, and the first one answered “yes” decides. Some of the questions are about permission, and the rest are the cost model. Step through them in order:

Step throughThe planner’s decision ladder
Seven questions in order; the first yes keeps the collection (or applies its annotation); all no gives struct of arrays1Unsafe? (any bailout)yesaosno2Annotated with @viscosity?yesas toldno3Opt-in mode, not annotated?yesaosno4Python, no --python-auto?yesaosno5No field provably numeric?yesaosno6No loop touches a field?yesaosno7Fewer than minSize (64)?yesaossoa: struct of arrays
Every collection that discovery found walks down this ladder. Each rung asks one question. The first “yes” decides the layout and records a reason; a collection that answers “no” to every rung becomes struct of arrays.
1/10

Here is the same ladder as code, simplified from plan() (the reason strings are shortened here):

if (c.bailouts.length)
  return keep('unsafe: …');
if (ann) return ann.layout;
if (opts.mode === 'opt-in')
  return keep('opt-in mode: …');
if (python && !opts.pythonAuto)
  return keep('CPython: …');
if (c.numericFields.size === 0)
  return keep('no field is …');
if (!c.loops.some((l) => l.depth > 0))
  return keep('no loop reads …');
const measured =
  opts.profile?.[c.name]?.maxLength;
const size = measured ?? c.sizeEstimate;
const about = measured === undefined
  && c.sizeIsUpperBound
  ? 'at most' : 'about';
if (size !== null && size < opts.minSize)
  return keep(`${about} ${size} …`);
return 'soa';

The order matters. Safety comes before everything, so not even an annotation can force an unsafe change. An annotation comes before the cost rules, because it is the programmer overruling the guess.

Three rules, and where they came from

Once permission is settled, the cost model is just three questions.

Is any field provably numeric? If not, every column would be a plain JavaScript array of values. Part of SoA’s measured advantage comes from typed storage: in this book’s benchmark run, a packed Float64Array that keeps the records together already captures a large share of the win (see FINDINGS.md, section 5). Plain-array columns get none of that, so the rule says no.

Does a loop read or write a field? A layout only pays off through work that is repeated. A collection whose fields are touched once, or never, is kept.

Is it big enough? Here is the measured basis. The same x += vx loop, at two sizes:

Moving particles, 1,000 elements
Median ns per element, n = 1,000 (shorter is faster)
array of objects: median 2.87 ns, p95 3.60 ns per elementarray of objects2.9struct of arrays: median 1.73 ns, p95 1.94 ns per elementstruct of arrays1.7 1.7× faster
Table view
layoutmedian nsp95 ns
array of objects2.873.60
struct of arrays1.731.94

Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.

At 1,000 elements the data already fits in cache, and SoA’s lead is modest.
The same loop, 100,000 elements
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 6.45 ns, p95 7.65 ns per elementarray of objects6.5struct of arrays: median 1.72 ns, p95 2.86 ns per elementstruct of arrays1.7 3.8× faster
Table view
layoutmedian nsp95 ns
array of objects6.457.65
struct of arrays1.722.86

Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.

At 100,000 elements the array of objects slows down per element; SoA barely changes.

In this run SoA was about 1.7× faster at 1,000 elements and 3.8× faster at 100,000 (results/baseline-node-2026-10-31.md). The win grows with size because the cache stops hiding the waste in each object.

Be clear about what that does and doesn’t prove. The benchmark never measured fewer than 1,000 elements, so the default threshold of 64 is not read off a chart. It is a conservative judgment. Sixty-four records of two doubles are 1 KB, sixteen 64-byte cache lines, and a loop over them is over too quickly for any layout to matter. You can change it with --min-size.

Guessing the size

The planner needs a size before the program runs. The analysis counts the literal elements, then adds each push, multiplied by the trip count of every loop around it, when the loop is a simple for (let i = 0; i < 1000; i++) with a constant bound. A push under an if may not run every time, so the count is a maximum, and explain says “at most 40 elements” rather than “about”. A push inside a function makes the size unknown, because the analysis doesn’t know how often the function is called. Call this program spawn.js:

const ps = [];
function spawn(k) {
  for (let i = 0; i < k; i++)
    ps.push({ x: i, v: 1 });
}
spawn(10);
for (let i = 0; i < ps.length; i++)
  ps[i].x += ps[i].v;
console.log(ps[3].x);
ps (line 1) {x, v} → struct of arrays
  · loop at line 7 in <module>()
    touches 2 of 2 fields (v, x)
  · typed Float64Array columns for
    all 2 fields
  · size unknown statically
  · 2 of 3 element accesses proven
    in bounds

An unknown size passes the threshold. The planner is optimistic: when it can’t tell, it assumes the collection may be large. Here it is wrong, because ps holds ten points.

Measuring fixes that. viscosity profile runs the original program with a little tracking added, and writes down how long each collection was at the end. Chapter 109 builds it. Fed back in, the measured size replaces the estimate:

$ viscosity profile spawn.js \
    -o prof.json
$ cat prof.json
{
  "ps": {
    "maxLength": 10
  }
}
$ viscosity explain spawn.js \
    --profile prof.json
ps (line 1) {x, v} → kept as array
  of objects
  · about 10 elements (measured):
    below the 64-element threshold,
    where the benefit is too small

Try it

This program transforms ws. Then try two changes, one at a time. Make n a string (n: 'x' + i), so that no field is numeric. Or delete the second loop, and print ws[9].w instead. Each change stops the transform at a different rung, with its own reason.

PlaygroundWhich rung stops it?

What the model leaves out

It’s worth being blunt: this is a simple model. It doesn’t look at:

  • How hot a loop is. Any loop counts, even one that runs once, or lives in a function nobody calls. The analysis records trip counts, but the planner doesn’t use them.
  • How many fields a loop touches. Explain prints “touches 1 of 2 fields”. SoA gains most when a loop uses few of many fields, but the planner doesn’t weigh that.
  • Costs SoA adds. Bounds checks, the generated code, and rebuilding objects (materialization) don’t enter the model. Today the safety analysis refuses any collection whose elements would need rebuilding, so that last cost never arises.
  • AoSoA. The planner never chooses it alone; only an annotation can.
  • The engine. The thresholds come from one run of Node on one machine.

The end-to-end results show why that matters. All five benchmark programs were transformed, and in this book’s run each got faster, by anything from 1.09× (nbody) to 5.11× (ledger) (results/e2e-node-2026-10-31.md). The model had no way to predict which would gain 9% and which fivefold.

Under the hood

Optimizing compilers are full of cost models. LLVM’s loop vectorizer uses one to choose how many iterations to combine into SIMD instructions and how far to unroll, and its documentation shows flags and pragmas that let a programmer override the choice[1]. That is the same split as Viscosity’s ladder: a model that guesses, and a way for a person who knows better to overrule it. The research system Viscosity is modelled on made the columnar transformation at run time, inside the engine[2]. There, the array’s real size and use can be observed directly. A source-to-source compiler has to guess, or be told by a profile.

Your turn

ExerciseThe planner’s ladder

Write planFor(c, opts) for a collection summary c with bailouts (an array of strings), ann (null, 'aos' or 'soa'), numeric (how many fields are provably numeric), loops (how many loops touch a field), size (the static estimate, or null if unknown) and maybe measured (a profiled size). opts has mode ('auto' or 'opt-in') and minSize. Check the rules in Viscosity’s order and return [layout, rule], where rule is one of 'unsafe', 'annotated', 'opt-in', 'no-numeric', 'no-loops', 'small' or 'soa'.

function planFor(c, opts) {
  // Check the rules in order.
  // The first one that matches wins.
  return ['soa', 'soa'];
}
Extra practiceEstimating a size

Write estimate(lits, pushes). lits is the number of literal elements. Each push is { trips, inFunc }: trips lists the trip count of every loop around it (outermost first), each a number or null if not constant, and inFunc says whether the push is inside a function. Return the literal count plus, for each push, the product of its trip counts. Return null if any trip count is null or any push is in a function.

function estimate(lits, pushes) {
  return lits + pushes.length;
}

What you learned

  • A cost model estimates whether a safe change will pay off. Safe and profitable are separate questions.
  • Viscosity’s planner checks rules in order, and the first “yes” decides: unsafe, annotated, opt-in, Python, no numeric field, no loop, too small. Otherwise it chooses soa.
  • The size rule rests on this book’s benchmark results: SoA’s lead grows with size. The threshold of 64 is a conservative judgment, not a measured crossover.
  • The static size estimate counts literals and pushes in loops with constant bounds. An unknown size is treated as large, and a profile replaces the guess with a measurement.
  • The model ignores how hot a loop is, how many fields it uses, and the costs SoA adds. Simple, and explainable, on purpose.

Check your understanding

0/6
  1. 1.

    A collection is safe to transform, has numeric fields, and a loop reads them. Static analysis estimates 20 elements. With default options, what does Viscosity do?

  2. 2.

    A collection has a bailout and is annotated // @viscosity soa. What happens?

  3. 3.

    Why does a collection with only string fields stay an array of objects?

  4. 4.

    The static size of a collection is unknown, because its pushes are inside a function. What does the size rule do?

  5. 5.

    What does the 64-element default rest on?

  6. 6.

    Which of these does the current cost model weigh?

References

  1. [1] The LLVM Project. Auto-Vectorization in LLVM. LLVM documentation. llvm.org/docs/Vectorizers.html (checked 2026-10-31)
  2. [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)