Skip to content

Conversation

@KevSlashNull
Copy link

The LinesCombiner uses the Array#zip method to merge both line coverage arrays A and B together. This creates a new array, immediately discarding A and B. When we're combining a lot of files, this creates significant pressure on the garbage collection because we're allocating a lot of intermediate arrays.

For example, all GitLab unit tests emit about 328 MB of minified, uncompressed .resultset.json files. If we use SimpleCov.collate to merge them (using the default configuration), we invoke the GC over 1,700 times.

If we instead reuse the longest array A or B to merge coverage in-place without creating a new array, we reduce the GC invocations to 55.

This results in a total reduction in GC time by around 90%. On my M3 Max, this reduces the garbage collection time with our unit test coverage result sets from 2.78s to 0.32s. In total, we're observing a total speed up of coverage merging in this example of about 25% (9.55 -> 7.05s).

Because both approaches of using Array#zip or updating the array in-place are functionally identical, we can replace the former with the latter without any other changes.

How to test locally

It's a bit cumbersome to set this up without setting up the whole GitLab project. We can try though.

  1. Download the .resultset.json for our unit tests. This link might break at some point (then you'd have to look for another pipeline that runs our unit tests): https://gitlab.com/gitlab-org/gitlab/-/jobs/11891530948 (on the right, click on Download)
  2. Unzip the archive into <simplecov_repo>/test
  3. Create a test file:
    require_relative './lib/simplecov.rb'
    
    SimpleCov.collate(Dir['./test/**/.resultset.json'])
  4. Run time ruby test.rb, both on the default branch and this
  5. Observe the time difference
  6. Optionally use a gem like singed or ruby-prof to flamegraph, or use GC::Profiler to get a GC profile

Hyperfine results

Benchmark 1: before
  Time (mean ± σ):     11.669 s ±  0.309 s    [User: 11.256 s, System: 0.354 s]
  Range (min … max):   11.224 s … 12.067 s    10 runs
 
Benchmark 2: after
  Time (mean ± σ):      8.082 s ±  0.038 s    [User: 7.765 s, System: 0.301 s]
  Range (min … max):    8.045 s …  8.150 s    10 runs
 
Summary
  after ran
    1.44 ± 0.04 times faster than before

GC dump

Before

GC 1724 invokes.
Index    Invoke Time(sec)       Use Size(byte)     Total Size(byte)         Total Object                    GC Time(ms)
    1               0.121              1684320              4651920               116298         0.82099999999998840572
    2               0.122              1681840              4717440               117936         0.08199999999999874056
[...]
 1708              11.013             10815880             66895920              1672398         1.22499999999803321771
 1709              11.018             10938280             66895920              1672398         1.20899999999579677024

After

GC 55 invokes.
Index    Invoke Time(sec)       Use Size(byte)     Total Size(byte)         Total Object                    GC Time(ms)
    1               0.119              1689400              4651920               116298         0.95399999999998263966
    2               0.120              1685640              4717440               117936         0.08099999999996998490
[...]
   39               7.261              9120800             76003200              1900080         9.25800000000087663921
   40               7.587                    0                    0                    0         7.97399999999726105671

Flamegraph

Before

image

After

image

The LinesCombiner uses the `Array#zip` method to merge both line
coverage arrays A and B together. This creates a new array, immediately
discarding A and B.

When we're combining a lot of files, this creates significant pressure
on the garbage collection because we're allocating a lot of intermediate
arrays.

For example, all GitLab unit tests emit about 328 MB of minified,
uncompressed `.resultset.json` files. If we use `SimpleCov.collate` to
merge them (using the default configuration), we invoke the GC over
1,700 times.

If we instead reuse the longest array A or B to merge coverage in-place
without creating a new array, we reduce the GC invocations to 55.

This results in a total reduction in GC time by around 90%. On my M3
Max, this reduces the garbage collection time with our unit test
coverage result sets from 2.78s to 0.32s. In total, we're observing a
total speed up of coverage merging in this example of about 25% (9.55 ->
7.05s).

Because both approaches of using `Array#zip` or updating the array
in-place are functionally identical, we can replace the former with the
latter without any other changes.
@splattael
Copy link
Contributor

Related to #1121.

@amatsuda Do you have time to look at this or the other PR to speed up simplecov for large resultsets? 🙏

@amatsuda amatsuda merged commit afcf15e into simplecov-ruby:main Oct 29, 2025
10 checks passed
@amatsuda
Copy link
Member

@KevSlashNull Thank you for the patch and detailed explanation. I haven't indeed experienced a situation where this part becomes a bottleneck, but I merged this because the patch doesn't seem like a bad thing anyway.

@KevSlashNull KevSlashNull deleted the lines-gc-pressure branch October 29, 2025 16:26
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.

3 participants