Skip to content

perf: scale reusable pool workers adaptively#10749

Open
sheremet-va wants to merge 4 commits into
mainfrom
perf/adaptive-pool-scaling
Open

perf: scale reusable pool workers adaptively#10749
sheremet-va wants to merge 4 commits into
mainfrom
perf/adaptive-pool-scaling

Conversation

@sheremet-va

@sheremet-va sheremet-va commented Jul 9, 2026

Copy link
Copy Markdown
Member

Description

Starts pool workers adaptively instead of one per queued task up front. For reusable workers (isolate: false, and vm pools' shared workers) the upfront strategy pays maxWorkers × (spawn + runtime bring-up + environment setup) even when a couple of reused workers would drain the queue sooner. Follows the same idea as the browser pool's adaptive sessions (#10726), which @hi-ogawa pointed out is a universal scheduling optimization that should transfer to the process pools.

  • The first min(maxWorkers, 4) workers start in parallel, exactly like before — any queue that reaches this point justifies them, and it keeps small machines (CI runners have 4 vcpus, so maxWorkers is 3) on the previous behavior byte for byte.
  • Beyond that, workers ramp up by doubling (in-flight starts never exceed already-running workers), so a long queue still reaches maxWorkers in logarithmic time.
  • Once a per-task cost signal exists, another worker only starts when the projected queue drain time across the running workers exceeds twice the measured worker start cost. The signal is an EMA over reused workers' tasks — a worker's first task pays the environment bring-up on top of the test, so it doesn't count — and resets per task group, since e.g. a typecheck group after a unit group has a completely different per-task cost.

Only tasks with isolate: false are gated. Isolated tasks pay a worker per task no matter what — there is no avoidable bring-up cost for the scaling to save — so they keep the exact previous behavior.

Benchmarks

macOS (M4, 10 cores → maxWorkers 9), 40/160-file fixtures, jsdom environment unless noted, interleaved A/B (median of 3, same build):

cell before after Δ
forks isolate:false, 40 files 1085ms 936ms −14%
threads isolate:false, 40 files 1005ms 883ms −12%
forks isolate:false, node env, 40 files 568ms 532ms −6%
forks isolate:false, 160 files, 1022 modules 3007ms 3074ms ~flat
forks/threads isolate:true, vmThreads ~flat (not gated / cost justifies max)

CI-sized runners (4 vcpus → maxWorkers 3, same-runner interleaved A/B, node env, median of 5):

cell windows before windows after Δ ubuntu Δ
forks isolate:false, 40 files 827ms 846ms +2.3% (noise) −0.6%
forks isolate:true, 40 files 3946ms 3981ms +0.9% −0.5%
forks isolate:false, 160 files 1378ms 1381ms +0.2% −0.1%
threads isolate:false, 40 files 850ms 855ms +0.6% −0.5%

The win scales with core count and per-worker bring-up cost (environment setup dominates it for jsdom), so the beneficiaries are many-core machines; small runners stay on the previous behavior by construction.

Comment thread .github/workflows/bench-adaptive.yml Fixed
@netlify

netlify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Name Link
🔨 Latest commit 7d4bb6e
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a4f55f03edfa9000717d1b7
😎 Deploy Preview https://deploy-preview-10749--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread .github/workflows/bench-adaptive.yml Fixed
@sheremet-va sheremet-va marked this pull request as ready for review July 9, 2026 13:01

@AriPerkkio AriPerkkio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All this calculation seems complex. What does it do in practice? I didn't understand most of this PR based on the generated description.

Comment on lines +169 to +170
// the next worker can start while this one runs its task
void this.schedule()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks a lot like #8755

private _startingCount = 0
// refined with the measured duration after every worker start
private _spawnCost = 150
// EMA of how long a single task takes; undefined until the first signal,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's EMA?

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The browser side PR was partly reverted #10753. How heuristics can fail is pictured in https://artifacts.hiro18181.workers.dev/vitest-pr-10726-adaptive-sessions#failure-mode

This PR changes on node pool has slightly different code flow, but it should have effectively same characteristics where once it caps spawning new worker, it cannot grow back. This is because, while this PR changed so that one schedule can call two schedule, this "schedule ramp up chance" is given only to new worker (first we have 4 chances), so once each "chance" misses scaling opportunity due to small fileCost for earlier tests, it doesn't grow back even when later fileCost turn out to be large.

We should craft a similar adversarial examples like #10753 to verify.

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.

4 participants