Viscosity
Review
Part VII · Building Viscosity · Day 26

102. Opting in with @viscosity

Sometimes you know what the compiler can only guess. One comment above a declaration tells Viscosity to leave a collection alone, or to transform it whatever the cost model says, and the program stays plain JavaScript either way.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Write the three `@viscosity` annotations and say exactly which declarations each one reaches.
  • Predict what an annotation can override (the cost model) and what it can’t (safety).
  • Use opt-in mode to transform only the collections you mark.
  • Avoid the silent mistakes: wrong case, a blank line, a trailing comment above the declaration.

You know something it doesn’t

The cost model guesses. Sometimes you know better. The test data has 10 particles, but in production there are two million. Or you’re chasing a bug and want ps to stay exactly as you wrote it. Or the collection is built in a function, so the size is unknown, and you know it is tiny.

In each case the fix is one comment:

// @viscosity aos
const ps = [];
Intuition

A sticker on a moving box: “fragile”, or “open first”. The movers still decide how to load the van, but for this box they follow the sticker. And if you never hire movers, the sticker does no harm.

Three words

There are three annotations, one per layout:

// @viscosity aos       keep objects
// @viscosity soa       struct of arrays
// @viscosity aosoa(8)  AoSoA, blocks of 8

The JavaScript plugin finds them with one regular expression, in frontend-js/src/index.ts (one line in the source, split here):

/@viscosity\s+(aosoa|soa|aos)\b
  (?:\(([^)]*)\))?/

That is: the word @viscosity, some space, one of the three layout names, a word boundary, and optionally whatever is in parentheses, the block size. The planner checks that it is a number later. The \b stops @viscosity soap from being read as soa. Try the pattern on a few comments:

Try itThe annotation pattern
// The pattern from frontend-js.
const ANN = new RegExp(
  '@viscosity\\s+(aosoa|soa|aos)\\b'
  + '(?:\\(([^)]*)\\))?');

const tests = [
  ' @viscosity soa',
  ' @viscosity aosoa(4)',
  ' @viscosity aosoa(x)',
  ' @viscosity aosoa',
  ' @viscosity SoA',
  ' @viscosity soap',
  ' keep this one @viscosity aos!',
];
for (const t of tests) {
  const m = ANN.exec(t);
  console.log(JSON.stringify(t), '->',
    m ? m[1] + ' ' + (m[2] ?? '-')
      : 'no match');
}
Definition

An annotation is a comment (or other marker) that gives the compiler extra instructions without changing what the program means. Viscosity’s annotations name a layout for one collection. A program with annotations is still ordinary JavaScript, and runs the same with or without Viscosity.

Which declaration?

A comment has no idea which variable it is about. While parsing, acorn reports every comment with the line it ends on. The plugin then looks at that line of the source. If the comment is alone on its line, it applies to every declaration on the next line. If code comes before it (a trailing comment), it applies only to declarations on its own line (simplified):

for (const cm of comments) {
  const m = ANN.exec(cm.value);
  if (!m) continue;
  // nothing but the comment on its line?
  const own = /^\s*(\/\/|\/\*|\*)/
    .test(srcLines[cm.line - 1]);
  for (const [jb, id] of bindingOf) {
    const line = jb.decl?.loc?.start.line;
    if (own ? line === cm.line + 1
            : line === cm.line)
      annotations.set(id, {
        layout: m[1],
        blockSize: m[2] ? Number(m[2])
          : undefined,
        line: cm.line });
  }
}

Step through a file with four comments, three of which look like annotations. (The full program also fills each array with 100 points and loops over them.)

Step throughAttaching annotations to declarations
// @viscosity aos
const ps = [];
const qs = []; // @viscosity soa
// @viscosity SoA
const rs = [];
/* @viscosity aosoa(4) */
const ts = [];
Each comment, whether it matches, and which declaration it attaches toLINECOMMENTMATCHAPPLIES TO1@viscosity aos3@viscosity soa4@viscosity SoA6@viscosity aosoa(4)
While parsing, acorn hands Viscosity every comment, line or block, with the line it ends on. Nothing has been attached yet.
1/7
Pitfall

Annotations fail silently. @viscosity SoA is just a comment, and so is an annotation with a blank line before its declaration. Neither gives a warning. The placement rule is strict in the other direction too. A comment at the end of line 3 annotates line 3 and nothing else, so const qs = []; // @viscosity aos keeps qs but says nothing about a declaration on line 4. To annotate line 4, the comment must sit alone on line 3. (Earlier versions let a trailing comment reach the next line as well; a corpus test now pins the rule down.) Run viscosity explain and look for “annotated @viscosity … at line N” to check that an annotation landed where you meant.

What an annotation can and can’t do

Look back at the planner’s ladder from chapter 101. The annotation is the second rung: below safety, above every cost rule.

aos stops a transform. This program becomes SoA. Add // @viscosity aos on the line above const ps, and watch the explanation and the compiled code change. Then run both:

PlaygroundStop the transform

soa skips the cost rules. The corpus file literal_init.js has three points. The cost model would keep them, but the annotation wins, and the size is only reported:

pts (line 3) {x, y} → struct of arrays
  · annotated @viscosity soa at line 2
  · loop at line 5 in <module>()
    touches 1 of 2 fields (x)
  · typed Float64Array columns for
    all 2 fields
  · about 3 elements
  · 1 of 3 element accesses proven
    in bounds

It even skips the numeric rule. An annotated collection with only a string field gets a plain-array column for it.

aosoa(N) has two conditions. Every field must be provably numeric, because the whole block is one Float64Array. Otherwise the annotation is refused with a reason:

es (line 2) {x, id} → kept as array of
  objects
  · annotated aosoa, but fields id may
    hold non-numbers

And N must be a power of two, at most 65536 (the default is 8). The planner checks this too, and keeps the collection with a reason rather than stopping the compile:

ps (line 2) {x, y} → kept as array of
  objects
  · annotated aosoa(6), but the block
    size must be a power of two (2, 4,
    8, 16, ...)

Chapter 107 explains why.

Nothing overrides safety. Annotate a collection and then pass it to console.log, and you still get “unsafe: the array is passed to a method call”. The annotation tells the cost model what to think. It can’t make an observable change invisible.

Opt-in mode

By default Viscosity decides everything, and annotations adjust it. With --mode opt-in it flips: only annotated collections are transformed. The corpus file opt_in_mode.js has two identical arrays, one marked:

// @viscosity soa
const marked = [];
const unmarked = [];
$ viscosity explain opt_in_mode.js \
    --mode opt-in
marked (line 3) {v} → struct of arrays
  · annotated @viscosity soa at line 2
  …
unmarked (line 4) {v} → kept as array
  of objects
  · opt-in mode: not annotated

Opt-in mode suits adopting Viscosity a little at a time, or a team that wants every layout change to be visible in the source and in code review. Python’s default is close to opt-in already: there the planner keeps lists unless you annotate them, for the reasons in chapter 101.

Under the hood

Compilers have long taken hints this way. Clang accepts #pragma clang loop vectorize(enable) before a loop, and can be told a vector width, overriding its vectorizer’s cost model for that one loop[1]. Intel’s ISPC goes further and puts the layout in the type: soa<8> Point pts[...] declares an array stored eight elements at a time, field by field, while code still writes pts[10].x[2]. Viscosity sits between the two. Its hint names a layout, like ISPC’s, and sits on one declaration, like a pragma on one loop. But it lives in a comment, so every JavaScript engine and tool reads the program exactly as before.

Your turn

ExerciseAttach annotations to declarations

Write findAnnotations(src). Split src into lines (line 1 is the first). A line holds a comment if it contains //; the comment is the text after it. If the comment matches /@viscosity\s+(aosoa|soa|aos)\b(?:\((\d+)\))?/, it applies to declarations on one line: if nothing but spaces comes before the //, the next line; otherwise (a trailing comment) its own line only. A declaration is a line matching /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)/. Return an object mapping each annotated name to its layout: 'aos', 'soa', or 'aosoa(N)' with N defaulting to 8.

function findAnnotations(src) {
  const out = {};
  return out;
}
Extra practiceRefuse a bad AoSoA annotation

Write checkAnn(ann, fields, num). ann is { layout, block } (block may be undefined), fields lists the field names, and num is a Set of the provably numeric ones. Return null if the annotation is usable. For aosoa, return 'fields id, tag may hold non-numbers' (the non-numeric fields in order, joined by , ) if any field isn’t numeric, or else 'block size must be a power of two' if the block (default 8) isn’t one.

function checkAnn(ann, fields, num) {
  return null;
}

What you learned

  • An annotation is a comment with instructions for the compiler. Viscosity has three: @viscosity aos, soa and aosoa(N).
  • A matching comment alone on its line applies to the next line; a trailing comment applies only to its own line. Wrong case or a blank line, and it silently does nothing.
  • Annotations sit below safety and above the cost model: aos stops a transform, soa skips the cost rules, and nothing forces an unsafe change.
  • aosoa(N) needs every field numeric, and N a power of two up to 65536.
  • --mode opt-in transforms only annotated collections.
  • Check with viscosity explain: every annotation that landed is named in the reasons.

Check your understanding

0/6
  1. 1.

    Where must // @viscosity soa go to annotate const ps = []; on line 10?

  2. 2.

    A collection is passed to console.log and annotated // @viscosity soa. What does explain say?

  3. 3.

    What does // @viscosity SoA do?

  4. 4.

    An annotated @viscosity soa collection has 3 elements. What does the size rule do?

  5. 5.

    Which annotation is refused for { x, id }, where id is a string?

  6. 6.

    With --mode opt-in, what happens to an unannotated collection that would win easily as SoA?

References

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