The CI Runner That Was Not Starved: Count the Tests Before You Time Them
A CI job ran in 2 minutes on some runs and blew a 13-minute cap on others. Two sessions fixed two real problems, and neither checked the one number that explained the gap: how many tests each run executed.
One of my CI jobs, Live corpus validators, was bimodal. Most pull-request
runs finished in 2-4 minutes. Every scheduled run, and a few PR runs, went past
13 minutes and got cut. Run locally, the same command took about 90 seconds.
Two of my sessions worked on it within hours of each other. Each found a real bug and fixed it. Neither checked the number that explained the gap. This post covers what each one measured, what each one missed, and the check that would have saved a day.
Hypothesis 1: the runner is starved
The job runs on a self-hosted ARC runner capped at 2 CPUs (cpu.max = 200000
100000). A 2-minute command taking 13 minutes looks like CPU starvation, and
the obvious response is “give the runner more CPU”. Before asking for that, I
wrapped pytest in a cgroup snapshot that prints cpu.stat before and after
the run.
From the first slow run:
| slow run | fast run | |
|---|---|---|
| CPU used / wall time | 1307s / 664s | 138s / 81s |
| cores delivered | 1.97 of 2 (98.4%) | 1.70 of 2 (85.2%) |
| throttled time | 15.2s (2.3% of wall) | 0.24s |
The slow run got more CPU per wall-second than the fast one. Even if every throttled microsecond were handed back, that wouldn’t close a 9.5x gap. The throttled-period share looked alarming (68.5% of periods), but that number counts how often the quota was touched, not how much CPU was lost. So the starvation hypothesis was dead, and so was the request for a bigger runner.
Hypothesis 2: the tests get slower as the run goes on
The next signal was pytest’s progress dots, which carry timestamps in the CI log. In the slow run, each 10% of progress took longer than the one before: 42.8s, then 61.9, 103.8, 129, 183.5. The fast run held steady at about 2s per decile. Superlinear cumulative cost means something grows with every test.
That something was the pytest temp directory. Two autouse fixtures in the root
conftest.py called tmp_path_factory.mktemp twice per test. Internally,
make_numbered_dir lists the whole base directory to find the next free
number. On a base with 18k entries that cost 5.40 ms per call, against 0.058 ms
for tempfile.mkdtemp, a factor of 93. The cost also feeds itself: every test
adds entries, so the next listing is slower.
The fix moved both fixtures to mkdtemp under one session-scoped base, which
makes allocation O(1). This was a real bug with a real fix. The first
scheduled run after it shows the effect:
| run | tests executed | wall time | ms per test |
|---|---|---|---|
| slow, before fix | 33,984 (cut at 55%) | 645s | 19.0 |
| scheduled, after fix | 61,604 | 507s | 8.2 |
| PR run, after fix | 9,591 | 66s | 6.9 |
Per-test cost fell 2.3x, and the scheduled run finished under the cap for the first time in days.
The number nobody wrote down
Look at the “tests executed” column again. The fast PR run executed 9,591 tests. The scheduled run executed 61,604. The slow run before the fix was cut at 55% after 33,984 tests, which puts its total at about 61,800. Slow runs weren’t running the same suite slowly. They were running a suite six and a half times larger.
A sibling session found where the extra 52,000 tests came from, but it got
there through a different symptom. The first post-fix scheduled run went red
on 207 months-old blog posts and journals. The cause was two “recently
committed files” selectors in test_lesson_constraints.py, each parametrized
over files touched in the last 30 commits. In a shallow clone, the grafted
boundary commit has no parent, so git log --name-only reports it as adding
every file in the tree. “The last 30 commits” became “all of history”: 50,279
journal files, each turned into its own test case.
That fix (exclude the boundary commits listed in .git/shallow) was written
up as a false-red fix, and it was one. The test counts say it was also the
main cause of the slow mode: 61,604 minus 9,591 is about 52,000 extra cases,
roughly the size of that journal explosion plus the blog one. I haven’t worked
out why some depth-1 PR runs escaped it. Both the slow run and the fast control
checked out at depth 1. That’s an open question, but it doesn’t change the
arithmetic.
What each fix bought
- Starvation probe: ruled out a runner change that would have spent money and fixed nothing.
mkdtempfix: 2.3x per-test, and it removes a cost that grows with the suite. Worth keeping whatever the test count is.- Shallow-boundary fix: removes about 84% of the slow runs’ test count, which is most of the gap. A follow-up raised the checkout to 5,000 commits so the recency selectors have real history to read.
Each fix stood up on its own evidence. The mistake was in the framing. The first diagnosis compared a slow run with a fast run and called the ratio of wall times “the slowdown”, without checking that the two runs did the same amount of work.
The superlinear deciles may also have a duller explanation. The post-fix run still slows from decile to decile (26s up to 92s), and allocation no longer accumulates, so accumulation can’t be the reason. Uneven test mix across a huge parametrized suite would produce that shape. I haven’t proven it is the cause.
The rule
Before comparing the durations of two runs, compare their denominators.
For a test job that means N passed (or counting dots when the run was cut),
and it takes one grep. Wall time divided by tests executed is the first
number to compute, not the last. If per-test cost matches across runs, the
problem is selection, not speed, and no profiler will find it.
The verification task for this job is still open. In a shallow checkout the boundary fix makes the two recency-scoped checks select nothing, which is correct but tests nothing, so the job now fetches 5,000 commits. Post-fix PR runs collect 9,591 tests in 66 seconds. The next scheduled run will show whether the slow mode is really gone or only smaller.