| id | 190 | ||
|---|---|---|---|
| title | Intra-file rule parallelism for non-NodeChecker rules | ||
| status | ✅ | ||
| model | opus | ||
| depends-on |
|
||
| summary | Run the non-NodeChecker subset of a file's rules concurrently within one file when the file-level worker pool has not saturated the available cores. Reverses plan 187's task 5 deferral — small-file LSP / PR runs leave most cores idle, so this is a free win on the exact paths users feel most. |
Parallelize the non-NodeChecker subset of a single file's rules when
the file-level worker pool leaves cores idle. Examples include
mdsmith lsp linting one file, or a 5-file PR check on a 16-core
host. Each rule's Check is independent: rule clones are per-call, the
File is read-only, and File.Memo is sync.Map-safe. Diagnostics
remain byte-identical to the sequential path because slots are
concatenated in rules order after the workers join.
Plan 187 task 5 deferred this work because the multiplex experiment was flat. A follow-up review judged the compound impact of small wins is the way to close the gap to faster Rust linters. An intra-file boost on the small-file LSP path lands directly on user-visible latency, not on batch wall time alone.
Concurrency model:
- The file-level pool (
Runner.runFiles) decides its worker count inResolveWorkers. Pass that count intocheckRulesvia a new parameter so the inner pool can decide how many cores remain. - Each non-NodeChecker rule's
Check(f)writes to its own slot only; results merge in rules order after the workers join, so output is byte-identical to serial. Runner.IntraFileConcurrencygates the behaviour: 0 = auto, 1 = disabled, n>1 = explicit cap. Auto picksmax(1, GOMAXPROCS / max(1, fileWorkers)).RunSource(LSP single-file path) usesGOMAXPROCSdirectly — there is no file-level pool to compete with.
- Write this plan.
- Add
Runner.IntraFileConcurrency(int, default 0 = auto). Thread the file-worker count fromrunFilesintolintFileandcheckRules. Compute the auto cap once per Run. - Refactor
checkRulesto run non-NodeChecker slots concurrently when the cap > 1, preserving slot order at the end. Keep the NodeChecker multiplex unchanged (a single shared walk is already internally serial). - Add tests in
internal/engine/intrafile_test.go:TestCheckRules_ParallelEqualsSequentialmirroring the multiplex byte-identity test;TestCheckRules_ParallelRespectsRulesOrderto pin slot order;TestCheckRules_ParallelHonorsCapusing an atomic concurrency counter inside a stub rule. - Run
go test -race ./internal/engine/...to confirm race-cleanness. - Add
BenchmarkCheckCorpusFewFilesplus a control variantBenchmarkCheckCorpusFewFilesNoIntraFile. Record numbers below.
Measured on the 4-vCPU sandbox; intra-file cap auto vs cap=1, same workload, 50 samples each:
BenchmarkCheckCorpusFewFiles: p95 ~3 ms with intra-file auto.BenchmarkCheckCorpusFewFilesNoIntraFile: p95 ~5 ms with cap=1.- Standing budgets unchanged:
BenchmarkCheckCorpusSmallp95 ~29 ms (budget 2 s);BenchmarkCheckCorpusLargep95 ~187 ms (budget 12 s).
The small/large numbers are flat because the file-level pool already saturates the four cores there. The few-files variant exercises the path this plan targets — the intra-file pool reclaims the cores left idle when the outer pool has nothing to do — and shows a ~40% latency drop.
-
Runner.IntraFileConcurrencyfield exists with the documented semantics (0=auto, 1=off, n>1 explicit cap). - Auto cap formula in code:
max(1, GOMAXPROCS / max(1, fileWorkers))forRun, andGOMAXPROCSforRunSource. - Diagnostic output remains byte-identical to the sequential path on every existing fixture.
- New tests pass under both
go test ./internal/engine/...andgo test -race ./internal/engine/.... -
mdsmith check .passes andgo tool golangci-lint runreports no issues. -
BenchmarkCheckCorpus{Small,Large}stays within budget.