Speed up SimpleCov.collate with optional multi-process merge - #1246
Speed up SimpleCov.collate with optional multi-process merge#1246danielwestendorf wants to merge 5 commits into
Conversation
6d3b765 to
9b9e939
Compare
15cc4d1 to
e3417da
Compare
Add SimpleCov.parallel_collate to fan the merge out across processes
Collating a large CI matrix's resultsets reads, parses and folds every
one
of them in sequence, and nearly all the wall clock goes into that fold.
`SimpleCov.parallel_collate` is `SimpleCov.collate` with the fold spread
across forked workers. Measured with `PROCESSES=N ruby
benchmarks/collate.rb`
(160 resultsets, 1836 files, 147,875 lines, 8,205 branch conditions,
branch
coverage enabled, 14 cores; store / format / thresholds skipped, since
the
fan-out only touches the merge phase):
processes merge
serial 8.53s
4 2.65s -68.9%
8 2.04s -76.1%
It takes `collate`'s arguments plus a required `processes:`. The count
is
deliberately not clamped to the core count nor gated on a minimum number
of
resultsets - only the caller knows what a collate job is allowed to use
-
and asking for more processes than there are result files just gives one
file per process. Below 1 it raises rather than quietly merging
serially.
The report is identical to `collate`'s for the same inputs, not merely
equivalent. Each worker folds a *contiguous* slice and the parent folds
the
slices back in index order, so the resultsets are visited in the order
the
serial fold visits them. That matters because visiting order is
observable:
`MethodsCombiner` retains the first key it sees for a given source
identity,
so a round-robin split would have produced a report differing from
`collate`'s in its method keys. Verified byte-identical against the
serial
fold over all 160 fixture resultsets at processes = 2, 3, 7, 8, 13, 160
and
400, and `features/test_unit_parallel_collate.feature` pins the same
percentages the existing collate feature asserts.
Notes:
- Every failure path returns nil rather than a partial merge, and the
caller
redoes the fold serially: reporting coverage for a subset of the
resultsets would silently understate it. That covers a runtime that
cannot
fork (JRuby, TruffleRuby, Windows - detected by the
NotImplementedError
the call raises, not by `respond_to?`), a worker that died, and a
payload
that came back truncated.
- Workers ship their folded pair back over a pipe, deserialized on a
thread
per worker so every pipe is drained while the workers are still
writing.
A payload larger than the pipe buffer would otherwise block its worker
mid-write, and the parent would block reaping a worker that can never
finish.
- A worker folds its slice one file at a time, as `merge_results` does,
so
memory scales with the worker count rather than the resultset count.
- Children end with `exit!` so they never fall through to the collating
process's at_exit handlers. `run_worker` returns the status rather
than
exiting itself, which keeps it exercisable in-process.
- `collate` and `parallel_collate` move to `lib/simplecov/collation.rb`,
sharing the validate / configure / finalize scaffolding. `collate` is
unchanged, including that it still merges via
`ResultMerger.merge_and_store`.
- `benchmarks/collate.rb` gains a `PROCESSES` knob so a parallel run can
be
compared against a serial baseline.
ca802ad to
d8df170
Compare
There was a problem hiding this comment.
🟡 Changes recommended
A few updated/new comments reference a non-existent SimpleCov.parallel_collate, and processes: should be coerced/validated to an Integer early to avoid runtime errors from non-integer inputs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR adds an optional multi-process merge path for SimpleCov.collate, allowing large sets of resultset shards (e.g., CI matrices) to be merged faster by fanning out the fold across forked workers while preserving identical merge order and results.
Changes:
- Add
processes:keyword toSimpleCov.collate(defaulting fromSIMPLECOV_CONCURRENCY) and route merging through a newSimpleCov::ParallelResultMergerwhenprocesses > 1. - Refactor
ResultMergerto extractmerge_resultsetsfor reuse by the parallel implementation. - Add extensive specs, documentation, and benchmark harness updates to validate correctness and measure performance.
File summaries
| File | Description |
|---|---|
| test_projects/faked_project/Rakefile | Adds a rake task exercising collate(..., processes: 2) in the fake project. |
| spec/support/merge_reference.rb | Adds a reference/oracle implementation of N-way merging for differential testing. |
| spec/support/merge_fuzzer.rb | Adds deterministic fuzz shard generation to hit adversarial merge edge cases. |
| spec/simplecov_spec.rb | Adds .collate across processes specs for equivalence and env/default behavior. |
| spec/result_merger_spec.rb | Adds unit coverage for ResultMerger.merge_resultsets and immutability of input paths. |
| spec/parallel_result_merger_spec.rb | Adds thorough specs for fan-out behavior, worker failure handling, chunking, etc. |
| spec/helper.rb | Introduces FORK_SUPPORTED and adjusts coverage filtering for fork-unreachable code paths. |
| spec/combine_differential_spec.rb | Adds end-to-end differential merge spec comparing fold vs reference rules. |
| sig/simplecov.rbs | Updates RBS signature/docs for SimpleCov.collate and adds merge_collated. |
| sig/internal/simplecov/result_merger.rbs | Adds RBS signature for new merge_resultsets helper. |
| sig/internal/simplecov/parallel_result_merger.rbs | Adds RBS definitions for the new parallel merger module. |
| README.md | Documents processes: usage, semantics, and fallback behavior. |
| lib/simplecov/result_processing.rb | Adds processes: to collate and routes through merge_collated. |
| lib/simplecov/result_merger.rb | Extracts merge_resultsets from merge_results for reuse and testing. |
| lib/simplecov/parallel_result_merger.rb | Introduces worker fan-out/collection implementation for parallel merges. |
| lib/simplecov.rb | Requires the new parallel_result_merger implementation. |
| features/test_unit_parallel_collate.feature | Adds cucumber feature asserting parallel collate produces identical output. |
| CHANGELOG.md | Documents the new processes: option, behavior, and benchmark results. |
| benchmarks/collate/runner.rb | Adds processes option and uses parallel merge in the merge phase when enabled. |
| benchmarks/collate/report.rb | Reports whether merge ran serially vs across forked workers; persists processes. |
| benchmarks/collate/cli.rb | Adds PROCESSES parsing and wiring into benchmark options. |
| benchmarks/collate.rb | Documents the new PROCESSES environment variable for the benchmark harness. |
Review details
- Files reviewed: 22/22 changed files
- Comments generated: 4
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| # Folds a list of resultset files into one merged coverage table across | ||
| # forked worker processes. Drives `SimpleCov.parallel_collate`. | ||
| # |
| # Above 1, the merge phase runs the fan-out `SimpleCov.parallel_collate` | ||
| # runs instead of the serial fold. Every later phase is unchanged, so a | ||
| # PROCESSES run is directly comparable to a serial baseline. |
| module SimpleCov | ||
| # | ||
| # Merges a list of resultset files into one coverage table across forked | ||
| # worker processes. Drives `SimpleCov.parallel_collate`. |
| def collate(result_filenames, profile = nil, processes: ENV.fetch("SIMPLECOV_CONCURRENCY", 1).to_i, | ||
| ignore_timeout: true, &) |
There was a problem hiding this comment.
Thanks for this PR! Overall, it looks quite good.
I agree with the first three Copilot comments. I’m not sure it’s necessary to coerce processes to an Integer (the fourth comment from Copilot), so feel free to skip that one.
Beyond what Copilot caught:
-
The differential test never exercises the parallel association. The "identical, not merely equivalent" claim depends on the combiners being associative: serial computes
((a+b)+c)+dwhile the fan-out computes(a+b)+(c+d). Same visiting order, different grouping.combine_differential_spec.rbonly compares the serial:foldagainst the reference oracle, and thestrategyparameter inmismatch_foris only ever called with:fold, which suggests a chunked strategy existed at some point and was dropped. The "same pair however many processes" spec uses simple line-only fixtures, so none of the adversarialreconcile_synthesizedor drifted-key cases the fuzzer generates ever go through the chunked path. Adding a:chunkedstrategy (chunk, fold each slice, fold the slices) would directly pin the property the whole PR rests on with relatively little code. -
The
succeeded?(-1)spec is fragile.Process.wait2(-1)doesn't mean "a pid I can't reap", it means "wait for any child". If an unrelated child process is alive when that example runs, the call can block or reap a stranger's child and returntrue. It only passes today because nothing else leaves children around. A guaranteed-nonexistent positive pid (or stubbingwait2to raiseErrno::ECHILD) would make it more deterministic. -
Naming nit:
merge_fuzzer.rbandmerge_reference.rbboth say they servespec/merge_differential_spec.rb, but the actual file isspec/combine_differential_spec.rb.
The PR description says non-fork runtimes are detected by the NotImplementedError the call raises rather than by respond_to?, but the code does the opposite (correctly, and the comment explains why). Also, the table’s percentages don’t match its own numbers (49s to 30s is about a 39% reduction, not 61%, and 49s to 28s is about 43%, not 77%). Mostly pointing this out for posterity, in case anyone ever looks back at this PR while researching or debugging a future issue.
One final thought: if fork raises EAGAIN partway through the fan-out, the already-spawned workers are never reaped and their readers leak. I agree with propagating the error, but an ensure in fan_out could still close readers and reap what was spawned before re-raising.
Happy to merge once the these issues are sorted.
This is an AI-Assisted PR
Collating the resultsets from a large CI matrix is slow in a way that scales badly. The collating process reads, parses and folds every shard in sequence, and nearly all the wall clock goes into that fold. This PR let's this process run on more than one core, speeding up overall collation.
Tested on real CI 1 process vs multi-processes (CI node cpu count = 4) for 160 real resultsets on a 231k line rails app saw
collatereduce from 49sec to ~30sec.The API change
SimpleCov.collate takes a new processes: argument:
It defaults to 1, which never forks, so existing calls are completely unaffected — same code path as before. Everything else about the signature is unchanged.
Early attempts used
Etc.nprocessorsto automatically tune process count, however, this often reports the number of processes of the host machine, not the vm/container of which it runs, so I opted for manual tuning. Setting ENV varSIMPLECOV_CONCURRENCYwill also work.Failure handling
Every failure path returns nil rather than a partial merge, and the caller redoes the fold in-process. Reporting coverage for a subset of the resultsets would silently understate it, which is a worse outcome than being slow. That covers:
Workers ship their folded pair back over a pipe, deserialized on a thread per worker so every pipe is drained while the workers are still writing — otherwise a payload larger than the pipe buffer blocks its worker mid-write and the parent blocks reaping a worker that can never finish. Children end with exit! so they never fall through to the collating process's at_exit handlers. Each worker folds its slice one file at a time, as the serial path does, so memory scales with the worker count rather than the resultset count.