Skip to content

Vitest worker count is a library default that oversubscribes this team's machines #2336

Description

@cliffhall

Found while landing #2323 (PR #2334), and it is the same class that issue is
about — a number nobody chose — one layer down. #2323 fixed the wall-clock
budgets; this is the concurrency that makes those budgets get spent.

The defect

No Vitest project in this repo sets maxWorkers — confirmed, nothing in any
vitest.shared.mts, client config or poolOptions names it — so all six inherit
Vitest's own default.

That default is Math.max(availableParallelism() - 1, 1) for a non-watch run
(getDefaultThreadsCount, node_modules/vitest/dist/chunks/cli-api.BK8pd4xc.js:2354,
Vitest 4.1.10). On the machine this team works on (Apple M3, 4 performance + 4
efficiency cores
, 8 logical) that is 7, not 8.

⚠️ Vitest's own CLI help text says default: os.availableParallelism(), which is
stale — the code subtracts one. Do not re-derive this number from --help.

Seven workers on eight logical cores still means only four of them can get a
performance core. The rest run the same workload on E-cores that are several
times slower, which is how a test that takes a second blows a 15-second budget.
That default assumes homogeneous cores. Every Apple Silicon machine violates that
assumption, and the whole team is on them.

One stage is already exempt: the browser pool (Storybook) self-caps at
Math.min(12, numCpus - 1), so it is not governed by whatever is decided
here.

Measured

344-file web unit suite, runs sequential, same machine, nothing else of ours
running. npx vitest run --project=unit --maxWorkers=N:

maxWorkers Wall vs 8 CPU in tests CPU in import
8 (explicit) 46s 138.7s 97.3s
6 56s +22% 120.5s 80.2s
4 60s +30% 99.0s 53.2s
3 70s +52% 88.1s 44.9s
2 93s +102% 82.6s 39.0s

⚠️ The baseline row is not the default. It was run as an explicit
--maxWorkers=8, one worker above the 7 this repo actually inherits. The shape of
the curve is very unlikely to change, but every percentage in the vs column is
measured against a configuration nothing runs. Re-measure with 7 as the
baseline before quoting any of these figures in a decision.

The CPU columns are the finding, not the wall column. Same 344 files and the
same assertions, but 8 workers burns 40% more CPU in tests and 2.5x more
in import
than 2 workers. That is contention overhead — cache thrash plus work
landing on efficiency cores. The default is not buying eight cores of
parallelism; it is buying roughly four and paying for eight.

⚠️ maxWorkers: 4 is not "one per performance core." macOS exposes no CPU
affinity API, and Vitest's workers are given no distinguishing QoS class, so the
scheduler is free to place any of them on an E-core — and with the main process
and v8 coverage instrumentation also runnable, it will. Capping reduces
contention; it does not buy P-core residency. 4 is a chosen number, not a
derived one, and whatever value is committed has to be justified as a chosen one.

What the table actually argues

Worth stating plainly, because it shapes the decision: the default is the
fastest configuration measured.
Every row below it is slower in wall clock. The
CPU columns only matter to the extent that something else wants those cycles —
and on this machine the only something else is another gate in another worktree.

So this is a politeness knob for the multi-session case, not a fix for a bad
single-session default. A developer running one gate alone pays the full wall-clock
cost for no benefit. That is a real trade to make deliberately; it is not a free
win, and the issue should not be closed as though it were one.

Why it matters beyond runtime

#2334's review took thirteen local:gate runs, of which roughly half went red —
every time on unrelated files with a bare Test timed out, a different set each
run, each passing in about a second in isolation. One showed 940 seconds of wall
clock against a 15-second budget
, which is a starved worker rather than a slow
assertion. Non-causation was provable on the later ones: zero files under
clients/ had changed since the last fully green run.

Not memory (55% free, zero swap) and not Docker (<1% CPU). Sampling during a gate
showed the spikes coming from the gate itself.

⚠️ The flake reduction is a well-grounded hypothesis, not a measured result.
The mechanism fits exactly, but proving it needs many runs at each setting — hours
of machine time — and nobody should read the table above as having demonstrated
it. Measuring that is part of this issue, not a premise of it.

The design question, which is why this is not a one-liner

Capping workers is a pure loss on CI. GitHub runners are not oversubscribed
and no evidence from #2323 or #2334 implicates them — CI went 8 for 8 green across
that PR's entire review. A committed maxWorkers: 4 would slow every CI run to
fix a problem CI does not have.

So the options, in rough order of preference:

  1. Set it in local:gate, as an env var read by vitest.shared.mts. CI never
    runs local:gate.github/workflows/main.yml invokes npm run validate
    (line 55) and npm run coverage (line 175) directly — so the cap lives in the
    one entry point that is local-only by construction. No CI cost, no CI
    sniffing, no knob anyone has to discover, and it propagates to every child
    script through the environment. verify:test-timeouts can assert both the
    plumbing and the value, which is exactly its existing charter.
    The gap to name: a bare npm run validate in the fast inner loop would not
    get the cap, since that script is shared with CI. Probably acceptable — the
    long contended runs are gate runs — but say so rather than leaving it implicit.
  2. Local-only via a bare env override. A TEST_MAX_WORKERS read in
    vitest.shared.mts next to TIMEOUTS, defaulting to unset (library behavior)
    and set by developers who want it. Honest, but a knob everyone has to discover
    is a knob nobody sets — the same objection Test-gate timeouts are still library defaults — the unit, tui and launcher projects and all 788 Testing Library waits fail correct tests under concurrent-worktree load #2323 raises against its own
    deferred INSPECTOR_TEST_TIMEOUT_SCALE. Option 1 is this option with the
    discovery problem solved.
  3. Key it off CI detection. process.env.CI ? undefined : 4. Gets the benefit
    by default locally at no CI cost, but makes the local gate and CI run
    measurably different configurations.
    ⚠️ The original filing cited the pinned-CLI reasoning in AGENTS.md
    against this. That citation was a poor fit — the pinned-CLI argument is about a
    different schema, one that can disagree about pass/fail on semantics, and
    maxWorkers changes nothing about what runs. The objection survives for a
    different reason:
    the budgets this knob interacts with are wall-clock, so a
    purely-scheduling difference can still flip an outcome. That is the whole
    premise of this issue, and it is the reason to write down, not the other one.
  4. Commit maxWorkers: 4 everywhere and accept the CI cost as the price of one
    number that means the same thing in both places.

⚠️ None of these compose across worktrees. Four sessions at maxWorkers: 4 is
still 16 workers on 8 cores — worse than one session at the inherited 7. This
narrows the self-inflicted share only — which the sampling suggests is the
larger one — and the cross-worktree lease #2323 raises and defers is the actual
fix for the multi-session case. Do not close that door by declaring this issue the
solution.

Work

  1. Re-measure the baseline at 7, the value actually inherited, and recompute
    the vs column against it. The existing table's reference point does not exist
    in practice.
  2. Re-run the sweep under v8 coverage (test:coverage), which is the heavier
    and more flake-prone stage, and on the tui project. The unit-suite shape
    should transfer but has not been checked.
  3. Measure the flake rate, not just runtime: N full runs at the default and at
    the candidate, counting Test timed out failures. That is the number this issue
    actually turns on.
    ⚠️ Decide up front whether this is affordable and whether it can be run
    cleanly.
    It is hours of machine time on a box whose contention is the thing
    under test, and a concurrent session in another worktree invalidates the sample
    — the control-arm problem from the gate-queue work: control-green /
    treatment-red is not a verdict if ambient load moved between the arms. If it
    cannot be run cleanly, say so and fall back to deciding on the CPU-burn
    evidence alone, with the flake claim recorded as a standing hypothesis.
  4. Pick an option above and say why in the code, the way TIMEOUTS does.
  5. If a value is committed, extend verify:test-timeouts to assert it — it already
    owns "the concurrency and budget numbers are stated, not inherited".

Acceptance

  • maxWorkers is either a stated value with its reasoning, or explicitly recorded
    here as "keep the default" with the measurement that justifies it.
  • The reasoning states the value as chosen, not as derived from the P-core
    count, since it cannot be.
  • The flake-rate measurement is recorded — or its infeasibility is, with the
    decision resting explicitly on the runtime and CPU evidence instead.
  • CI runtime impact is stated explicitly, whichever option is taken.

What #2339 means for this issue's ordering (from PR #2344)

The maxWorkers cap here and the #2339 lease are alternatives for the load half, and at two sessions the load half measured ~1.3x with no timeouts. With gates serialized, the oversubscription this issue exists to mitigate comes only from non-gate work (a bare npm run coverage in another session, Spotlight after an install). Measure this against a leased baseline before deciding it.

Activity

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

Metadata

Metadata

Assignees

Labels

choreMaintenance: deps, build tooling, CI, cleanup — no user-facing behavior changetestsUnit, smoke (e2e), Storybookv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions