This repo's benches run on every PR via CodSpeed. This document explains what the numbers mean, why they don't match real wall-clock time, and how to read warnings from the CodSpeed dashboard.
Bench files live under packages/*/bench/*.bench.js and are driven by
vitest bench + @codspeed/vitest-plugin@^5. The full pipeline is in
.github/workflows/pr-checks.yml (jobs: codspeed-bench and
codspeed-walltime).
- We run CodSpeed in
mode: simulation— Cachegrind/Valgrind-based CPU simulation, not wall-clock timing. - Numbers like
19.8 msare modeled instruction-time on a reference CPU with a modeled cache hierarchy, not what a user's machine takes to run the bench. - The numbers are deterministic and reproducible (<1% run-to-run drift on most benches), so they're great for catching regressions.
- They are not honest wall-clock. For JIT-heavy JS loops the model inflates 30–100× vs production V8; for wasm decode kernels it's ~5–15×; for pure compute it's roughly 1×.
CodSpeed has two instruments: simulation (Cachegrind) and walltime
(real CPU, statistical sampling on macro-runners). We run both:
| Simulation | Walltime | |
|---|---|---|
| What it measures | Modeled instruction time | Real wall-clock |
| Determinism | <1% drift | 1–3% drift |
| Runner | Standard GHA runner | CodSpeed macro-runner |
| Cost | Free CI minutes | Macro-runner minutes |
| Honest about syscalls | No (excludes them) | Yes |
| Honest about JIT tier-up | No (depends on tier at bench time) | Yes |
| Good for | Regression detection on a PR | Absolute production-like timing |
Simulation is the blocking gate because regression detection is the primary goal — catching "this PR slowed openjpeg by 5%" matters more than knowing the exact ms a user's browser will take, and simulation's <1% determinism flags small algorithmic slips that walltime noise would hide.
Walltime runs as a second, advisory instrument (codspeed-walltime
job, continue-on-error: true) on CodSpeed macro runners — 16-core ARM64
bare-metal machines. It covers simulation's two blind spots: real-time
effects (branch prediction, actual caches) that instruction counting
models away, and the pure-JS packages (little-endian/big-endian)
where the no-JIT simulation model is furthest from production V8.
Walltime benches run packages sequentially (--concurrency 1) because
parallel processes contend for cores and add noise; simulation is immune
to contention so it keeps --parallel. Note the macro runners are ARM64:
walltime numbers are real milliseconds, but on different silicon than
most x86 production traffic.
Cachegrind disables V8's optimizing JIT — code runs in the interpreter (Ignition) or baseline JIT (Sparkplug) the whole time. The interpreter retires roughly an order of magnitude more low-level instructions per logical JS operation than TurboFan-optimized native code.
Specific patterns and their typical inflation factor vs production:
- Tight JS loops (e.g.,
big-endianbyte-swap loop): 30–100×. That's whybig-endian 16-bit + swap, 512×512shows ~73 ms here but is ~1–3 ms in a real browser. - Wasm decode kernels (charls, openjpeg, openjphjs, libjpeg-turbo): ~5–15×. Wasm runs under Valgrind without JIT but doesn't suffer the Ignition penalty as badly.
- Pure compute / native syscalls: ~1× or excluded entirely.
The codec packages each expose four kinds of benches per fixture:
The cost of new Decoder(); decoder.delete() in isolation. Includes
embind plumbing (makeClassHandle, RegisteredPointer_fromWireType,
finalizer setup), V8's object-handle allocation, and the wasm-side
constructor/destructor. Tracks lifecycle regressions.
A fresh decoder/encoder instance whose first .decode() / .encode()
call happens inside the bench body. Models frame 1 of a worker
session.
In practice "cold" here measures per-instance setup cost, not truly
cold-everything: the warmup of the sibling warmDec at module load
runs first, so by the time the cold bench fires the wasm heap is
already grown and the wasm decode path is already tiered. The
cold-vs-warm delta therefore isolates per-instance state cost
(internal working buffers, instance-local allocations), not
module-load cost.
A shared decoder/encoder that's been pre-warmed with 5 untimed
decode/encode iterations at module load. The bench body is the 6th+
call. Models frames 2..N of a worker session — the dominant case
for stack scrolling, since cornerstone3D's decodeJPEGLS.ts:73,
decodeJPEG2000.ts:68, and decodeJPEGBaseline8Bit.ts:61 all cache
the decoder on local.decoder and reuse it.
Bench bodies between cold and warm are identical code shape — the only difference is module-load state, so the delta is a clean "first-instance-call overhead" signal.
No decoder class — just a function call. These have no cold/warm split and no instantiation bench.
Calls dicomCodec.decode(bytes, info, uid) per transfer syntax. Under
CodSpeed each bench runs once, so each dispatcher bench measures the
first-call-per-UID cost — including wasm module instantiation
through the runProcess → initialize path in
packages/dicom-codec/src/codecs/codecFactory.js:80. That's why
dispatcher numbers are 12–32× larger than the corresponding raw codec
bench: the dispatcher pays the cold-start tax once per UID.
Some wasm decode benches fire syscalls during the bench window — mmap
for wasm heap growth, page faults on cold pages, futex for V8 worker
thread coordination. Cachegrind can't honestly model syscall cost
(its multiplier blows up), so CodSpeed excludes those from the headline
number and posts this warning.
What to do: nothing. The headline value is still your reliable regression signal — it's the instruction-counted decoder work alone. The "Xs of execution time" the warning mentions is Cachegrind's model producing nonsense for syscalls; not real elapsed time. The cold/warm split here keeps the warning from showing up on warm benches because the wasm heap is already grown and pages are already touched by the module-load warmup.
V8's wasm runtime hosts JIT-compiled wasm code in an anonymous function
chunk. Cachegrind has no symbol info for that JIT'd code, so the flame
graph collapses to one opaque box. You can't drill into "where inside
decode is the time spent" for wasm code — that's a fundamental
limitation of wasm-via-V8-via-Cachegrind. For wasm hotspot analysis,
profile the native build with perf / Instruments / VTune instead.
The instantiation bench measures whatever V8 tier the embind helpers
(makeClassHandle, RegisteredPointer_fromWireType, etc.) happen to
be in at bench time. If the bench file's module load calls the
constructor a few times (e.g., once per fixture for the cold decoder
instances), V8 has more samples to tier up the embind code before the
instantiation bench runs, and that bench reports a lower number. With
only one constructor call at module load, the instantiation bench runs
in baseline tier and reports higher. This is a Cachegrind artifact,
not a real production regression — V8 tiers up after a handful of
calls in any real app.
We've verified on three runs of identical source (commits a5b8ee6,
5ada4ee, fa2db51) that:
- 22 of 29 benches reproduce bit-exact across runs
- 6 drift ≤ 1.1% (measurement floor on µs-scale benches)
- The cold/warm split eliminated the previously noisy benches by pinning wasm allocator state via module-load warmup
If a single PR shows a < 5% delta on a noisy bench, look at two more runs before believing it. For the stable benches (which is most of them), a 3% delta is real signal.
For a wasm codec, follow the pattern in
packages/charls/bench/decode.bench.js:
- Read fixtures at module top, gated on
existsSync(distPath). - Create one cold decoder per fixture (constructed only — never call decode at module load).
- Create one shared warm decoder; loop
decode()5 times on it at module load using the largest fixture, so the wasm heap never needs to regrow when smaller fixtures hit the warm bench. - Pair each fixture:
bench("decode X — cold", ...)andbench("decode X — warm", ...)with identical bench bodies. - Include
bench("instantiate+destroy XDecoder", ...)for the lifecycle signal.
For a pure-JS package, just call the function — no cold/warm split, no instantiation bench.
If you're trying to understand a specific bench's behaviour, the flame-graph URL on codspeed.io shows the per-function breakdown (though wasm code resolves to anonymous chunks — see warnings above). Each bench file in this repo has a top-of-file comment with the codec-specific rationale.