Skip to content

Derive the parallel worker count from the file count - #6467

Open
SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:adaptive-parallel-worker-count
Open

SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:adaptive-parallel-worker-count

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

A warm run of a few dozen changed files uses one worker, on any machine. jobSize: 20 and minimumNumberOfJobsPerProcess: 2 together ask for 40 files before a second worker is allowed. The edit-and-rerun cycle is therefore single threaded while the rest of the cores idle.

This PR is a proposal, not a finished answer. It is off by default. What I am asking for is measurements on other machines and other corpora, because my numbers come from one 14-core laptop.

What it does

Behind featureToggles.adaptiveParallelWorkerCount, off by default and on in bleedingEdge, the worker count comes from the file count:

workers = clamp(round(0.5 * sqrt(files)), files >= 9 ? 2 : 1, cores)

and never below what the job count already justifies, so it can only ever raise parallelism.

The shape is fitted to the measurements below, not derived from a model. What the data shows is that the number of files a worker needs before it earns its place grows with the size of the run.

files processes today jobs today processes with the toggle jobs with the toggle
9 1 1 2 4
25 1 2 3 6
50 1 3 4 8
100 2 5 5 10
200 5 10 7 14
300 7 15 9 18
400 10 20 10 20
600 14 30 14 30
4524 14 227 14 227

From 381 files upward the schedule is identical, jobs and processes both. I walked every file count from 1 to 900 to find that boundary rather than reading it off the table.

Measurements

Warm runs of this repository, one result-cache snapshot per arm, 3 rounds, medians. A duplicate control arm sizes the noise on every row.

warm files processes before wall before processes after wall after wall CPU control
9 1 4.32s 2 4.19s -3.0% +25% 0.5%
25 1 4.82s 3 4.41s -8.5% +44% 3.7%
50 1 6.93s 4 5.11s -26.3% +50% 0.7%
100 2 7.56s 5 5.96s -21.2% +35% 0.8%
200 5 8.21s 7 7.50s -8.6% +14% 2.4%

@staabm asked for the range above 200, up to where the process count reaches the machine's 14. Those rows are weaker and I am reporting them as measured:

warm files processes before processes after wall control reading
300 7 9 -4.3% 7.4% inconclusive, the control is larger than the effect
400 10 10 -2.3% 13.5% inconclusive, and the schedules are identical anyway
600 14 14 see below 2.2% was a regression, now identical
800 14 14 -0.9% 3.9% identical schedules, so this is the noise

The process counts are observed, not derived: each arm was run once with the live worker processes counted while it ran, before the timed rounds.

A regression that range found

The first version of this PR used sqrt() alone. It dips below the existing job-count formula between roughly 400 and 800 files, so it quietly took workers away from large runs. At 600 warm files it scheduled 12 processes where the default schedules 14, and measured 13.2% slower against a 2.2% control.

Fixed by taking the maximum of the two formulas, so the adaptive count can only ever raise parallelism. A test asserts that invariant across 13 file counts, because the dip appears in a band that neither end of the range covers.

Thanks to @staabm for asking for exactly the numbers that exposed it.

The cost

It trades CPU for latency, 14 to 50 pct more CPU, which is why it is opt-in. On a laptop that is fans and battery on every run.

The shape was fitted on 18 points across five codebases. Those are this repository plus four real applications of 1957, 4339, 6395 and 11897 files, at levels 5 through max. Against measured worker counts the worst case was +0.0 pct and the mean -9.3 pct. The coefficient 0.5 was fitted on the same points it was scored against, so treat it as a starting value rather than a settled one. Its neighbours 0.4 and 0.6 score about the same, so it is not knife edge.

Two traps if you benchmark this

Both cost me an invalid run.

  1. Flipping the toggle invalidates the result cache, because the project config is part of its metadata. The first run after flipping is cold and will look catastrophic. Build a separate warm cache per arm.
  2. On a source checkout, run composer dump-autoload after switching to this branch. The autowired parameter is read from the generated vendor/attributes.php, so without it the toggle silently stays off and both arms measure the same thing.

What I have not tested

One machine, 14 cores, where diagnose reports Mechanism: fork (pcntl_fork). Whether the same shape suits a spawning setup is open, and it is the reason I am asking for measurements elsewhere. The coefficient was never tested against a different core count. The small-file evidence is warm runs only.

maximumNumberOfProcesses still applies on top, so an explicit cap keeps working.

@SanderMuller
SanderMuller force-pushed the adaptive-parallel-worker-count branch 2 times, most recently from 9cfa18c to 5d93cef Compare September 17, 2026 10:47
A warm run of a few dozen changed files uses one worker today, on any machine.
jobSize 20 and minimumNumberOfJobsPerProcess 2 together ask for 40 files before
a second worker is allowed, so an edit-and-rerun cycle is single threaded while
the other cores idle.

Behind featureToggles.adaptiveParallelWorkerCount, off by default and on in
bleedingEdge, the worker count comes from the file count instead:

    workers = clamp(round(0.5 * sqrt(files)), files >= 9 ? 2 : 1, cores)

A worker's startup is a fixed cost, so the number of files it needs to earn its
keep grows with the size of the run. sqrt(files) encodes that: about 12 files
per worker at 25 files, about 40 at 400, saturating at the usable cores from
roughly 800 files upward. Full runs therefore keep the schedule they have today,
jobs and workers both, which the tests assert at 800 and 4524 files.

The job count follows the worker count, because the spawn loop stops when the
queue runs dry and a worker without a job of its own never starts.

Warm runs of phpstan-src, one cache snapshot per arm, 3 rounds, medians, with a
duplicate control arm (0.5 to 3.7 pct):

    files  default        adaptive        wall     CPU
        9  1w    4.32s    2w    4.19s    -3.0%    +25%
       25  1w    4.82s    3w    4.41s    -8.5%    +44%
       50  1w    6.93s    4w    5.11s   -26.3%    +50%
      100  2w    7.56s    5w    5.96s   -21.2%    +35%
      200  5w    8.21s    7w    7.50s    -8.6%    +14%

The trade is CPU for latency, so it is opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanderMuller
SanderMuller force-pushed the adaptive-parallel-worker-count branch from 5d93cef to abc3ff1 Compare September 17, 2026 10:57
@SanderMuller
SanderMuller changed the base branch from 2.2.x to 2.3.x September 17, 2026 10:57
@staabm

staabm commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

in the 2 tables of the PR description, it would be interessing how many processes were used in parallel (before the PR; and after the PR)

edit: I just realized what I am asking for is this "5w" in the description?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Yes, that is what the w meant, and it was not obvious. I have split it into its own column in both tables.

The counts are observed rather than derived. Each arm ran once with the live worker processes counted while it ran, before the timed rounds. Deriving them from the formula is how I got an earlier run wrong.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense.

other recent changes to the scheduler did not use a feature flag, thats why I think we don't need one here either.

my computers all have 14 cores, so I cannot provide new numbers ;-).
ondrej has a different spec and can test on different hardware.
maybe @VincentLanglet does also have different hardware available to test it

@ondrejmirtes

Copy link
Copy Markdown
Member

Two things that come to my mind before I think about this in depth:

  1. What if we decreased the jobSize dynamically for low numbers of analysed files? This is relevant with warm result cache, when a 5k+ files project might only analyse 50 files for example. Might be interesting to see a performance comparison with this.
  2. A worker's startup is a fixed cost

When forking workers there is essentially no cost. Run with -vv to see whether they spawn or fork in your case.

sqrt(files) dips below the existing job-count formula between roughly 400 and
800 files, so the rule quietly took workers away from large runs. On 600 warm
files it scheduled 12 where the default schedules 14, and measured 13.2% slower
against a 2.2% control.

The rule exists to stop small runs being starved, never to reduce a large one.
Take the maximum of the two, so the adaptive count can only ever raise
parallelism. Small runs are unchanged: 9, 25, 50, 100 and 200 files still
schedule 2, 3, 4, 5 and 7 workers.

The test asserts the invariant across 13 file counts rather than trusting a
table, because the dip only shows up in a band neither end of the range covers.

Reported by @staabm on the pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Both points land, and the second one corrects a claim I made.

Forking. You are right. diagnose here says Mechanism: fork (pcntl_fork), so there is no container boot per worker on this machine. The sentence in the description about a worker's startup being a fixed cost does not hold, and I have removed it. The shape was fitted to measurements rather than derived from that story, so the numbers stand, but my explanation for them did not. It also means the 1.2s I had attributed to worker startup in a one-file warm run is something else. I do not know what yet.

Decreasing jobSize instead. I measured it rather than reasoning about it. Same 50-file warm run, four rounds, medians:

arm processes wall CPU
default, jobSize: 20 1 7.28s 6.06s
default again, as a control 1 6.94s 5.79s
this PR, jobSize: 20 plus the toggle 4 5.19s 8.80s
jobSize: 6, toggle off 4 5.10s 8.54s

Your shape and mine are 1.6% apart against a 4.7% control, so they are the same thing. The jobSize that reproduces this PR's worker count is close to sqrt(files) at every size I checked:

files default this PR jobSize = min(20, round(sqrt(files))), worker formula untouched
25 1w 3w 2w (jobSize 5)
50 1w 4w 4w (jobSize 7)
100 2w 5w 5w (jobSize 10)
200 5w 7w 7w (jobSize 14)
300 7w 9w 9w (jobSize 17)
400 and above unchanged unchanged unchanged, because min(20, ...) stops there

I think your framing is the better one, for two reasons beyond it being a smaller change.

It cannot regress a large run. A smaller chunk only ever increases the job count, so floor(jobs / minimumNumberOfJobsPerProcess) can only go up. My version had no such guarantee. @staabm asked for the numbers above 200 files and found exactly that. sqrt() dips below the existing formula between roughly 400 and 800 files. At 600 warm files it scheduled 12 processes where the default schedules 14, and measured 13.2% slower against a 2.2% control. I have fixed it by taking the maximum of the two formulas. Your shape would not have needed the fix.

It also keeps the tuning in one place. jobSize already exists, is already documented, and is already the knob people reach for.

Two things it does not cover. At 9 and 25 files the sqrt chunk lands one worker lower than mine. Nine files measured -3.0% against a 0.5% control, so there is something there worth keeping. And at a fixed worker count I could not find a consistent jobSize effect. On 4524 files at 14 processes throughout, jobSize 20, 10, 5 and 2 gave 18.26s, 18.89s, 18.34s and 19.02s. That is non-monotonic against a 0.8% control. So the chunk size seems to matter only through the worker count it produces, which is why the two framings meet.

Happy to rebuild it as a jobSize rule. I would rather ask than guess which one you want to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants