the benchmark suite¶
cargo build --release --bin by
uv run --no-project --python 3.13 python scripts/native-bench/bench.py
the suite lives in scripts/native-bench/: bench.py stages and builds,
timer.py times, programs/ holds the benchmarks and programs.toml says what
each is for and what it is allowed to leave interpreted
it needs a release build — a debug by is too slow to measure anything with
— and it needs uv, which fetches both the interpreter and mypyc
the one thing to know¶
the ratio is the measurement. absolute times on this suite move by a factor
of four with machine load: cpython's own dot has read 47ms and 209ms on the
same laptop in the same week. no number here is reported from one build's clock
alone
and every row carries its own error bar. each benchmark is compiled twice,
independently, by the same compiler, and both builds are timed. two builds of one
program are one program — literally: the generated C is byte-identical once the
module name is normalised out — so whatever ratio the suite reports between them
is noise it invented. that is the noise column, and nothing smaller than a
row's own noise is a finding
how a run works¶
four builds of each benchmark, staged under four module names of their own:
| build | what it is |
|---|---|
cpython |
the source, interpreted |
by |
by compile |
control |
by compile again, separately |
mypyc |
mypyc, for scale |
the distinct names are what makes the method possible. an extension module's
init hook is found by name, so two of them cannot answer to mandel — under
mandel_by and mandel_control they can, and all four builds then live in one
process at once
and because they live in one process they can be interleaved. a run is a sequence of rounds, and in each round every build is timed once, in an order that rotates so none of them is always the one paying for a cold cache. a load spike therefore lands inside the same round as everything else, and mostly cancels when the round is turned into a quotient
the statistics¶
the median, throughout — never the mean, never the minimum
- the mean is moved by the one round that hit a scheduler
- the minimum is an extreme-value statistic. its expectation depends on how many rounds were run and on how quiet the machine happened to be, so a minimum is not comparable with another minimum, which is the only thing this suite is for
a ratio is paired: it is the median of the per-round quotients, not the quotient of the two medians. dividing one build's median by another's would let a spike that landed on only one of them through unchallenged
the interval on each ratio is the distribution-free one for a median, taken from the binomial tail. it is exact, assumes nothing about the shape of the noise, needs no resampling and no random numbers — so two readings of the same data agree. below nine rounds that interval degenerates to the range of everything seen, so nine is a floor the harness enforces rather than a default
how long a sample is¶
one sample is fifty milliseconds, and each build fills that for itself: it
calls bench() until the time is up, and reports the elapsed time divided by the
calls it got through. so the call count differs between builds, differs between
rounds, and is not decided before the run
the size is set by the scheduler rather than by the clock. perf_counter
resolves nanoseconds, but a sample the scheduler takes away comes back about a
ten-millisecond quantum late, and that is the error a sample has to be long
enough to swamp. at 50ms one such hit is a 20% outlier, which is the sort of
thing a median over 21 rounds absorbs
it used to be one count per benchmark, calibrated once and handed to every build of it. that is not wrong on a quiet machine — it is what produced most of the tables anyone has quoted — but it leaves two ways for a busy one to ruin a row:
- one count cannot serve four builds. they can be two hundred times apart, so
a count long enough for the interpreted build left the compiled one timing a
third of a millisecond. that is fine until something deschedules it, and then
a single ten-millisecond quantum is thirty times the whole reading:
mandel_inlinereported a ±631% floor for two builds of one program on an evening when the machine was at eight times oversubscription - the count came from one un-replicated probe, taken minutes before the
timing. a machine that got busier in between went on using a count chosen for
a speed the run no longer had: one
mandel_inlinecount was picked against a 16ms interpreted call and was still in use when that call had become 77ms
so what a duration buys is not resolution, it is robustness — see the numbers further down
⚠️ scaling a benchmark's own work up does not fix any of this. it is the
obvious remedy and it is a no-op here, because the count was derived from the
measured speed: ten times the work per call divides the count by ten and the
sample comes out the same length. measured rather than reasoned — mandel_inline
with its inner loop scaled tenfold went from 9 calls of 0.32ms to 1 call of
3.26ms, a 2.9ms sample against a 3.3ms one, and calls from 9 × 0.37ms to
1 × 3.45ms. what starved the fast builds was never the size of the program, it
was the spread between the builds of it, and no amount of scaling changes a
ratio
what it refuses¶
each of these is a way this suite has actually produced a wrong number, and each is now a refusal rather than a warning:
| refusal | the failure it closes |
|---|---|
| the artefact must be a real extension module | a broken shim made by compile fail silently and the previous .so was timed in its place |
| …under this run's own root, newer than this build | the run's root is a fresh temporary directory, so a stale one cannot be reached at all |
| an interpreted build must not be an extension | the mirror image, which would make the baseline a compiled one |
| every build must return cpython's answer | a build that got a different answer is not a faster build |
the decline count must match programs.toml exactly |
a benchmark can decline and quietly measure interpreted code while still posting a number |
| an unknown name, an unlisted program, an empty run | a harness that matches nothing looks exactly like a harness that found nothing wrong |
the decline check is the one worth dwelling on. by compile never fails on code
it cannot lower — that function runs from its interpreted definition instead — so
a "compiled" number can silently be an interpreted one. programs.toml records
the expected count per benchmark and the run fails when it moves in either
direction: a compiler that started compiling something is as much a change as
one that stopped. improving the compiler edits that file in the same commit,
which makes it a readable ledger of what the backend has learned to take
the refusals are exercised rather than asserted:
which builds a leg that is wrong in each of those ways and proves each one is turned away
what it measures¶
one axis per group, and a benchmark earns its place by being the only one on its axis — or by being half of a pair whose difference is the axis:
| group | benchmarks | the question |
|---|---|---|
float |
mandel, mandel_inline |
scalar float work; the pair isolates calls |
int |
loops, bigint |
tagged integers, and leaving the word |
dispatch |
calls, methods, recurse, inherit, props, props_ext, props_get |
call, method, depth, an override, a property pair, and a lone getter |
memory |
alloc, fields, objects |
allocation and field access, apart and together |
list |
dot, prefix, sieve |
indexed reads, growth, indexed writes |
tuple |
tuples, pairs |
pack, unpack and a two-value return; the pair isolates the slot |
set |
sets |
membership as the whole operation |
dict |
dictget, dicthist |
lookup-and-update, and the histogram miss |
str |
words, chars, strops, keybuild |
build, scan, methods, and key construction |
boxing |
generic, generic_mono |
the pair isolates what a type parameter costs |
control |
excs |
a raise that is caught, and a try that never fires |
frames |
gen, coro |
the two halves of the resumable-frame lowering |
the pairs are read as pairs. generic alone says almost nothing; against
generic_mono, which is the same program with the call monomorphised by hand,
it says exactly what the type parameter costs. same for mandel against
mandel_inline, and for objects against alloc, fields and methods
props_ext is props with one line added — a class in the same module that
extends the one holding the pair. that makes the holder a mutable heap type,
because a subclass could override a half, so every read and write goes round the
descriptor protocol instead of calling the half. the difference between the two
rows is what that costs, and fields is the floor under both: the same reads and
writes done directly
props_get is the third of that set: the same read through a @property with no
setter written under it. python folds a group of one into a property just as it
folds a pair, so the two rows differ only in whether the source wrote the two
lines under @v.setter — which is what makes the difference between them a
measurement of the compiler rather than of the program
pairs is tuples with the first slot changed from an int to an instance the
loop is handed rather than builds. a tagged word is copied and nothing is
retained; a pointer is retained and released, and that is the other half of what
a tuple slot can be. the object is built once outside the loop so the difference
between the two rows is the slot rather than an allocation, which alloc
already measures
adding one is three files: the program, an entry in programs.toml, and a line
in the table above. the entry has to say what it measures that nothing else does
comparing two runs¶
the quantity compared between runs is the speedup, not the time. absolute times are not comparable across machines, across interpreters, or across a Tuesday, and this suite exists because they were compared anyway
a baseline written before the sample became a duration is comparable only for
what it is: the two runs timed the same programs, but not for the same lengths,
so a --baseline across that change carries a method change inside every row.
the run's metadata carries sample_target for exactly this reason
every run also records the sha256 of the compiler it measured, and a comparison says whether the two runs' hashes agree. a version string and a git description are about the checkout rather than about the file on disk, and both go on saying the same thing while the binary underneath is rebuilt — or is not rebuilt when it should have been. running one binary against itself is a useful thing to do deliberately, since it is how the floor is measured, and a disastrous one to do by accident; from the outside the two look identical, and an ablation harness here once did the second and read the result as the first
a change is called one only when it clears the bar, and the bar is whichever is
larger: the noise the two runs measured for themselves, or
--regression-threshold. a regression exits non-zero, so it is detected rather
than eyeballed. a baseline from a different interpreter or a different host is
compared anyway but says so first, and a row either run found too noisy is
skipped rather than given a very wide bar
the noise column does not bound this. the control is two builds in one process at one moment, so it bounds the noise within a run. between two runs there is more: a different process, a different heap layout, a different machine mood. measured rather than assumed — two runs of an unchanged compiler, forty minutes apart, agreed within 3% on 25 of 27 rows and disagreed by 8.1% and 8.4% on the other two, while both of those rows reported a ±0.8% floor for themselves. so the default bar is 10%, which is what that evidence supports, and not the within-run floor, which would have called both of them improvements
the consequence is worth stating plainly: on a machine doing other work this
suite sees a 10% change and does not see a 5% one. for smaller than that, use a
machine that does nothing else, drop --regression-threshold to match the floor
it then reports, and confirm anything it flags by running it again
the half that can run anywhere¶
--verify-only does everything the suite does except look at a clock: it builds
all four ways, proves each artefact, checks that every build gives cpython's
answer, and checks the decline ledger. none of that depends on how busy the
machine is, so it is the half that belongs on a shared runner — and it is where
most of the correctness value is. it catches a benchmark that stopped
compiling, a compiled build that started answering differently, and a compiler
that quietly began declining something it used to take
the timing half wants a machine of its own. a shared runner cannot hold a 3%
noise floor, and this suite's whole premise is that a number it cannot stand
behind should not be printed as if it could. run the table on a fixed machine,
keep the --json, and gate on --baseline against the previous one
reading a bad run¶
a row whose noise exceeds --noise-limit (10% by default) is marked ! and
does not count as measured. the run then exits non-zero even if everything else
looks fine, because a table that is mostly trustworthy is the kind nobody
remembers to check before quoting one of its rows
that limit is not fastidiousness. pairing cancels the noise a busy machine
adds, but it does not cancel the bias: under preemption the longer sample
loses more, and the interpreted build's sample is the long one, so contention
pushes the reported speedup up. a run at load 95 on a 16-core laptop read
fields at 23.8x where two quiet runs both put it near 12x, and the two
supposedly identical builds disagreed by 74% in the same breath. widening the
bar does not fix a bias, so a row that noisy is skipped rather than compared
between those two extremes the floor is simply reported. a ±6% floor means
the run can see a 2x difference and cannot see a 10% one, which is often all
that was wanted
each row also records the load at the moment that row was timed, and a row
marked ! prints it. the run-level figure is taken at the two ends and a run is
minutes long, which is not the same thing: one run here started at load 26 and
finished at 193, so its early rows and its late rows were measured on what
amounted to two different machines and the table alone could not say which was
which
what the sample length was worth¶
measured the only way that is self-contained: the suite run twice against one unchanged binary, so that every difference between the two tables is noise the suite invented. same 29 programs, same compiler — asserted by sha256 rather than by the path that was typed — on a 16-core laptop, in one quiet stretch, one pair of runs each way
| shared count | duration per build | |
|---|---|---|
| load across the two runs | 121 → 57 → 26 | 22 → 30 → 32 |
run-to-run change in vs cpython, median |
1.96% | 2.02% |
| … worst row | 50.0% | 8.0% |
| … rows agreeing within 10% | 25 / 29 | 29 / 29 |
| within-run floor, median | ±3.3%, ±2.6% | ±2.6%, ±2.8% |
| … worst | ±61.8% | ±25.3% |
it did not make the typical row more precise, and it was not expected to. the
median is unchanged, and on a quiet machine the shared count was never the
problem: mandel_inline at a 0.9ms sample read a ±0.9% floor for itself
what it removes is the tail — the row that blows up because one sample of a
few milliseconds caught a descheduling, which is the row somebody then cannot
quote. sieve went from a 50.0% run-to-run disagreement to 4.6%, tuples from
30.2% to 3.6%, recurse from 18.8% to 1.2%. that matters more than it sounds,
because a run is failed by its worst row rather than its median
on a busy machine the same shape shows up much larger. alternating the two timers benchmark by benchmark through six rounds at loads between 44 and 165, the median floor went 8.4% → 6.0% and the upper quartile 20.9% → 11.9%, with the duration-based timer lower in 30 of 48 paired readings
⚠️ speedups from a run before this change are not comparable with one after
it, and the method is only half the reason. those earlier runs were the busier
ones, and contention pushes a reported speedup up: words read 91x and 110x on
a loaded evening and 48x in the quiet stretch, on one unchanged compiler