Speed up SimpleCov.collate with optional multi-process merge - #1246
Draft
danielwestendorf wants to merge 5 commits into
Draft
Speed up SimpleCov.collate with optional multi-process merge#1246danielwestendorf wants to merge 5 commits into
danielwestendorf wants to merge 5 commits into
Conversation
danielwestendorf
marked this pull request as draft
July 31, 2026 13:52
danielwestendorf
force-pushed
the
add-parallel-collate
branch
from
August 3, 2026 03:19
6d3b765 to
9b9e939
Compare
sferik
force-pushed
the
main
branch
3 times, most recently
from
August 3, 2026 23:01
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.
danielwestendorf
force-pushed
the
add-parallel-collate
branch
from
August 4, 2026 02:58
ca802ad to
d8df170
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.