Skip to content

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

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

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

Conversation

@eightbitraptor

@eightbitraptor eightbitraptor commented Sep 30, 2026 •

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 measured
iteration. The JSON output records raw per-worker samples, worker-sum series,
a controller-observed compaction count, and the measurement scope:
gc_scope: "ractor-local-workload", gc_stat_scope: "ractor-local",
gc_measure_total_time_scope: "ractor-local", plus the target's gc_config.

The target needs Ruby 4.1 or newer with per-Ractor global GC attribution
(ruby/ruby#19147). Older targets fail before warmup with
NotImplementedError; a fork-based probe checks the semantics without the
benchmark process ever creating a Ractor.

What it looks like

Per-iteration harness output:

r:   itr:   time   gc_total   marking  sweeping  gc_count     major     minor    global compacts*
(* controller-observed compacting cycles; may overlap global counts and is not additive.)
0    #1:   26ms    2.6ms      0ms      3ms         6         0         6         0         0
2    #1:   28ms    9.2ms      0ms      8ms       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/iter (worker sum)  controller compacts/iter*
object-new        0                   10.489        10.489                      1.000                       9.500                    6.5                      0.0
6.5                       0.0                        0.0
object-new        2                   23.860        11.930                      2.000                      20.000                  976.0                      2.0
974.0                       0.0                        0.0

Comparison summary (ratios, then 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          0.940        0.940              N/A             1.000            N/A           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.987        0.987              N/A             0.941            N/A           0.942                N/A        976.0  →  976.5             2.0  →
2.0          974.0  →  974.5               0.0  →   0.0  100%  →  100%

How the numbers compose

With ruby/ruby#19147, GC.stat(:global_gc_count) counts the global
(stop-the-world) cycles the calling Ractor initiated, and global cycles no
longer count as majors. Every worker-sum column is additive:

GCs/iter == minor/iter + major/iter + global/iter

global/iter is the global cycles the sampled workers initiated. No overlap,
no caveat paragraph.

One exception keeps its star: controller compacts/iter*. compact_count
increments in every object space during a global compacting cycle, so it can
never be worker-summed. We record the main Ractor's delta once per iteration.
The entire GC metric notes: block is one sentence:

- controller compacts/iter*: the main Ractor's GC.stat(:compact_count) delta per iteration. Every global compacting cycle increments compact_count in every object space. Do not
sum it across workers. Comparison tables show base → comparison values.

Failure handling and alignment

  • A worker or count-0 failure writes no partial JSON, and the measurement
    settings are restored. Covered by subprocess tests against a real build.
  • A target missing an optional counter keeps its series slot with JSON nulls.
    Indexes stay aligned, and the table renders N/A. Absence is never zeroed.

Feedback wanted

This is a draft mainly to get eyes on the presentation:

  1. Column names: (worker sum) suffixes, GC ms/worker, and the remaining
    starred controller compacts/iter*. Do these read well?
  2. GC ms/worker divides each iteration's worker-sum GC time by its sampled
    worker count, then takes the mean. Is the mean enough, or do we want
    max/median?
  3. The tables are wide. We have tried to keep output narrower than the
    GitHub comment box, and these columns break that. Layout suggestions are
    very welcome.

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.

GC.stat(:global_gc_count) now counts the global cycles the calling
Ractor initiated, and global cycles no longer count as majors:
count == minor + major + global in both scopes.

This removes the overlap that forced the controller-observed global
column. gc_global_count joins the worker-sum whitelist, the tables show
a plain additive global/iter (worker sum) column and an unstarred
global/iter ratio, and minor GC % divides by the total count. Only
controller compacts/iter keeps its star: compact_count increments in
every object space, so it still cannot be summed across workers.

The preflight gains a fork-based probe (the benchmark process never
creates a Ractor, so multi-ractor mode stays unarmed): a child arms a
Ractor, runs one explicit global GC.start, and the child's main Ractor
must see no scoped delta while the process total moves. Builds without
the attribution change fail before warmup with NotImplementedError.
@luke-gruber

Copy link
Copy Markdown
Collaborator

This is very useful for our work, so I'm +1 on merging it. It's a lot of code added to ruby-bench, but it's self-contained enough that I think it's okay.

@luke-gruber
luke-gruber self-requested a review October 2, 2026 13:34
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