Benchmarks¶
Use the benchmark report to compare APN Mojo with GMP, MPFR and MPC through Rug, and FLINT and Arb through python-flint. It checks every case before timing it. Benchmark results summarizes the published run and links to the full report. Record measurements that explain a design choice in the relevant architecture chapter.
Run the commands below from the project directory containing pixi.toml.
Running the report¶
Install the comparison environment for python-flint and a Rust toolchain
(cargo) for the Rug worker:
pixi install --locked -e comparison
pixi run --locked report # the full catalog, about 5 minutes
pixi run --locked report --quick --area float # 2 ms samples: for development
pixi run --locked report --check-only # correctness only, seconds
pixi run --locked report --case float.add.53 --baseline HEAD
pixi run --locked report --publish # update the summary and full report download
Select cases with --area, --case, --op, or --bits, and inspect them
with --list. Results go to build/report/report.md and report.json, which
includes every sample. A disagreement with a reference exits with status 1.
Investigate both results before deciding which library is at fault.
--publish writes a compact area summary to the site and preserves every
case in docs/content/downloads/benchmarks/report.txt, downloadable as
Markdown. Both files come from the same run; do not edit the published
numbers by hand. The local report.json retains the individual samples.
The apn_mojo worker builds once per set of sources, at -O3, with one compiler
thread under the suite's memory cap (about 85 s), and is cached under
build/report-cache/.
What is compared¶
tests/benchmarks/catalog.py generates every case from one seed: about 480
cases in twelve areas, from 64-bit Integer addition to 4096-bit Ball
functions, text conversion and batches of 1000. Operands are exact text that
every worker reads alike. A Float operand is already exact at its precision, so
no backend rounds its inputs.
| Area | Reference |
|---|---|
| Integer and Rational arithmetic, number theory | GMP (Rug) and FLINT (python-flint) |
| Float arithmetic and functions | MPFR (Rug) |
| Complex arithmetic and functions | MPC (Rug) |
| Ball and ComplexBall arithmetic and functions | Arb (python-flint) |
| Text conversion | GMP and MPFR; FLINT for Integers |
| Batches | Loops over MPFR and GMP values (Rug) |
Each backend computes every case once before any timing:
- Integer, Rational, Float and Complex results must be equal. Both libraries round Float and Complex results correctly, so equality is exact.
- Balls must overlap, since both enclose the true value. The report also gives the ratio of the radii.
- Printed decimals must have the same value.
Some operations differ in what they compute:
- python-flint has no exact division, so its
div_exactis floor division. - GMP's primality test with 24 repetitions and FLINT's
is_probable_primeare both Baillie–PSW tests, like apn_mojo's. - In the batch area, a
_loopcase times apn_mojo's plainListloop of the scalar function. It measures the batch layer's own cost. - The batch table also times apn_mojo on every allowed CPU. All other numbers use one CPU.
How it times¶
Each backend runs as a persistent worker process:
apn_worker.mojo;rug/, in Rust;flint_worker.py.
The driver pins all of them to one CPU (CPU 2 by default; --cpu), and only
one works at a time. Each case is prepared before timing; the timed call
creates its result and destroys it. Every worker follows the timeit protocol:
- The number of calls steps 1, 2, 5, 10, 20, ... until one sample of that
many calls takes at least the target, 10 ms by default (
--target-ms). - Then seven samples (
--repeats), each one timer pair around the loop, without checks. - The report gives the median per call and half the interquartile range.
The backends take turns case by case, in a rotating order, so slow drift in clock speed affects all of them alike. A sentinel case is timed every 25 cases. The spread of its medians is the run's drift, and absolute times are only as steady as that.
python-flint is timed through the interpreter. The report times an empty call
in the same loop (callable(a), or operator.is_(a, b)) and subtracts it from
every python-flint time. A † marks a call that takes less than four times the
overhead, since its net time is uncertain.
Stop other heavy work before measuring. The driver warns if the pinned CPU was busy before the run. Concurrent Mojo compilation has made entire runs several times slower.
Before and after a change¶
--baseline <commit> builds the same worker against that commit's sources in
a temporary detached worktree and adds it as one more backend. The column
"vs baseline" is the working tree's median over the commit's, measured in the
same rotation. Select the changed functions with --case, --op or --area;
before a commit, that measurement is the only gate.
Differential checks¶
A change that should not alter results is checked against a reference commit:
pixi run --locked python3 tests/benchmarks/differential.py --reference HEAD
pixi run --locked python3 tests/benchmarks/differential.py --only rational
The script builds tests/benchmarks/differential.mojo against a temporary
detached worktree of the reference and against the working tree, runs both, and
compares their output line by line, exiting with status 1 when a line differs.
The reference executable is kept under build/differential/ while the
harness is unchanged. Each build is one compilation, about 20 s.
The roughly 17,800 lines cover Integer arithmetic, comparison, bitwise
operations, shifts, in-place updates, division, gcd, roots and factorials at
every seventh length up to 3300 bits and at selected lengths up to 20,000;
Rational arithmetic over 17 numerator and 13 denominator sizes; Float square
roots and arithmetic at 21 precisions in all five rounding modes, with exact,
same-precision and wider operands; and Complex square roots and arithmetic in
13 format pairs and 7 rounding-mode pairs; and Ball arithmetic, sqrt and six
functions on balls of eight precisions with five radii each, in three result
precisions. Values print as their residue modulo 1000000007, bit length and
sign, Float results with their status flags, and balls with the radius's
mantissa and exponent; --only ball runs that section alone. Each Ball line
starts with its operation, so a change meant to alter one radius shows on that
operation's lines alone.
Attributing time¶
When hardware performance counters (perf) are restricted in your development
environment, compile with --debug-level=line-tables and profile instruction
counts using Callgrind:
valgrind --tool=callgrind --callgrind-out-file=.cache/out ./harness
callgrind_annotate --inclusive=yes .cache/out
The report's apn_mojo worker accepts calls i n, which makes exactly n timed
calls of case i and nothing else, so counts repeat between builds. The
report prints the worker's path and writes its catalog, cases.tsv, whose
line i (from 0) is case i:
pixi run --locked report --case integer.gcd.1024 --check-only --line-tables
printf 'calls 0 20\nquit\n' | valgrind --tool=callgrind <worker> build/report/cases.tsv
Instruction counts miss memory stalls and cache effects. Returning a large struct across a non-inlined call, for example, can add substantial latency without many instructions. Confirm a suspected bottleneck with a targeted change and a wall-clock measurement.