Viscosity
Review
Part II · How machines run code · Day 6

24. Benchmarking honestly

A stopwatch around a loop feels like science, but it measures the wrong code, can be fooled by the compiler, and lies on a busy machine. Here is how this book’s harness avoids each trap, and why every number you saw deserves your trust.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain JIT warm-up and why a benchmark must discard its first runs.
  • Explain dead-code elimination and how a sink prevents it.
  • Summarize noisy timings with the median and p95 instead of the mean.
  • Explain why each benchmark case runs in its own fresh process.

A stopwatch is not enough

Here is the benchmark almost everyone writes first:

const t0 = performance.now();
sumAll(xs);
const t1 = performance.now();
console.log(t1 - t0, 'ms');

It looks reasonable. It is wrong in four separate ways. It times the function the very first time it runs, before the engine has optimized it. It throws the result away, so a clever compiler might skip the work. It trusts one measurement on a machine that is also doing a hundred other things. And it may run straight after some other benchmark that left the engine in a strange state.

This chapter takes the four problems one at a time, using the real harness behind every chart in this part of the book.

Intuition

Timing a runner on their first lap of the day, in the rain, while they’re still tying their shoes, tells you little about how fast they run. You let them warm up, time many laps, and report a typical lap, not the one where they tripped.

Warm-up: time the code that really runs

A JavaScript engine doesn’t run your function the same way every time. V8 first compiles all code to bytecode and interprets it. While it runs, V8 records how the program behaves, including object shapes and types. Functions that get hot are compiled by faster tiers (Sparkplug, then the optimizing compilers Maglev and TurboFan), which use that feedback to generate specialized machine code[1]. That code is speculative: if an assumption breaks, V8 throws it away and falls back, which is called deoptimization[1].

So the first calls of a function measure the interpreter and the compilers, not your loop. The harness runs the work for 300 ms first, and at least five times, before recording anything. Step through its core:

Step throughInside the harness’s measure()
function measure(run) {
let sink = 0;
const end = now() + 300; // warm up
while (now() < end) sink += run();
const ts = [];
for (let s = 0; s < 30; s++) {
const t0 = now();
sink += run();
ts.push(now() - t0);
}
ts.sort((a, b) => a - b);
return { median: q(ts, 0.5),
p95: q(ts, 0.95), sink };
}
Bar heights show how long each run took (illustrative, not measured): warm-up runs on the left get faster, samples on the right are mostly equal with one slow outlier.WARM-UP (not recorded)SAMPLESheights illustrative
measure() gets the work to time as a function, run. Every run returns a number, which goes into sink. Hold that thought.
1/6
Definition

JIT warm-up is the period in which code gets faster as the engine observes it and compiles it with better tiers. A benchmark that wants steady-state speed must run the code until it is warm, and only then start measuring.

Dead code: make the result matter

Compilers delete work whose result nobody uses. It’s a standard optimization called dead-code elimination; LLVM, for example, ships several passes that do it[2]. A loop whose answer is thrown away is a perfect candidate, and an optimizing JIT that removes it would make your benchmark report a near-zero time for a loop that never ran.

The harness’s defence is simple. Every kernel returns a number. Every returned number is added into a variable called sink, and at the end of each case the sink is printed. Now the work has an observable result, so it can’t be deleted:

let sink = 0;
function timeIt(run) {
  const t0 = performance.now();
  sink += run(); // keep the result
  return performance.now() - t0;
}
// ...later: print sink
Definition

Dead-code elimination is a compiler deleting computations whose results are never used. A sink is a variable that collects every result and is used at the end, so the measured work stays observable.

Median and p95: summarize honestly

Even warm code doesn’t take the same time twice. A garbage-collection pause, another program, or the operating system can make one sample much slower. Look what one bad sample does to the mean:

Try itOne outlier, two summaries
const ts = [2.1, 2.0, 2.2, 2.0,
  30, 2.1, 2.3];
let sum = 0;
for (const t of ts) sum += t;
console.log('mean', sum / ts.length);

const s = [...ts].sort((a, b) => a - b);
console.log('sorted', s);
console.log('median', s[3]);

The harness takes 30 samples, sorts them, and reports two numbers. The median (the 50% point) says what a typical run costs. The p95 (95% of samples were at most this slow) says how bad the slow end gets. Each sample also lasts at least 10 ms, because one call of a fast loop is too short to time: browsers deliberately round performance.now() to 100 microseconds, or 5 in specially isolated pages[3].

Definition

The median of a set of timings is the middle one once sorted. The p95 (95th percentile) is the value that 95% of timings are at or below. Both ignore a few wild outliers that would drag the mean around.

One fresh process per case

The last trap is the sneakiest. V8’s optimized code is specialized for the shapes and types it has seen[1]. Suppose the get-objects suite ran every layout in one process. The shared consume(p) function would see object literals, then instances of one class, then of another: three different shapes. Its feedback becomes mixed, so each layout’s time would depend on which layouts ran before it.

Left: in one shared process, the function consume receives objects of three different shapes, one per layout, so its feedback is mixed. Right: in three separate processes, each copy of consume only ever sees one shape.One shared processobject literalclass Pclass Kconsume(p)sees 3 shapes: mixedOne process per caseobject literalconsume(p)fresh process: 1 shapeclass Pconsume(p)fresh process: 1 shapeclass Kconsume(p)fresh process: 1 shape
Isolation keeps one layout’s history out of another’s timing. If every layout ran in one process (left), consume would see three shapes and the measurements would depend on the order. One fresh process per case (right) gives each layout a clean engine.

So the harness never runs two cases in the same process. Its driver starts a brand-new Node process for every combination of suite, layout and size:

for (const layout of LAYOUTS) {
  const out = execFileSync(
    process.execPath,
    ['case.ts', suite, layout, n],
  );
  rows.push(JSON.parse(out));
}

A fresh process also starts with a fresh heap, so garbage left by one layout can’t trigger a collection during the next. The in-browser LiveBench in chapter 23 does the same with a fresh Web Worker per layout.

Definition

Benchmark isolation means running each measurement in a fresh process (or worker), so that type feedback, compiled code and heap state from one case can’t affect another.

Noise is normal

This book’s results came from a shared cloud machine, and the results file says so: expect noise, and compare ratios, not absolute times. A ratio like “SoA was about 4× faster” survives a slower machine; “1.7 ns” does not. When a result surprises you, rerun it, and look at the p95 next to the median: a wide gap means the run was noisy.

Under the hood

This harness is more than a teaching aid. The hand-written layouts it measures are the baselines that Viscosity’s generated code will be compared against. When the compiler claims “this program should be SoA”, the claim is checked with exactly these rules: warm, sunk, summarized by median and p95, one process per case. An optimizer is only as honest as the benchmark that judges it.

Your turn

The core exercise hands you the broken stopwatch from the start of the chapter. Fix it.

ExerciseFix the broken benchmark

bench(fn, now) times the function fn using the clock function now, but it makes the classic mistakes. Fix it so that it: calls fn 3 times to warm up (without timing them), then takes 9 timed samples (one fn() call each, now() before and after), adds every result of fn() into a sink, and returns { median, sink }, where median is the middle of the 9 sample times.

function bench(fn, now) {
  const t0 = now();
  fn();
  const t = now() - t0;
  return { median: t, sink: 0 };
}
Extra practiceCompute p95 like the harness

Write p95(xs) the way the harness does. Sort a copy of xs numerically. The 95% position is pos = (n - 1) * 0.95. If pos falls between two slots, interpolate: s[lo] + (s[hi] - s[lo]) * (pos - lo), where lo and hi are pos rounded down and up. Don’t change xs.

function p95(xs) {
  // your code here
}

console.log(p95([2, 1]));

What you learned

  • JIT warm-up: V8 interprets first and optimizes hot code later, so warm up before measuring.
  • Dead-code elimination can delete work whose result is unused; a sink keeps the result observable.
  • Summarize many samples with the median (typical) and p95 (slow end), not one run or the mean.
  • Benchmark isolation: one fresh process per case, so feedback and heap state don’t leak between layouts.
  • On shared machines, trust ratios and look at the spread.

That’s the end of Part II. Next comes Part III: you’ll start building Drip, your own programming language, one piece at a time.

Check your understanding

0/6
  1. 1.

    A benchmark times the first call of a function and reports it. What is it mostly measuring?

  2. 2.

    A benchmark computes sum(xs) a million times but never uses the result. What might an optimizing compiler do?

  3. 3.

    Samples (ms): 2, 2, 2, 2, 2, 2, 50. Which summary best describes a typical run?

  4. 4.

    Why does the harness run each (suite, layout, size) case in its own process?

  5. 5.

    In a browser, why can’t you time one 50-microsecond loop run with performance.now()?

  6. 6.

    This book’s results came from a shared cloud machine. What should you trust most?

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)
  2. [2] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
  3. [3] MDN contributors. Performance: now() method. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/API/Performance/now (checked 2026-10-31)