Skip to content

Speed up SimpleCov.collate with optional multi-process merge - #1246

Open
danielwestendorf wants to merge 5 commits into
simplecov-ruby:mainfrom
danielwestendorf:add-parallel-collate
Open

Speed up SimpleCov.collate with optional multi-process merge#1246
danielwestendorf wants to merge 5 commits into
simplecov-ruby:mainfrom
danielwestendorf:add-parallel-collate

Conversation

@danielwestendorf

@danielwestendorf danielwestendorf commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 collate reduce from 49sec to ~30sec.

┌────────────────────────┬─────────────┬──────┐
│                        │ merge time  │      │
├────────────────────────┼─────────────┼──────┤
│ single process         │ 49s         │      │
├────────────────────────┼─────────────┼──────┤
│ processes: 4           │ 30s         │ −61% │
├────────────────────────┼─────────────┼──────┤
│ processes: 6           │ 28s         │ −77% │
└────────────────────────┴─────────────┴──────┘
image image

The API change

SimpleCov.collate takes a new processes: argument:

SimpleCov.collate Dir["simplecov-resultset-*/.resultset.json"], processes: 6

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.nprocessors to 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 var SIMPLECOV_CONCURRENCY will 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:

  • a runtime that cannot fork — JRuby, TruffleRuby, Windows. Detected by the NotImplementedError the call raises, not by respond_to?, since those runtimes define Process.fork and only raise when it's invoked
  • a worker that died
  • 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 — 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.

@danielwestendorf
danielwestendorf marked this pull request as draft July 31, 2026 13:52
@sferik
sferik force-pushed the main branch 3 times, most recently from 15cc4d1 to e3417da Compare August 3, 2026 23:01
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
danielwestendorf marked this pull request as ready for review August 4, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant