Benchmarks¶
In one sentence
Stacked, Angelo's optimisations take a 48 second job down to 4.5 seconds with unchanged verdicts. This note records the measurements, the comparison against mutmut, one bug that made an earlier set of numbers false, and one optimisation that worked and bought nothing.
Method¶
- Machine. WSL Ubuntu on Windows 11, 16 cores, Python 3.14.
- Synthetic project. Forty independent functions, one test each, 200 mutants. A
conftest.pysleep sets the suite length, so the same mutant pool can be measured against a fast suite and a slow one. - Control. The score. Every configuration must produce the same one.
- Single runs, not averages. Treat any difference under about 10 percent as noise.
The feature matrix¶
Two hundred mutants, eight workers.
| Suite | Batch | Selection | Warm | Time | Score |
|---|---|---|---|---|---|
| 0.2 s | 1 | off | off | 15.88 s | 39.5% |
| 0.2 s | 8 | on | off | 3.99 s | 39.5% |
| 0.2 s | 8 | on | on | 2.16 s | 39.5% |
| 2.0 s | 1 | off | off | 48.13 s | 39.5% |
| 2.0 s | 8 | on | off | 6.22 s | 39.5% |
| 2.0 s | 8 | on | on | 4.45 s | 39.5% |
10.8x on the slow suite, 7.4x on the fast one. The score never moved.
Where a run's time goes¶
One selected single test run, measured by isolating each step:
pie showData
title One 327ms selected run
"interpreter start" : 18
"import pytest" : 155
"collect and configure" : 139
"the actual test" : 15
About 95 percent is overhead. This is why warm workers exist, and why test selection alone reaches a ceiling.
Which feature pays when¶
| Feature | Fast suite | Slow suite |
|---|---|---|
| Batching | 3.7x | 4.6x |
| Test selection | 1.05x | 2.8x |
| Warm workers | 2.5x | 1.9x |
The rule of thumb: selection removes test time, warm workers remove startup time. A project with a slow suite wants the first. A project with many cheap tests wants the second. Batching helps both, though it partly competes with selection.
A real codebase, where the numbers do not hold¶
The synthetic result does not survive contact with click. 957 mutants, a 4.21 second suite, eight workers.
| Batch | Selection | Warm | Time | Per mutant | Detected | Survived |
|---|---|---|---|---|---|---|
| 1 | off | off | 1119 s | 1.170 s | 540 | 336 |
| 1 | on | off | 1189 s | 1.242 s | 540 | 336 |
| 8 | on | off | 1130 s | 1.181 s | 541 | 335 |
The optimisations bought essentially nothing, and test selection was slightly slower than no selection at all. Every configuration lands near 1.2 seconds per mutant.
The likely cause is visible in the verdicts. Between 60 and 76 mutants time out on every run. A timeout costs the full timeout budget, here about 22 seconds, and no amount of batching or test selection shortens it. On click, waiting for timeouts dominates everything the optimisations save.
The synthetic project has no timeouts at all, which is precisely why it showed 10.8x.
The budget was the bug, and it has changed
Those 22 seconds came from the whole suite's duration, charged to every mutant, including the ones whose selected tests are worth 50 milliseconds. A run is now budgeted from the tests it actually runs. These three rows predate that change and have not been re-measured. The claim still under test is that batching and selection start paying on a repository where they previously did not; until click is re-run on the same machine, treat the table above as the last honest measurement rather than the current one.
Read the synthetic numbers as an upper bound
Independent functions, disjoint tests, and no timeouts is the best case for every technique in this tool. A real project with slow or hanging mutants can see no speedup whatsoever.
Verdicts moved slightly, and why that is not the batching bug¶
The three rows disagree: 540, 540 and 541 detected; 336, 336 and 335 survived.
This is timeout classification, not misattribution. A mutant sitting near the timeout
threshold is detected on a loaded machine and survives on an idle one, because
timeout_factor is a wall clock budget. The kill and timeout columns also trade against
each other between runs for the same reason.
The verdict matrix that runs in continuous integration used a fixture with no timeouts, so it could not catch this class of variation at all. That gap is now closed: the fixture contains one mutant that deliberately spins forever, and all eight configurations must agree that it timed out. A budget derived from the selected tests differs per configuration, which is exactly the disagreement the matrix exists to catch.
An invalid fourth row¶
A fourth configuration was measured, but the operator set was expanded while the benchmark was running, so it planted 4596 mutants instead of 957. It is excluded here rather than reported, because it compares two different tools.
flask¶
Now measured, and it is where four bugs were found. A 1000-mutant sample, 16 workers, a Ryzen 7 5800 (8 physical cores, 16 threads), enumeration included:
| Fixed | Wall |
|---|---|
| — | 718 s |
| coverage contexts resolve, so selection works at all | 326 s |
| a timed-out batch scans instead of bisecting; a forking worker survives one | 125 s |
| enumeration commits once instead of 2541 times | 122 s |
a lone mutant gets -x even without a selection |
120 s |
Six times faster, and none of it was schemata. The first line is the important one: on
a project with no tests/__init__.py, no coverage context resolved to a node id, so every
run silently ran the whole suite. Everything else was hidden underneath that.
The last fix is not only a speedup. 23 of 1000 mutants had been recorded timeout when
they were plain killed — caught early, then left running until they overran the
whole-suite budget. The score does not move (a timeout counts as detected) but the report
was wrong about how they died.
Where the remaining time goes, and why it is a floor¶
At 16 workers, 87% of all the work is whole-suite runs, and there are only ~240 of them:
| runs | share of the work | |
|---|---|---|
| import-time mutants, whole suite | ~240 | 87% |
| everything else | ~520 | 13% |
An import-time mutant sits on a module body, a class attribute or a decorator. That code runs under every test, so no coverage data can narrow it and no batch can hold two of them. Each one costs a full 1.2 s run of flask's suite: 240 × 1.2 = 294 core-seconds that are the measurement rather than overhead on it.
Adding workers does not help, because the machine is already the limit:
| workers | wall | worker-seconds | concurrency |
|---|---|---|---|
| 4 | 271.8 s | 1063 | 3.9x |
| 8 | 182.9 s | 1403 | 7.7x |
| 16 | 173.4 s | 2595 | 15.0x |
Angelo scales almost perfectly at every level; 8 → 16 buys 5%, because there are eight real cores and the second thread per core only shares one. Getting a 1000-mutant flask sample under a minute would mean not mutating import-time code at all — which is what mutmut's schemata design forces, and it changes what the score is a score of.
Against mutmut and cosmic-ray¶
WSL Ubuntu, 16 cores, Python 3.12, twelve workers. Each tool mutates one module and
runs the same tests, in the repository's own virtualenv, so the job is identical.
Produced by scripts/benchmark.py.
Per-mutant seconds is the only column compared. The operator sets differ, so the pools differ, so wall time compares two different jobs. The scores are each tool's own kill rate over its own pool and are printed side by side, never subtracted.
| Repository | Module | Angelo | mutmut | cosmic-ray |
|---|---|---|---|---|
| fastapi | fastapi/security/api_key.py |
0.827 s | did not run | 1.677 s |
| thefuck | thefuck/types.py |
0.117 s | did not run | 0.510 s |
| graphify | graphify/cluster.py |
0.087 s | 0.080 s | 0.535 s |
Angelo is 2.0x to 4.4x faster per mutant than cosmic-ray, and level with mutmut on the one repository where mutmut runs at all.

What the other two did¶
cosmic-ray runs serially. worker-count = 12 under its local distributor produced
one pytest process at a time throughout. That is most of the gap above, and it means the
column measures cosmic-ray's default rather than its ceiling. Its fastapi row is a
timeout: 179 of 183 mutants inside a 300 second budget, counted from its own session
database rather than dropped.
mutmut ran on one repository of three. On fastapi and thefuck it generates its mutants
and then stops on its own sanity check — Unable to force test failures — without running
any of them. A control run on markupsafe finishes 314 mutants in 5.9 s, so this is
mutmut meeting these repositories rather than the harness misconfiguring it. Both are
recorded rather than omitted: a tool that produces no verdicts must not look fast.
What this does not prove¶
- One module per repository, not a whole codebase. A different module moves every number.
- Single runs.
benchmark.py --repeats 3 --warmupgives a median and was not used here. - mutmut's graphify score is 0.4% against Angelo's 29.0% on the same module. That is two different pools, not a quality comparison, and it is exactly why the rule above exists.
The allocation pass¶
Angelo's own Rust had never been read for allocation. Four patterns were fixed: a line
counter that rescanned a file from byte zero once per mutant, a String clone per covered
line while building the coverage map, a cloned HashSet on every classification, and a
full copy of the file per spliced batch member. No dependency was added and no mutant
changed.
Measured on a shallow clone of django, 523,660 lines across 2,927 files, from which Angelo
enumerates 89,303 mutants across 908 files. Windows 11, release build, three runs each.
Enumeration is isolated with exec --diff on a clean tree, which parses and enumerates
everything and then inserts nothing.
| Phase | Before | After |
|---|---|---|
| Enumeration alone | 1.63 s | 1.26 s |
| Enumeration plus the database insert | 150.4 s | 147.3 s |
Enumeration got 1.3x faster, and it did not matter. Writing 89,303 rows costs about 148 seconds, so the 0.37 seconds saved is a quarter of one percent of the command a user actually runs. On the demo project, which enumerates thirty mutants, it is unmeasurable.
That is the honest result, and it points at the next thing rather than this one: the row
insert is what makes angelo exec slow to start on a large codebase, and it was left
alone here, because this was an allocation pass and one row at a time is a different
problem.
The other two fixes sit on the run path rather than on enumeration, where each saving is buried under a pytest process that costs 300 milliseconds. Nothing was expected there and nothing was measured; what they had to prove is that they change no verdict, which is what the verdict matrix is for.
Two suggestions that do not apply, recorded so they stop coming back
rayon. TestRunner::run_all already fans out across cores with std::thread::scope
and an atomic work index, and the work is a subprocess rather than arithmetic. A
dependency that duplicates the standard library is a cost with no return.
swap_remove. The famous one, and there is no order-preserving Vec::remove
anywhere in the codebase to apply it to.
Comparison against mutmut¶
Same machine, same project, both at eight workers.
| Tool | Mutants | Wall time | Per mutant |
|---|---|---|---|
| Angelo, batch 16 | 200 | 2.5 s | 0.0125 s |
| mutmut 3.6.0 | 360 | 3.7 s | 0.0103 s |
mutmut is about 1.2x cheaper per mutant. It uses schemata plus fork(); Angelo comes
close with neither. The operator sets differ, so per mutant cost is the only fair column.
mutmut cannot run on Windows at all, because it requires fork().
The bug that made earlier numbers false¶
Four configurations that must agree reported 77, 73, 69 and 78 kills.
A .pyc file is reused when the source's recorded modification time in whole seconds and
its byte size both still match. Same size replacements, such as + becoming -, written
in the same second as the previous one, therefore ran the old bytecode. The mutant
survived for free.
- Invisible on Windows, where a 1.9 second suite pushes writes into different seconds.
- Obvious on Linux, where a 0.2 second suite lands many runs inside one second.
- Fixed by never writing bytecode. Note that the environment variable alone is not
sufficient if a
.pycalready exists, because Python still reads it.
The lesson is worth stating plainly: a mutation tester's characteristic failure is inventing test gaps that do not exist. The verdict matrix in continuous integration exists because of this bug.
What these numbers do not prove¶
- The synthetic project has independent functions with disjoint tests, which is the best possible case for batching. Real code shares tests, so expect less.
- Single runs on one machine, with no confidence intervals.
- Windows process spawning is far slower than Linux. Only compare within a platform.
- A tool that dies instantly on every mutant looks fast and scores zero. Check the error count before trusting any score.
Reproducing¶
bash scripts/verdict-matrix.sh # the correctness gate, runs in CI
bash scripts/setup-extra.sh # a virtualenv per repository in extra/
bash scripts/bench-repo.sh extra/click # the feature matrix on a real project
pip install -r scripts/requirements-bench.txt
python scripts/benchmark.py --root extra --angelo target/release/angelo
scripts/benchmark.py is the three-tool comparison: it writes bench-results.md,
bench-results.json and bench-results.png in one command.
python scripts/benchmark.py --tools angelo,cosmic-ray # skip a tool
python scripts/benchmark.py --repeats 3 --warmup # median of three
mutmut needs fork(), so that script is Linux and macOS only. On Windows it exits
rather than print half a table.
extra/ holds gitignored shallow clones of click, flask, httpx, requests, fastapi and
django.
Each needs its own virtualenv. Real projects pin pytest plugins in pyproject.toml.
Run them against a global Python and pytest exits 3, an internal error, before collecting
anything. Angelo then refuses to start, correctly, but the fix is dependencies rather than
Angelo.
django is cloned but Angelo cannot mutate it, because it uses its own test runner rather than pytest.