Viscosity
Review
Part VII · Building Viscosity · Day 25

98. Access patterns

Struct of arrays helps a loop that touches a few fields of many elements. So for every loop, Viscosity writes down which fields it reads and writes, how deeply it is nested, and, when it can tell, how many times it runs.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Describe a loop's access pattern as its read set, write set, nesting depth and trip count.
  • Build a per-loop access table from a list of element accesses, the way Viscosity does.
  • Predict when Viscosity can and can't compute a loop's trip count.
  • Explain what a hot loop is, and what Viscosity uses in its place before the program has run.

One shape, two loops

Chapter 23 showed that struct of arrays wins most when a loop reads only some of the fields. Here are two suites from this book’s benchmark run. Both use the same six-field records; one loop reads every field, the other reads one:

read-all: read all 6 fields
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 6.21 ns, p95 6.55 ns per elementarray of objects6.2struct of arrays: median 1.97 ns, p95 4.29 ns per elementstruct of arrays2.0 3.2× faster
Table view
layoutmedian nsp95 ns
array of objects6.216.55
struct of arrays1.974.29

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.

SoA still wins, but every column must be streamed through the cache.
sum-one-field: read 1 of 6 fields
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 5.60 ns, p95 6.11 ns per elementarray of objects5.6struct of arrays: median 0.66 ns, p95 0.81 ns per elementstruct of arrays0.7 8.5× faster
Table view
layoutmedian nsp95 ns
array of objects5.606.11
struct of arrays0.660.81

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.

With one field, SoA reads only the bytes the loop needs.

The collection is the same. What differs is the loop. So before it can decide anything, Viscosity needs to know, for every loop, what that loop does to the collection’s fields.

Intuition

A warehouse picker carries a clipboard with one line per aisle. In each aisle they tick which shelves they took from and which they restocked. At the end of the day the clipboard says how the warehouse is used, and whether it is laid out well.

Definition

A loop’s access pattern says what it does to a collection’s elements: which fields it reads, which it writes, how deeply it is nested inside other loops, and how many trips (iterations) it makes per execution, if that can be known.

In Viscosity it is one record per loop, from packages/core/src/analysis.ts:

interface LoopAccess {
  func: string;   // the enclosing function
  line: number;   // the loop's header line
  depth: number;  // 0 = not in a loop
  reads: Set<string>;
  writes: Set<string>;
  /** estimated iterations per
   *  execution, if known */
  trips: number | null;
}

Filing every access under a loop

By this point the safety pass (chapter 99) has already collected every element access: each field.get and field.set whose object is an element ps[i]. Note that ps[i].x += 1 is two accesses, a read and a write. Building the table is a matter of filing each one under the innermost natural loop around it. Step through it:

Step throughBuilding the access table
const ps = [];
for (let i = 0; i < 1000; i++)
ps.push({ x: i, y: 0, vx: 1, vy: 2 });
for (let t = 0; t < 60; t++) {
for (let i = 0; i < ps.length; i++) {
ps[i].x += ps[i].vx;
ps[i].y += ps[i].vy;
}
}
let s = 0;
for (let i = 0; i < 10; i++) s += ps[i].x;
console.log(s, ps[0].y);
One row per loop: depth, trip count, fields read, fields writtenLOOPDEPTHTRIPSREADSWRITESno rows yet
The safety pass has already listed every element access: each field read (field.get) and write (field.set) on an element of ps. Now Viscosity sorts them by loop.
1/7

The real loop is short. Simplified:

for (const a of c.accesses) {
  const f = funcOf.get(a.inst);
  const l = loopOf(loops(f), a.inst.block);
  const key = l ? f.id + ':' + l.header
                : f.id + ':none';
  let la = byLoop.get(key);
  if (!la) {
    la = { func: f.name, line: lineOf(l),
      depth: l ? l.depth : 0,
      reads: new Set(), writes: new Set(),
      trips: l ? tripCount(f, l) : 1 };
    byLoop.set(key, la);
  }
  const set = a.kind === 'read'
    ? la.reads : la.writes;
  set.add(a.field);
}

loopOf picks the smallest loop whose body contains the access’s basic block, and the key is the loop’s header block, so two accesses in the same loop always share a row. Printing the real rows for the program in the diagram gives (reformatted):

<module> line 5 depth 2 trips null
  reads vy, y, vx, x  writes y, x
<module> line 11 depth 1 trips 10
  reads x
<module> line 12 depth 0 trips 1
  reads y

The fields come out in the order the analysis happened to find them, which is fine: they are sets. Because the key includes the function, loops in different functions get different rows. In the corpus file interproc_ok.js, bodies is passed to two functions, and explain reports a loop in each:

bodies (line 2) {x, y, m} → struct of
  arrays
  · loop at line 7 in centerOfMass()
    touches 2 of 3 fields (x, m)
  · loop at line 11 in drift() touches 1
    of 3 fields (x)

Depth and trips

Depth comes from the natural loops of chapter 83: a loop’s depth is one more than the number of larger loops that contain its header. The inner loop in the diagram is inside the t loop, so it has depth 2.

Trips is harder, and Viscosity only tries the easiest case: the loop’s condition is i < c or i <= c for a constant c, where i is an induction variable that starts at 0 and steps by 1. Simplified:

const tripCount = (f, l) => {
  const t = f.blocks[l.header].term;
  if (t.kind !== 'condbr') return null;
  const cond = inst(t.cond);
  if (cond.operator !== '<' &&
      cond.operator !== '<=') return null;
  const [iv, bound] = cond.args.map(inst);
  if (!isCountingPhi(iv, true) ||
      bound.op !== 'const') return null;
  return cond.operator === '<'
    ? bound.value : bound.value + 1;
};

So i < ps.length has no trip count: the length isn’t a constant in the code. Neither does i < 64 * 64, because the bound is an expression, not a constant. That one is from nested_loops.js, where explain says “size unknown statically”. Today the trip counts feed exactly that line: a push inside loops with known trip counts adds their product to the size estimate, and the next chapters’ cost model looks at the size.

Hot loops

Intuition

In a restaurant kitchen, the layout of the stove matters far more than the layout of the storeroom, because the stove is where the cooks spend their evening.

Definition

A hot loop is a loop where a program spends most of its time. Those are the only loops whose memory layout is worth optimizing.

Before the program runs, nobody knows for sure which loops are hot. Viscosity uses the weakest sign that can be checked statically: an access counts only if it is in a loop at all. The planner, in plan.ts, throws away the depth-0 rows:

const fieldLoops =
  c.loops.filter((l) => l.depth > 0);
if (fieldLoops.length === 0)
  keep('no loop reads or writes its ' +
    'fields, so a new layout cannot ' +
    'pay off');

That is the whole story of the corpus file no_loops.js, which fills ps in a loop but only ever reads ps.length:

ps (line 2) {x} → kept as array of
  objects
  · no loop reads or writes its fields,
    so a new layout cannot pay off

Depth and trip counts are recorded, but the planner doesn’t yet weigh one loop against another with them. A loop that runs ten times and a loop nested three deep both count. Chapter 109 replaces this guess with measurements from a real run.

Under the hood

V8 finds its hot code by watching: functions start in the interpreter, and the ones that run often move up to optimizing tiers, which use the type feedback collected along the way[1]. Viscosity works before the program has run, so it can’t watch. It has to read hotness off the code, and “inside a loop” is the part it can be sure of.

Your turn

ExerciseBuild the access table

Each element access is { loop, field, kind }: loop is the innermost loop’s name (or null if it is in no loop), and kind is 'read' or 'write'. Write loopTable(accs), returning an object with one entry per loop (use the key 'none' for null). Each entry is { reads, writes }: arrays of field names in the order first seen, with no duplicates.

function loopTable(accs) {
  const table = {};
  // your code here
  return table;
}

console.log(loopTable([
  { loop: 'L5', field: 'x',
    kind: 'read' },
  { loop: 'L5', field: 'x',
    kind: 'write' },
]));
Extra practiceDescribe a loop like explain does

A row is { func, line, depth, reads, writes } (arrays of field names). Write describeLoop(row, F), where F is the number of fields in the shape. Return null for a row with depth 0. Otherwise return the explain line 'loop at line L in FUNC() touches K of F fields (A, B)', where the list is the reads followed by any writes not already listed, and K is its length.

function describeLoop(row, F) {
  // your code here
}

console.log(describeLoop({
  func: 'drift', line: 11, depth: 1,
  reads: ['x'], writes: ['x'],
}, 3));

What you learned

  • A loop’s access pattern is its read set, write set, nesting depth and trip count for one collection.
  • Viscosity files every element access under the innermost natural loop around it, one row per loop per function. Accesses outside any loop get a depth-0 row.
  • x += 1 is a read and a write. Rows hold sets of fields.
  • Trip counts are known only for loops like i < 10 counting from 0 by
    1. Today they only feed the size estimate.
  • A hot loop is where the time goes. Before the program runs, Viscosity’s stand-in is “in a loop”: with no field access in any loop, it keeps the array of objects.

Check your understanding

0/6
  1. 1.

    A loop runs ps[i].x += ps[i].vx. What is its row in the access table?

  2. 2.

    An access is inside an inner loop, which is inside an outer loop. How is it recorded?

  3. 3.

    Which loop gets a known trip count from Viscosity?

  4. 4.

    A program fills ps in a loop, then only reads ps[0].x and ps.length. What does Viscosity decide?

  5. 5.

    Why does Viscosity use “inside a loop” rather than measured time to judge hotness?

  6. 6.

    What does a known trip count change in Viscosity today?

References

  1. [1] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)