[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

perf: scale reusable pool workers adaptively

Vladimir Sheremet (Jul 9, 2026, 10:03 AM +0200) 3c4e4590 96fa6d73

+94 -1
+94 -1
packages/vitest/src/node/pools/pool.ts
··· 12 12 13 13 const WORKER_START_TIMEOUT = 90_000 14 14 15 + // escape hatch while the adaptive worker scaling is validated across platforms 16 + const isAdaptiveScalingEnabled = process.env.VITEST_POOL_ADAPTIVE !== '0' 17 + 15 18 interface Options { 16 19 distPath: string 17 20 teardownTimeout: number ··· 38 41 private exitPromises: Promise<void>[] = [] 39 42 private _isCancelling: boolean = false 40 43 44 + // Workers start one at a time while the queue justifies another one 45 + // (mirrors the browser pool's adaptive session scaling): every worker pays 46 + // a spawn + runtime bring-up that competes with the already-running workers 47 + // for the same CPUs and Vite server, so fast suites finish sooner with 48 + // fewer workers, while suites with slower files still scale up to 49 + // `maxWorkers`. 50 + private _startingCount = 0 51 + // refined with the measured duration after every worker start 52 + private _spawnCost = 150 53 + // EMA of how long a single task takes; undefined until the first signal, 54 + // which means "keep starting workers like before". A reused worker's first 55 + // task pays the environment bring-up on top of the test, so it only counts 56 + // when the worker cannot be reused (then it is the true per-task cost). 57 + private _taskCostEma: number | undefined 58 + 41 59 constructor(private options: Options, private logger: Logger) {} 42 60 43 61 setMaxWorkers(maxWorkers: number): void { ··· 46 64 this.workerIds = new Map( 47 65 Array.from({ length: maxWorkers }).fill(0).map((_, i) => [i + 1, true]), 48 66 ) 67 + 68 + // a new task group can have a completely different per-task cost 69 + // (different pool, environment or project — e.g. typecheck tasks after 70 + // unit tests), so its scaling starts from a clean signal 71 + this._taskCostEma = undefined 49 72 } 50 73 51 74 async run(task: PoolTask, method: 'run' | 'collect'): Promise<void> { ··· 69 92 return 70 93 } 71 94 95 + if (isAdaptiveScalingEnabled && !this.canScheduleNext()) { 96 + // re-evaluated when the in-flight worker start settles or a task 97 + // finishes — both end with another `schedule()` call 98 + return 99 + } 100 + 72 101 const { task, resolver, method } = this.queue.shift()! 73 102 74 103 try { 75 104 let isMemoryLimitReached = false 76 105 const runner = this.getPoolRunner(task, method) 106 + const isFreshRunner = !runner.isStarted 77 107 78 108 const poolId = runner.poolId ?? this.getConcurrencyId() 79 109 runner.poolId = poolId ··· 121 151 WORKER_START_TIMEOUT, 122 152 ) 123 153 154 + this._startingCount++ 155 + const startedAt = performance.now() 156 + 124 157 await runner.start({ workerId: task.context.workerId }) 125 158 .catch(error => 126 159 resolver.reject( 127 160 new Error(`[vitest-pool]: Failed to start ${task.worker} worker for test files ${formatFiles(task)}.`, { cause: error }), 128 161 ), 129 162 ) 130 - .finally(() => clearTimeout(id)) 163 + .finally(() => { 164 + clearTimeout(id) 165 + this._startingCount-- 166 + }) 167 + 168 + if (!resolver.isRejected) { 169 + this._spawnCost = performance.now() - startedAt 170 + } 171 + 172 + // the next worker can start while this one runs its task 173 + void this.schedule() 131 174 } 132 175 133 176 let span: Span | undefined 177 + const requestedAt = performance.now() 134 178 135 179 if (!resolver.isRejected) { 136 180 span = runner.startTracesSpan(`vitest.worker.${method}`) ··· 142 186 await resolver.promise 143 187 .catch(error => span?.recordException(error)) 144 188 .finally(() => span?.end()) 189 + 190 + // the EMA only informs the scaling of reusable workers, and a fresh 191 + // worker's first task pays the environment bring-up on top of the 192 + // test itself — only steady-state tasks of reused workers count 193 + if (!resolver.isRejected && task.isolate === false && !isFreshRunner) { 194 + const taskCost = performance.now() - requestedAt 195 + this._taskCostEma = this._taskCostEma == null 196 + ? taskCost 197 + : this._taskCostEma * 0.7 + taskCost * 0.3 198 + } 145 199 146 200 const index = this.activeTasks.indexOf(activeTask) 147 201 if (index !== -1) { ··· 217 271 218 272 async close(): Promise<void> { 219 273 await this.cancel() 274 + } 275 + 276 + private canScheduleNext(): boolean { 277 + const { task } = this.queue[0] 278 + 279 + // isolated tasks pay a worker per task no matter what — there is no 280 + // avoidable bring-up cost for the scaling to save, so they keep the 281 + // unrestricted behavior 282 + if (task.isolate !== false) { 283 + return true 284 + } 285 + 286 + // an idle reusable worker adds no bring-up cost 287 + if (this.sharedRunners.some(runner => isEqualRunner(runner, task))) { 288 + return true 289 + } 290 + 291 + // fresh workers ramp up by doubling (in-flight starts never exceed the 292 + // workers that already run) — the queue may no longer justify more 293 + // workers by the time the current batch is up, while a long queue still 294 + // reaches `maxWorkers` in logarithmic time instead of one by one 295 + const startedWorkers = this.activeTasks.length - this._startingCount 296 + if (this._startingCount >= Math.max(1, startedWorkers)) { 297 + return false 298 + } 299 + 300 + // the first worker always starts; without a per-task signal keep the old 301 + // behavior of scaling straight up to `maxWorkers` 302 + if (this.activeTasks.length === 0 || this._taskCostEma == null) { 303 + return true 304 + } 305 + 306 + // only pay for another worker when the remaining work, split across the 307 + // workers we already have, still takes considerably longer than a worker 308 + // start costs — a new worker does not just cost its own bring-up, it 309 + // also competes with the running workers for the same CPUs 310 + const projectedDrainMs 311 + = (this.queue.length * this._taskCostEma) / this.activeTasks.length 312 + return projectedDrainMs > Math.max(this._spawnCost, 50) * 2 220 313 } 221 314 222 315 private getPoolRunner(task: PoolTask, method: 'run' | 'collect'): PoolRunner {