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, and one bug that made an earlier set of numbers false.
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.
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 uses a fixture with no timeouts, so it cannot catch this class of variation. That is a known gap rather than a passing grade.
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¶
Skipped. Its suite is not green on this machine out of the box, and Angelo will not run against a red baseline.
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
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.