Skip to content

Add opt-in Ractor-local GC metrics to the Ractor harness - #537

Draft
eightbitraptor wants to merge 6 commits into
mainfrom
mvh-gc-process-stat
Draft

eightbitraptor wants to merge 6 commits into
mainfrom
mvh-gc-process-stat

Conversation

@eightbitraptor

Copy link
Copy Markdown
Member

./run_benchmarks.rb --category ractor --ractor-gc samples GC.stat and GC total time in every worker Ractor's own object space on each iteration, and also records process-wide GC counters observed by the main Ractor.

We record all data in the JSON output as well as teh summary tables.

The target needs Ruby 4.1 or newer (specifically after this PR ruby/ruby#18976). Older versions raise NotImplementedError instead of producing misleading numbers.

What it looks like

Per-iteration harness output:

r:   itr:   time   gc_total   marking  sweeping  gc_count     major     minor   global* compacts*
(* process/controller-observed; may overlap the other GC counts and is not additive.)
0    #1:   30ms    3.8ms      0ms      4ms         6         0         6         0         0
2    #1:   39ms   12.8ms      0ms     10ms       976         2       974         0         0

Single-executable summary (absolute columns):

bench       ractors  GC ms/iter (worker sum)  GC ms/worker  mark ms/iter (worker sum)  sweep ms/iter (worker sum)  GCs/iter
(worker sum)  major/iter (worker sum)  minor/iter (worker sum)  global GCs/iter*  controller compacts/iter*
object-new        0                    3.016         3.016                      0.500                       2.500
6.5                      0.0                      6.5               0.0                        0.0
object-new        2                   10.770         5.385                      0.000                       8.500
976.0                      2.0                    974.0               0.0                        0.0

Comparison summary (ratios + base → comparison counts):

bench       ractors  gc/iter ratio  gc/GC ratio  mark/iter ratio  sweep/iter ratio  mark/GC ratio  sweep/GC ratio  global/iter
ratio*  GCs/iter (worker sum)  major/iter (worker sum)  minor/iter (worker sum)  controller compacts/iter*     minor GC %
object-new        0          1.069        1.069            0.000             1.000          0.000           1.000
N/A           6.5  →   6.5             0.0  →   0.0             6.5  →   6.5               0.0  →   0.0  100%  →  100%
object-new        2          0.981        0.981              N/A             1.000            N/A           1.000
N/A        976.0  →  976.0             2.0  →   2.0          974.0  →  974.0               0.0  →   0.0  100%  →  100%

We've also added notes to the end of the run alongside the existing legend that explains some nuances in the numbers:

- global GCs/iter*: GC.stat(:global_gc_count) deltas per iteration. They count stop-the-world global GC cycles observed by the measuring main Ractor, not the process-wide total of all Ractors' local and global collections. Stop-the-world cycles only exist once a second Ractor has been alive, so count-0 rows read 0.0 unless the workload itself creates Ractors.
- controller-observed deltas can overlap the (worker sum) columns: a global or compacting cycle triggered by a sampled worker is already included in that worker's counts as a major GC. They can also reflect cycles from Ractors the harness does not sample. Do not add the starred columns to the worker sums.

The global GC count shenanigans

The asterisks and the notes are needed because these numbers overlap in a way the counters cannot currently separate:

  • A stop-the-world global cycle is attributed to the Ractor that triggered it: the driving objspace's major_gc_count increments (gc/default/default.c), and bystanders' local counts do not move.

  • GC.stat(:global_gc_count) is a process-wide counter mirrored into every Ractor's scoped GC.stat — a worker's global cycles move the main Ractor's scoped read too.

  • So a worker's local counts already include the global cycles it triggered, counted as majors. A row reading gc_count 41, major 10, global 4 can contain those same 4 cycles inside the 41. The starred columns are a view of a subset so you can't add them together

  • compact_count counts "my object space was compacted" per object space, so one global compacting cycle increments it in every Ractor. It can never be worker-summed; we record the main Ractor's delta once per iteration.

  • I am working on a small Ruby feature to add triggered_global_gc_count to the Ractor's local GC.stat output, which will allow us to calculate exact numbers.

Feedback wanted

This is a draft mainly to get eyes on the presentation, in order to start gathering feedback while I work on the triggered_global_gc_count change:

  1. Column names. (worker sum) suffixes, GC ms/worker, and the starred global GCs/iter* / global/iter ratio* / controller compacts/iter*. Does the * read as "see the notes" or is it noise? Better names or suggestions on how to present this better are very welcome.

  2. Do we even need the new CRuby counter to get a full picture or is it enough that the total gc count also contains the globals such that major + minor + global doesn't equal gc_count?

  3. GC ms/worker divides each iteration's worker-sum GC time by its sampled worker count, then takes the mean. Is mean good enough, or shall we have max/median instead.

  4. We've always tried to keep the table output narrower than the github comment box. Can we even do that anymore. Any suggestions for a better layout are very welcome here.

Add gc_total_time_warmup/bench in milliseconds and
gc_global_count_warmup/bench (on Ruby versions with support for
global_gc_count) to the JSON output, plus a global column in the
per-iteration stdout table.

Ignore this if the benchmarked Ruby doesn't support global gc counts
(introduced during Ruby 4.1 dev cycle).
enable with RUBY_BENCH_RACTOR_GC=1

The mode requires Ruby 4.1 or newer (because of the Ractor-local GC.stat
and per-Ractor GC.measure_total_time);
Passing --ractor-gc to run_benchmarks.rb now sets RUBY_BENCH_RACTOR_GC=1
@luke-gruber

luke-gruber commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

I think having a miscount of major_gc_count per objspace during global GC is fixable in Ruby. We could just not increment it during global GC.

@eightbitraptor

eightbitraptor commented Sep 30, 2026 •

Copy link
Copy Markdown
Member Author

@luke-gruber ruby/ruby#19147 I went a different way, by tracking the number of globals initiated by each Ractor. We can then subtract that from the major count to get the true local major count. It's difficult and I guess we could go one way or the other but I opted not to just discount globals as majors, because they do run a major GC. I'm not wedded to this decision though.

@luke-gruber

luke-gruber commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

I don't know, that seems kind of hacky. The fact that global GC does a pseudo-"major" GC on each objspace is an implementation detail that imo shouldn't leak out to GC.stat. For :triggered_global_count, Why not just have GC.stat(:global_gc_count) give the # triggered by the current Ractor, and GC.stat(:global_gc_count, scope: :global) give the total count?

Edit: to be honest, I don't really care how they're measured so if it's easier to do it that way I'm fine with it. What matters is getting good benchmarks and then fixing the GC issues.

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.

2 participants