Viscosity
Review
Part VII · Building Viscosity · Day 26

103. Generating SoA storage

Once the plan says soa, Viscosity has to write the storage itself: a small class with one typed array per field, a push that looks like Array’s, and a way to grow. Here is that class, line by line, exactly as the compiler emits it.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Read the container class Viscosity generates, and say what each member is for.
  • Explain which fields get a `Float64Array` column and which get a plain array, and why.
  • Trace a container growing as `push` is called.
  • Write a small SoA container with `push` and `grow` yourself.

Where do the columns live?

The planner has said soa. Now the compiler has to change this line:

const ps = [];

into storage for one array per field. Viscosity’s answer is to write a small class at the top of your file, and replace the empty array with an instance of it:

const ps = new __Vsc_ps();

Everything about the new layout lives inside that class. The rest of the program talks to it through push, length, and one column per field, which chapter 104 wires up.

Intuition

A paper register lists one visitor per row. A spreadsheet of the same visits keeps one column per question: every name in column A, every arrival time in column B. To add a visitor you write one cell in each column, on the same row. When the sheet runs out of rows, you get a bigger sheet and copy the columns across.

The generated class

Here is what Viscosity emits for ps, whose records are { x, vx }. The compiler writes each member on one long line; it is reformatted here to fit the page, with nothing else changed:

// Generated by Viscosity: "ps" stored as
// a struct of arrays (fields x, vx).
class __Vsc_ps {
  constructor(cap = 16) {
    this.length = 0; this.cap = cap;
    this.x = new Float64Array(cap);
    this.vx = new Float64Array(cap);
  }
  static of(...v) {
    const c = new __Vsc_ps(
      Math.max(16, v.length / 2));
    for (let i = 0; i < v.length; i += 2)
      c.push(v[i + 0], v[i + 1]);
    return c;
  }
  push(v0, v1) {
    const n = this.length;
    if (n === this.cap) this.grow();
    this.x[n] = v0; this.vx[n] = v1;
    return (this.length = n + 1);
  }
  grow() {
    const cap = (this.cap *= 2); let t;
    t = new Float64Array(cap);
    t.set(this.x); this.x = t;
    t = new Float64Array(cap);
    t.set(this.vx); this.vx = t;
  }
}

Member by member:

  • constructor sets length (how many elements are in use) and cap (how many slots each column has), and makes the columns. A new Float64Array is filled with zeros[2].
  • push(v0, v1) takes one value per field, in the shape’s field order, writes them into the same slot of every column, and returns the new length, just as Array.prototype.push does. A program that used ps.push(...)’s result still gets the same number.
  • grow() doubles cap, and for each typed column makes a bigger Float64Array, copies the old values in with set, and swaps it in. Chapter 105 explains why doubling makes push cheap on average.
  • static of(...v) replaces an array literal. It takes every field of every record, flattened, and pushes them in groups of two.
Definition

Column storage keeps one array per field: every x together, every vx together, with element i’s fields at index i of each column. An SoA container is the object that holds those columns, with the element count and the methods the program still needs (push, length). Viscosity generates one container class per transformed collection, named __Vsc_ plus the collection’s name.

Watching it grow

Step through the first seventeen pushes of for (let i = 0; i < N; i++) ps.push(i, 1):

Step throughA container growing
push(v0, v1) {
const n = this.length;
if (n === this.cap) this.grow();
this.x[n] = v0; this.vx[n] = v1;
return (this.length = n + 1);
}
grow() {
const cap = (this.cap *= 2); let t;
t = new Float64Array(cap);
t.set(this.x); this.x = t;
t = new Float64Array(cap);
t.set(this.vx); this.vx = t;
}
The x and vx Float64Array columns, their used slots, and the container's length and caplength 0cap 16x16vx16
const ps = new __Vsc_ps() makes an empty container: length 0, room for 16 (cap), and one Float64Array of 16 zeros per numeric field.
1/6

The check n === this.cap runs once per push. Most of the time it is false, and push is a few plain writes. The copying happens only on the 17th push, the 33rd, the 65th, and so on.

Typed columns and plain columns

A Float64Array stores raw 64-bit doubles, side by side, with no object per value[1]. That is the whole point. But it only stores numbers. Anything else you write into it is converted:

Try itWhat a Float64Array stores
const col = new Float64Array(4);
console.log(col); // zero-filled

col[0] = 2.5;
col[1] = -0;
col[2] = '7';   // converted
col[3] = 'abc'; // converted
console.log(col[0]);
console.log(Object.is(col[1], -0));
console.log(col[2], typeof col[2]);
console.log(col[3]);

// Writing past the end does nothing:
col[4] = 9;
console.log(col.length, col[4]);

// A plain array takes anything, and
// lengthens by itself:
const arr = [];
arr[0] = 'abc';
arr[3] = null;
console.log(arr.length, arr[0]);

So a typed column is only correct for a field that is provably always a number. Every other field gets a plain JavaScript array. Here is the constructor Viscosity emits for the corpus file mixed_columns.js, whose records are { id: 'e' + i, hp: 100, x: i, tag: … }:

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

push writes all four columns. grow only touches hp and x: a plain array lengthens by itself when you write past its end, so it never needs copying.

Doubles also keep the awkward values exactly. literal_init.js stores { x: -0, y: NaN } and checks, after compiling, that Object.is(pts[2].x, -0) and Number.isNaN(pts[2].y) are still true. They are: a Float64Array holds any IEEE 754 double, -0 and NaN included.

Try it: this collection has a string field, a numeric field and a field that is sometimes null. Open the compiled code and find the columns.

PlaygroundWhich columns are typed?

Names that would clash

Columns are named after their fields, so ps.x is the x column. But the container already has members of its own. A record with a field called length or push would overwrite them. The emitter therefore renames any field that is a reserved name (length, push, grow, of, constructor, prototype, get, cap, data) or isn’t a plain identifier, to f plus its position plus _. For records { length: i, cap: 1, 'a-b': 2 }:

this.f0_ = new Float64Array(cap);
this.f1_ = new Float64Array(cap);
this.f2_ = new Float64Array(cap);

The program’s ps[i].length becomes ps.f0_[i], and nothing outside the generated code ever sees the new name. A new name could still collide with a real field: records { length: i, f0_: 2 } would put both in f0_. So a renamed column gets another _ until its name is free: length becomes f0__, and the real f0_ keeps its own column.

Under the hood

ISPC does the same job inside a compiler for C-like code. Declaring soa<8> Point pts[...], for struct Point { float x, y, z; }, makes the compiler store the array as if each block were struct { uniform float x[8], y[8], z[8]; }[3]: a generated container, as here, but with the columns cut into blocks of eight. Viscosity’s SoA columns run the full length of the collection, and AoSoA (chapter 107) is its blocked version. One difference matters for JavaScript: Viscosity can only use a typed column when it has proved the field is a number, because a JavaScript field has no declared type.

Your turn

ExerciseA tiny SoA container

Write a class Ps for records { x, vx }. new Ps(cap) (default 2) sets length to 0, cap to cap, and makes x and vx as Float64Arrays of cap slots. push(x, vx) writes both into slot length, growing first if the columns are full, and returns the new length. grow() doubles cap and replaces each column with a bigger copy.

class Ps {
  constructor(cap = 2) {
    this.length = 0;
    this.cap = cap;
    this.x = new Float64Array(cap);
    this.vx = new Float64Array(cap);
  }
  push(x, vx) {
    const n = this.length;
    this.x[n] = x;
    this.vx[n] = vx;
    this.length = n + 1;
  }
}
Extra practiceReplacing an array literal

Add static of(...v) to Ps. It receives the fields of every record flattened, x0, vx0, x1, vx1, …, and returns a new Ps holding those records, created with room for at least 16 (Math.max(16, v.length / 2)). Also include the class from the core exercise.

class Ps {
  constructor(cap = 2) {
    this.length = 0;
    this.cap = cap;
    this.x = new Float64Array(cap);
    this.vx = new Float64Array(cap);
  }
  static of(...v) {
    return new Ps();
  }
}

What you learned

  • Column storage keeps one array per field; element i is index i of every column.
  • The SoA container is a generated class: length, cap, one column per field, push, grow and of.
  • push takes one value per field and returns the new length, like Array.prototype.push. When the columns are full, grow doubles them and copies with set.
  • Provably numeric fields get Float64Array columns; every other field gets a plain array, which grows by itself.
  • Fields whose names clash with the container’s own members are renamed f0_, f1_, … (with an extra _ if a real field already has that name), invisibly to the program.

Check your understanding

0/6
  1. 1.

    What does Viscosity put in place of const ps = []; for a collection planned as SoA?

  2. 2.

    The generated push(v0, v1) returns…

  3. 3.

    A container has cap 16 and length 16. What does the next push do first?

  4. 4.

    Which records get only plain-array columns, no Float64Array at all?

  5. 5.

    Why doesn’t grow() copy plain-array columns?

  6. 6.

    A record has fields { length, x }. What are the columns called?

References

  1. [1] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)
  2. [2] MDN contributors. TypedArray. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray (checked 2026-10-31)
  3. [3] The Intel ISPC project. Intel ISPC User’s Guide: Structure of Array Types. ispc.github.io documentation. ispc.github.io/ispc.html (checked 2026-10-31)