Viscosity
Review
Part VII · Building Viscosity · Day 28

109. Profile-guided optimization

Some questions a compiler can’t answer by reading the code, like “how many elements will this array hold?”. So Viscosity runs the program once, counts, and compiles with the answer.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what profile-guided optimization is and which question it answers for Viscosity.
  • Describe how `viscosity profile` instruments a program without changing what it does.
  • Read how a measured size replaces the static estimate in the cost model.
  • Say why a profile can cost Viscosity speed but never correctness.

A question the code can’t answer

In chapter 101 the cost model got one rule that has nothing to do with safety: below 64 elements, a new layout isn’t worth it. That rule needs a number. For most programs the analysis can count, because the elements are pushed in a loop with a constant bound. Here it can’t:

const ps = [];
function spawn(x) {
  ps.push({ x, vx: 1 });
}
for (let i = 0; i < 20; i++) spawn(i);
for (let f = 0; f < 100; f++) {
  for (let i = 0; i < ps.length; i++) {
    ps[i].x += ps[i].vx;
  }
}
console.log(ps[0].x);

The estimate counts literal elements, and pushes in top-level loops with a constant trip count. This push sits inside spawn, so the count depends on how often spawn is called, and the analysis gives up. This is what viscosity explain says (long lines wrapped):

ps (line 1) {x, vx} → struct of arrays
  · loop at line 7 in <module>() touches
    2 of 2 fields (vx, x)
  · typed Float64Array columns for all 2
    fields
  · size unknown statically
  · 2 of 3 element accesses proven in
    bounds

“Size unknown”, so the threshold can’t apply, and Viscosity transforms 20 elements into columns. It is safe, but pointless. You can read this program and see the 20. A real program’s count comes from a file, a network or a user, and nobody can read it off the source.

Intuition

A tailor can guess your size from your age and height, or can measure you. Measuring takes a minute and gives the right answer, for you, on the day you were measured.

Run it, count, compile

Viscosity answers the question by running the program. The command is viscosity profile, and the output goes back in with --profile:

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

Both outputs are real, from runs for this chapter. Step through the whole loop:

Step throughThe profile-guided loop
Five stages: the source is instrumented, run once, its collection sizes are written to a profile, and the compiler reads the profile to decide the layout1 sourcespawn.js2 instrument__vsc_track("ps", [])3 run oncefresh node process4 profilep.json5 compileexplain --profile p.jsonthe same source,compiled with the profileORIGINAL PROGRAMconst ps = [];function spawn(x) { ps.push({ x, vx: 1 }); }for (… < 20 …) spawn(i);without a profile: ps → struct of arrays (size unknown)
The program. ps grows inside spawn(), which a loop calls. The analysis cannot count calls through a function, so explain says “size unknown statically” and, with nothing to compare against the 64-element threshold, Viscosity transforms it.
1/6
Definition

Profile-guided optimization (PGO) is compiling with measurements taken from real runs of the program, instead of relying only on what can be worked out from the source. The measurements are the profile. The usual loop is: instrument, run on typical input, record, recompile.

Instrumentation

To count, viscosity profile builds a slightly different program. It finds the collections the same way the compiler does (chapter 96), and wraps the expression that creates each one. From cli/src/profile.ts, simplified:

function instrument(src, plugin) {
  const m = plugin.lower(src).module;
  const cs = analyze(m).collections;
  // Edit from the end: offsets stay valid.
  cs.sort((a, b) =>
    b.init.src.start - a.init.src.start);
  let out = src;
  for (const c of cs) {
    const { start, end } = c.init.src;
    out = out.slice(0, start) +
      `__vsc_track("${c.name}", ` +
      out.slice(start, end) + ')' +
      out.slice(end);
  }
  return PRELUDE + out;
}

So const ps = []; becomes const ps = __vsc_track("ps", []);. The prelude defines the tracker and an exit hook:

const __vsc_seen = [];
function __vsc_track(name, arr) {
  __vsc_seen.push([name, arr]);
  return arr;
}
process.on('exit', () => {
  const p = {};
  for (const [n, a] of __vsc_seen) {
    const old = p[n]?.maxLength ?? 0;
    p[n] = {
      maxLength: Math.max(old, a.length) };
  }
  writeFileSync(
    process.env.VSC_PROFILE_OUT,
    JSON.stringify(p, null, 2));
});

__vsc_track returns the same array it was given. Nothing is copied or wrapped, so every push, every === and every console.log behaves as before. A profile of a program that behaves differently would be a profile of the wrong program.

The profile run is the original program, run once in its own Node process with its output thrown away. Nothing is timed: it counts.

Definition

Instrumentation is code added to a program to record what it does while it runs: counts, sizes, branches taken, time spent. Good instrumentation changes nothing the program can observe, and costs as little as possible.

Try it in the browser. This is the same instrumentation, done by hand:

Try itInstrumentation by hand
// The tracker from the prelude.
const seen = [];
function track(name, arr) {
  seen.push([name, arr]);
  return arr; // the same array
}
function report() {
  const p = {};
  for (const [n, a] of seen) {
    const old = p[n]
      ? p[n].maxLength : 0;
    const len = a.length;
    p[n] = {
      maxLength: Math.max(old, len),
    };
  }
  return p;
}

// The program, instrumented:
const ps = track('ps', []);
function spawn(x) {
  ps.push({ x, vx: 1 });
}
const waves = 20; // try 5000
for (let i = 0; i < waves; i++) {
  spawn(i);
}
for (let i = 0; i < ps.length; i++) {
  ps[i].x += ps[i].vx;
}
console.log(ps[0].x);

// What the exit hook would write:
const prof = report();
console.log(JSON.stringify(prof));
const n = prof.ps.maxLength;
console.log(n < 64
  ? 'kept as objects (measured)'
  : 'struct of arrays (measured)');

Where the number goes

The profile reaches exactly one line of the planner. From core/src/plan.ts:

const measured =
  opts.profile?.[c.name]?.maxLength;
const size = measured ?? c.sizeEstimate;
if (size !== null && size < opts.minSize) {
  // keep: "about N elements (measured)"
}

A measurement, when there is one, beats the estimate. And the report says which it used: “(measured)” on a kept collection, “(measured by profiling)” on a transformed one. Profile the same program with 5000 calls to spawn and the verdict flips:

ps (line 1) {x, vx} → struct of arrays
  · …
  · about 5000 elements (measured by
    profiling)

Look at where that line sits. By the time the planner reads the profile, the collection has already passed every safety check, has a numeric field, and has a loop over its fields. A profile can’t rescue an unsafe collection, because none of those checks look at it.

Pitfall

A profile describes one run. Profile with a tiny test input, deploy with a million elements, and Viscosity will have kept a collection as objects that should have been columns. Profile with the input you care about. Note that viscosity profile runs the program with no command-line arguments, so the program must produce its typical workload on its own.

Why a wrong profile is only slow

Here is the property that makes Viscosity’s PGO calm. Both layouts were already proven to behave identically (chapter 99). The profile only chooses between two correct programs. So the worst a stale or unrepresentative profile can do is pick the slower of two right answers.

There are rough edges, visible in the code. Profiles are keyed by the array’s variable name, so two collections both called ps in different functions share one entry. And lengths are read once, at exit. For a collection Viscosity can transform that is the same as the maximum, because such a collection only ever grows: only .push and .length are allowed. The profile type also has room for per-field read counts, but nothing records or reads them yet.

Under the hood

A JIT does profile-guided optimization all the time, during the same run. V8 records feedback about the shapes and types each operation sees, and its optimizing compilers use it to generate specialized code; if an assumption later breaks, the code is thrown away and execution deoptimizes[1]. That is the price of using a profile for decisions that affect correctness: every assumption needs a guard. Viscosity’s profile is collected ahead of time and only picks a size threshold, so its output needs no guard at all.

Your turn

ExerciseInstrument an array factory

Write makeTracker(). It returns an object with two methods. track(name, arr) remembers arr under name and returns the same array. profile() returns an object mapping each name to { maxLength }: the largest .length, **read when profile() is called**, among all arrays tracked under that name.

function makeTracker() {
  const seen = [];
  return {
    track(name, arr) {
      // your code here
      return arr;
    },
    profile() {
      return {};
    },
  };
}
Extra practiceThe size decision

Write sizeLine(est, measured, min), the size step of the planner. est is the static estimate (a number, or null if unknown); measured is the profiled length, or undefined if there is no profile. Use the measurement if there is one. If the size is known and below min, return { keep: true, text } with text about N elements (measured): below the MIN-element threshold (drop (measured) if the size was estimated). Otherwise return { keep: false, text } where text is size unknown statically, or about N elements, with (measured by profiling) added if measured.

function sizeLine(est, measured, min) {
  return { keep: false, text: '' };
}

What you learned

  • Profile-guided optimization compiles with measurements from a real run. Viscosity measures the one thing it can’t always compute: how big each collection gets.
  • Instrumentation wraps each collection’s creation in __vsc_track, which returns the same array, so the program behaves exactly as before. An exit hook writes the lengths to a JSON profile.
  • In the planner, measured ?? estimate: a measurement wins, and the report says “(measured)”.
  • The profile is read only after every safety check passes, so a bad profile can cost speed, never correctness. Profile with typical input.

Check your understanding

0/6
  1. 1.

    What does viscosity profile measure?

  2. 2.

    Which program does the profiling run execute?

  3. 3.

    In spawn.js, spawn is called 20 times. What changes when you compile with the profile?

  4. 4.

    A collection is passed to console.log (an escape). Its profile says it holds a million elements. What does Viscosity do?

  5. 5.

    Why does V8 need deoptimization while Viscosity’s PGO needs no guard?

  6. 6.

    Which is the best input to profile with?

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)