Viscosity
Review
Part VII · Building Viscosity · Day 27

108. Hot/cold splitting

Fields that are used together should live together, and fields that are hardly used should stay out of the way. Here is how to measure that, and how Viscosity’s mixed columns relate to it.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain a classic hot/cold split, and what it saves a hot loop.
  • Compute field hotness and field affinity from loops and how often they run.
  • Describe Viscosity’s real per-field policy, typed columns for proven numbers and plain columns for the rest, and how it relates to hot/cold splitting.

The fields nobody reads

A game keeps its entities in an array of objects:

const es = [];
es.push({
  x: 0, y: 0, vx: 1, vy: 0,
  health: 100, name: 'goblin',
});

Every frame a loop moves every entity, using x, y, vx and vy. The name is printed when an entity dies. Yet V8 keeps an object’s fields together, right after its header[2], and a cache works in whole 64-byte lines[1]. So the movement loop drags health and a pointer to name through the cache for every entity, and never looks at them.

Intuition

A toolbox has a top tray and a bottom drawer. The hammer and screwdriver you use every day go in the tray; the pipe wrench you need twice a year goes in the drawer. You don’t throw the wrench away. You just stop lifting it every time you reach for the hammer.

Splitting hot from cold

Chapter 23 named the fix: a hot/cold split. Put the busy fields in one place and the rest in another, both indexed by the same i:

const hot = {
  x: [0], y: [0], vx: [1], vy: [0],
};
const cold = [
  { health: 100, name: 'goblin' },
];
// the movement loop:
for (let i = 0; i < n; i++)
  hot.x[i] += hot.vx[i];

The movement loop now streams only hot data. The price is on the cold side: code that wants a whole entity has to look in two places.

Which fields go together?

“Hot” is a matter of counting. Count, for each field, how many times the program touches it. Then count, for each pair of fields, how often a loop uses both. Weight each loop by how many times it runs:

Step throughHotness and field affinity
Per-field access counts, pairwise affinity scores, and the resulting groupsLOOPSmove ×1000: x, y, vx, vydamage ×100: x, healthreport ×1: health, nameHOTNESSxyvxvyhealthnameAFFINITY
An entity with six fields, and three loops. move runs every frame (1000 times in our run), damage 100 times, report once. Which fields belong together?
1/6
Definition

Field affinity measures which fields are used together. A simple score for a pair of fields is the total run count of the loops that use both. Fields with high affinity belong in the same group; a field’s hotness (how often it is used at all) decides whether its group is hot or cold.

The run counts matter. Counted by loop alone, report would look as important as move. Weighted by runs, name scores 1 and x scores 1,100.

What leaving fields out is worth

The sum-one-field suite reads one of six particle fields:

Summing one field of six
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.6packed Float64Array: median 1.83 ns, p95 1.93 ns per elementpacked Float64Array1.8 3.1× fasterstruct 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
packed Float64Array1.831.93
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.

Packed AoS still loads all six fields; SoA loads only the one it uses.

Packed AoS already drops the headers and pointers, but its cache lines still carry five unused fields. SoA carries none. That is the hot/cold argument taken to its limit.

What Viscosity does

Viscosity doesn’t build a hot struct and a cold struct. It gives every field its own column, which is the finest split there is: each field is its own group, and each loop loads exactly the columns it touches. A move loop over Viscosity’s columns never reads name at all, whatever its affinity.

What Viscosity does decide per field is the representation. The corpus program mixed_columns.js shows it:

for (let i = 0; i < 300; i++)
  es.push({
    id: 'e' + i, hp: 100, x: i,
    tag: i % 3 ? null : { boss: true },
  });
for (let r = 0; r < 3; r++) {
  for (let i = 0; i < es.length; i++) {
    es[i].x += 1;
    if (es[i].tag !== null) es[i].hp -= 10;
  }
}
es[4].id = 42;

(Long lines are wrapped here; the declaration of es and the printing at the end are left out.) viscosity explain says:

es (line 2) {id, hp, x, tag} → struct of
  arrays
  · loop at line 5 in <module>() touches
    3 of 4 fields (hp, tag, x)
  · loop at line 12 in <module>() touches
    2 of 4 fields (hp, x)
  · typed Float64Array columns for hp, x;
    plain array columns for id, tag (may
    hold non-numbers)

And the generated constructor, reformatted:

constructor(cap = 16) {
  this.length = 0;
  this.cap = cap;
  this.hp = new Float64Array(cap);
  this.x = new Float64Array(cap);
  this.id = [];
  this.tag = [];
}

The type analysis proved hp and x are always numbers, so they get Float64Array columns of raw doubles[3]. id holds strings and, after es[4].id = 42, a number; tag holds null or an object. Those get plain array columns, which store any value.

Notice that this split is by type, not by heat. tag is read in the hottest loop, yet it is a plain column, because a Float64Array can’t hold an object. And a numeric field that is almost never read still gets a typed column. The per-loop lines in the explanation (“touches 3 of 4 fields (hp, tag, x)”) are exactly the raw data an affinity analysis needs, but Viscosity doesn’t group fields with them today. Try the policy: make id a number, and it gets a typed column too.

PlaygroundTyped and plain columns
Under the hood

Affinity becomes important when fields share storage. In packed AoS, and in chapter 107’s AoSoA blocks, all of a record’s fields sit in one array, so the order and grouping of fields decide what each cache line carries. A layout that grouped hot fields into one AoSoA array and kept cold ones in plain columns would need exactly the counts in this chapter. Viscosity sidesteps the question by giving each field a column, which is why its only per-field decision is typed or plain.

Your turn

ExerciseCount co-access

Each loop is { runs, fields }. Write coAccess(loops): an object whose keys are pairs of field names, sorted alphabetically and joined with + (like 'vx+x'), and whose values are the total runs of the loops that use both fields. Leave out pairs that are never used together.

function coAccess(loops) {
  const aff = {};
  // your code here
  return aff;
}

const loops = [
  { runs: 1000,
    fields: ['x', 'y', 'vx', 'vy'] },
  { runs: 100,
    fields: ['x', 'health'] },
  { runs: 1,
    fields: ['health', 'name'] },
];
console.log(coAccess(loops));
Extra practiceGroup fields by affinity

Write groupFields(fields, aff, t). Two fields belong to the same group if their affinity (from coAccess) is at least t, or if they are linked through other fields that way. Return the groups as arrays, in the order of fields, each group’s members also in that order.

function groupFields(fields, aff, t) {
  // your code here
  return [fields];
}

What you learned

  • A hot/cold split moves rarely used fields out of the hot loop’s way. Code that wants a whole record pays by looking in two places.
  • Field affinity measures which fields are used together: for each pair, add the runs of every loop using both. Hotness is the same count per field.
  • SoA is the finest split: each field is its own column, and a loop loads only what it touches.
  • Viscosity’s real per-field decision is typed or plain: Float64Array for fields proven numeric, plain arrays for the rest. It doesn’t group fields by affinity.

Check your understanding

0/6
  1. 1.

    A movement loop reads x, y, vx, vy of every entity; health and name are read once per game. What does a hot/cold split do?

  2. 2.

    Loop A (runs 500 times) uses x and vx. Loop B (runs 20 times) uses x and hp. Loop C (runs 20 times) uses vx and x. What is the affinity of x and vx?

  3. 3.

    How does Viscosity split fields?

  4. 4.

    In mixed_columns.js, which fields get Float64Array columns?

  5. 5.

    Why is SoA called the finest possible hot/cold split?

  6. 6.

    Where would field affinity matter most for a layout?

References

  1. [1] Sergey Slotin. Cache Lines, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/cache-lines/ (checked 2026-10-31)
  2. [2] Camillo Bruni (2017). Fast properties in V8. V8 blog (v8.dev), 30 August 2017. v8.dev/blog/fast-properties (checked 2026-10-31)
  3. [3] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)