Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Unreleased

## Enhancements
* The favicon (a solid square in the overall coverage band's colour) is now drawn by the viewer from the report's own palette instead of shipping as fixed PNGs, so it matches the report's green/yellow/red exactly and follows the light/dark theme, including the in-page toggle.
* `SimpleCov.collate` takes a new `processes:` argument that fans the resultset merge out across that many forked worker processes. This addresses the wall clock of a large CI matrix's collate step, where the collating process reads, parses and folds hundreds of resultsets in sequence and nearly all the time goes into that fold: merging 160 resultsets covering 1,836 files on a 14-core machine took 4.53s at the default `processes: 1` and 1.35s across 8 workers. The report is identical either way, not merely equivalent — each worker folds a contiguous slice of the file list and the collating process folds the slices back in order, so the resultsets are visited in the same order a single-process merge visits them. The fan-out lives in a new `SimpleCov::ParallelResultMerger`, whose `merge_resultsets` mirrors the `ResultMerger.merge_resultsets` extracted alongside it. `processes` defaults to the `SIMPLECOV_CONCURRENCY` environment variable (1 when unset), so one rake task can serve CI runners of different sizes without being edited, and an explicit argument wins over the variable. It never forks at 1, so existing `collate` calls are unaffected; it 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 — asking for more processes than there are result files just gives one file per process, and anything below 1 is taken as 1. Merging falls back to the collating process, with the same report and no error, when the runtime cannot fork (JRuby, TruffleRuby, Windows), when there is only one resultset, or when a worker dies. A `benchmarks/collate.rb` harness (`PROCESSES=N`) measures the phases against a saved baseline.

1.0.3 (2026-07-26)
==================
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,57 @@ namespace :coverage do
end
```

#### Fanning the merge out across processes

Collating a handful of resultsets is quick. Collating a few hundred is not: the collating process reads, parses and
folds every one of them in sequence, and on a large CI matrix that fold is where nearly all the wall clock goes.

Pass `processes:` to spread that fold across forked worker processes:

```ruby
# lib/tasks/coverage_report.rake
namespace :coverage do
desc "Collates all result sets generated by the different test runners"
task :report do
require 'simplecov'

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

The report is identical to the one a single-process collate produces for the same inputs, not merely equivalent: each
worker folds a contiguous slice of the file list and the collating process folds the slices back in order, so the
resultsets are visited in the same order they would be otherwise.

`processes` defaults to the `SIMPLECOV_CONCURRENCY` environment variable, or 1 when that is unset — and 1 never forks,
so existing `collate` calls behave exactly as before. Setting it in the environment lets one rake task serve runners of
different sizes without editing the task:

```sh
SIMPLECOV_CONCURRENCY=8 bundle exec rake coverage:report
```

An explicit `processes:` argument wins over the environment variable. The count is deliberately not clamped to your core
count, nor gated on some minimum number of resultsets: how many processes a collate job can afford is something only you
know. Asking for more processes than there are result files simply gives one file per process, and anything below 1 is
taken as 1, so a count computed from arithmetic that can reach zero needs no guarding.

It falls back to merging in the collating process — same report, no error — when the runtime cannot fork (JRuby,
TruffleRuby, Windows), when there is only one resultset to fold, or when a worker dies.

Merging 160 resultsets covering 1,836 files on a 14-core machine (`benchmarks/collate.rb`, so reproduce it on your own
hardware before budgeting for it):

| `processes:` | merge phase |
| ------------ | ----------- |
| 1 (default) | 4.53s |
| 4 | 1.70s |
| 8 | 1.35s |

Memory scales with the worker count rather than the resultset count: each worker folds its slice one file at a time, so
it holds one resultset plus its own running total, and the collating process holds one folded total per worker.

### Forked subprocesses

`SimpleCov.merge_subprocesses true` lets SimpleCov observe subprocesses started with `Process.fork`. It wraps Ruby's
Expand Down
4 changes: 4 additions & 0 deletions benchmarks/collate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@
#
# ruby benchmarks/collate.rb baseline
# COUNT=8 ruby benchmarks/collate.rb faster --baseline baseline
# PROCESSES=8 ruby benchmarks/collate.rb parallel --baseline baseline
#
# Environment:
# COUNT merge only the first N resultsets — the knob for a fast
# iteration loop; merge cost grows with N (default: 160)
# PROCESSES fan the merge phase out across N forked workers, as
# `SimpleCov.collate processes: N` does; 1 merges in this
# process (default: 1)
# SCALE divide `Shape::FILES` by this (default: 4, giving ~1,836 files;
# SCALE=1 generates the full 7,345)
# SKIP comma-separated trailing phases to skip, e.g. SKIP=format,store
Expand Down
22 changes: 16 additions & 6 deletions benchmarks/collate/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ module CollateBenchmark
module CLI
DEFAULT_SCALE = 4

DEFAULT_PROCESSES = 1

# `resultsets` rather than `count`, which would shadow `Struct#count`.
Options = Struct.new(:label, :resultsets, :scale, :skip, :rebuild, :baseline, :breakdown,
Options = Struct.new(:label, :resultsets, :scale, :skip, :rebuild, :baseline, :breakdown, :processes,
keyword_init: true)

class << self
Expand All @@ -21,16 +23,24 @@ def run(argv)
def options(argv)
argv = argv.dup
Options.new(
label: label(argv),
baseline: flag_value(argv, "--baseline"),
label: label(argv), baseline: flag_value(argv, "--baseline"),
resultsets: ENV.fetch("COUNT", Shape::RESULTSETS).to_i,
scale: ENV.fetch("SCALE", DEFAULT_SCALE).to_i,
skip: skip,
rebuild: ENV["REBUILD"] == "1",
breakdown: ENV["BREAKDOWN"] == "1"
skip: skip, rebuild: ENV["REBUILD"] == "1",
breakdown: ENV["BREAKDOWN"] == "1", processes: processes
)
end

# 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.
Comment on lines +34 to +36
def processes
count = ENV.fetch("PROCESSES", DEFAULT_PROCESSES).to_i
return count if count >= 1

raise ArgumentError, "PROCESSES must be at least 1 (got #{count})"
end

def label(argv)
argv.first && !argv.first.start_with?("-") ? argv.shift : "run"
end
Expand Down
10 changes: 8 additions & 2 deletions benchmarks/collate/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ def initialize(run:, timings:, baseline_label:)
def header(fixture)
puts
puts "SimpleCov collate benchmark — #{@run.label}"
print_summary(fixture)
puts
end

def print_summary(fixture)
merge = @run.processes > 1 ? "across #{@run.processes} forked workers" : "serial, in this process"
puts " resultsets: #{fixture.resultset_paths.size}"
puts " merge: #{merge}"
puts " fixture: #{fixture_summary(fixture)}"
puts " skipping: #{@run.skip.to_a.join(', ')}" if @run.skip.any?
puts
end

def fixture_summary(fixture)
Expand Down Expand Up @@ -112,7 +118,7 @@ def write(peak_rss, files_reported)
FileUtils.mkdir_p(Fixture::TIMINGS_DIR)
timings = {
"label" => @run.label, "scale" => @run.scale, "resultsets" => @run.resultsets_used,
"phases" => @timings, "total" => total, "peak_rss" => peak_rss,
"processes" => @run.processes, "phases" => @timings, "total" => total, "peak_rss" => peak_rss,
"files_reported" => files_reported, "ruby" => RUBY_DESCRIPTION,
# Instrumented runs carry a few percent of wrapper overhead; flagged so
# a future comparison knows not to trust this as a baseline.
Expand Down
32 changes: 28 additions & 4 deletions benchmarks/collate/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Runner
# only the three trailing phases can be dropped from a run.
SKIPPABLE_PHASES = %w[store format thresholds].freeze

attr_reader :label, :scale, :skip, :breakdown, :resultsets_used
attr_reader :label, :scale, :skip, :breakdown, :processes, :resultsets_used

def initialize(options)
@label = options.label
Expand All @@ -30,14 +30,15 @@ def initialize(options)
@rebuild = options.rebuild
@baseline_label = options.baseline
@breakdown = options.breakdown
@processes = options.processes
@timings = {}
end

def call
fixture = Fixture.prepare(scale: @scale, resultsets: @requested_resultsets, force: @rebuild)
@resultsets_used = fixture.resultset_paths.size
configure(fixture)
Breakdown.install! if @breakdown
install_breakdown if @breakdown

report = Report.new(run: self, timings: @timings, baseline_label: @baseline_label)
report.header(fixture)
Expand All @@ -47,6 +48,17 @@ def call

private

# The counters live in whichever process ran the wrapped method, so a
# forked worker's attribution dies with it and the merge row would come
# back near-empty. Say so rather than print a misleading table.
def install_breakdown
if processes > 1
warn "[#{@label}] BREAKDOWN only attributes work done in this process; " \
"the workers' share of the merge will be missing"
end
Breakdown.install!
end

def measure(fixture)
sampler = RssSampler.new
run_phases(fixture)
Expand Down Expand Up @@ -83,9 +95,21 @@ def run_phases(fixture)
@files_reported = result&.files&.size
end

# The read-and-fold loop out of `ResultMerger.merge_results`, stopping short
# of `create_result` so source-file building is timed separately.
# With PROCESSES > 1, the fan-out `SimpleCov.collate processes: N` performs —
# same merge, same visiting order, spread over forked workers. Falls
# through to the in-process loop if the fan-out bails, which is what
# `ResultMerger.merge_results` does too.
def merge_coverage(paths)
return serial_merge_coverage(paths) if processes < 2

SimpleCov::ParallelResultMerger.merge_resultsets(paths, processes: processes, ignore_timeout: true) ||
serial_merge_coverage(paths)
end

# `ResultMerger.merge_resultsets`, reproduced here so the per-resultset
# progress line can be printed, and stopping short of `create_result` so
# source-file building is timed separately.
def serial_merge_coverage(paths)
remaining = paths.dup
initial = valid_results(remaining.shift)

Expand Down
43 changes: 43 additions & 0 deletions features/test_unit_parallel_collate.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@test_unit
Feature:

Using SimpleCov.collate with processes: > 1 should get the user the same
coverage report a single-process collate does, with the merge fanned out
across forked workers.

Background:
Given I'm working on the project "faked_project"

Scenario:
Given SimpleCov for Test/Unit is configured with:
"""
require 'simplecov'
SimpleCov.start
"""

When I successfully run `bundle exec rake part1`
Then a coverage report should have been generated
When I successfully run `mv coverage/.resultset.json coverage/resultset1.json`
And I successfully run `rm coverage/index.html`

When I successfully run `bundle exec rake part2`
Then a coverage report should have been generated
When I successfully run `mv coverage/.resultset.json coverage/resultset2.json`
And I successfully run `rm coverage/index.html`

# Identical to the figures in test_unit_collate.feature: fanning the merge
# out changes how the resultsets are folded, not what they fold to.
When I open the coverage report generated with `bundle exec rake parallel_collate`
Then I should see the groups:
| name | coverage | files |
| All Files | 88.09% | 4 |

And I should see the source files:
| name | coverage |
| lib/faked_project.rb | 100.00% |
| lib/faked_project/some_class.rb | 80.00% |
| lib/faked_project/framework_specific.rb | 75.00% |
| lib/faked_project/meta_magic.rb | 100.00% |

And the report should be based upon:
| Unit Tests |
1 change: 1 addition & 0 deletions lib/simplecov.rb
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def warn_if_jruby_full_trace_disabled
require_relative "simplecov/last_run"
require_relative "simplecov/lines_classifier"
require_relative "simplecov/result_merger"
require_relative "simplecov/parallel_result_merger"
require_relative "simplecov/parallel_adapters"
require_relative "simplecov/command_guesser"
require_relative "simplecov/version"
Expand Down
Loading