Skip to content

Commit 7d30503

Browse files
Collect Ractor GC samples in harness-ractor-mem
With --ractor-gc (RUBY_BENCH_RACTOR_GC=1), harness-ractor-mem checks target support before the baseline and writes gc_by_ractors in the same shape as harness-ractor. Scenarios spawn their own workers, so each worker wraps its body in measure_worker_gc and the main Ractor passes the sample to record_worker_gc. A trial fails when its recorded worker indexes are not 0...count. ractor-idle-garbage takes its sample while the set is still live and sends a shareable message, so sampling cannot sweep the garbage before the worker parks.
1 parent 3c6f9a3 commit 7d30503

6 files changed

Lines changed: 215 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ On a target with Ractor-local GC, a plain `GC.start` collects only the main
198198
Ractor's object space. The JSON field `ractor_mem_settle` records `global` or
199199
`default`.
200200

201+
With `--ractor-gc` (`RUBY_BENCH_RACTOR_GC=1`), every worker samples its own
202+
Ractor-local GC activity. The results then carry the same `gc_by_ractors` data
203+
as `harness-ractor`. A scenario wraps each worker body in
204+
`measure_worker_gc { ... }`, which returns `[result, sample]`. The main Ractor
205+
passes each sample to `record_worker_gc(worker_index, sample)`. A trial fails
206+
when its recorded worker indexes are not `0...count`.
207+
208+
Worker samples cover only the workers' own object spaces during the scenario.
209+
They do not include allocation by the main Ractor, such as the payloads that
210+
`ractor-msg-backlog` sends. They also do not include the GCs that the harness
211+
runs to measure retention. The JSON field `gc_controller_samples` covers the
212+
main Ractor during the scenario.
213+
201214
## Ruby options
202215

203216
By default, ruby-bench benchmarks the Ruby used for `run_benchmarks.rb`.
@@ -337,7 +350,8 @@ process's lifetime peak from `getrusage`.
337350
## Measuring Ractor GC activity
338351

339352
The `--ractor-gc` option of `run_benchmarks.rb` collects Ractor-local GC
340-
metrics for benchmarks that use the Ractor harness (`--category ractor`).
353+
metrics for benchmarks that use `harness-ractor` or `harness-ractor-mem`
354+
(`--category ractor`).
341355
The target must use Ruby 4.1 or newer with per-Ractor global GC attribution
342356
([ruby/ruby#19147](https://github.com/ruby/ruby/pull/19147)); older targets
343357
fail before warmup.

‎benchmarks/ractor-dead-set/benchmark.rb‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,24 @@
1111
run_benchmark(3) do |num_ractors|
1212
workers = num_ractors.times.map do |worker|
1313
Ractor.new(worker, DEAD_SET_ITEMS) do |worker_id, items|
14-
keep = []
15-
i = 0
16-
while i < items
17-
keep << "worker #{worker_id} item #{i} " + ("y" * 100)
18-
i += 1
14+
measure_worker_gc do
15+
keep = []
16+
i = 0
17+
while i < items
18+
keep << "worker #{worker_id} item #{i} " + ("y" * 100)
19+
i += 1
20+
end
21+
keep.size
1922
end
20-
keep.size
2123
end
2224
end
2325

24-
total = workers.sum(&:value)
26+
total = 0
27+
workers.each_with_index do |worker, worker_id|
28+
size, sample = worker.value
29+
record_worker_gc(worker_id, sample)
30+
total += size
31+
end
2532
raise "unexpected dead-set size" unless total == num_ractors * DEAD_SET_ITEMS
2633
nil
2734
end

‎benchmarks/ractor-idle-garbage/benchmark.rb‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,30 @@
1212

1313
run_benchmark(3) do |num_ractors|
1414
drained = Ractor.new(num_ractors) do |count|
15-
count.times { Ractor.receive }
16-
:all_drained
15+
Array.new(count) { Ractor.receive }
1716
end
1817

1918
workers = num_ractors.times.map do |worker|
2019
Ractor.new(drained, worker, IDLE_GARBAGE_ITEMS) do |ack, worker_id, items|
2120
keep = []
22-
i = 0
23-
while i < items
24-
keep << "worker #{worker_id} garbage #{i} " + ("y" * 100)
25-
i += 1
21+
_, sample = measure_worker_gc do
22+
i = 0
23+
while i < items
24+
keep << "worker #{worker_id} garbage #{i} " + ("y" * 100)
25+
i += 1
26+
end
2627
end
28+
message = Ractor.make_shareable([worker_id, sample])
2729
keep = nil
28-
ack.send :drained
30+
ack.send message
2931
Ractor.receive
3032
:worker_done
3133
end
3234
end
3335

34-
raise "unexpected drain barrier result" unless drained.value == :all_drained
36+
drained_workers = drained.value
37+
raise "unexpected drain barrier result" unless drained_workers.size == num_ractors
38+
drained_workers.each { |worker_id, sample| record_worker_gc(worker_id, sample) }
3539

3640
proc do
3741
workers.each do |worker|

‎benchmarks/ractor-msg-backlog/benchmark.rb‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
run_benchmark(3) do |num_ractors|
1717
consumers = num_ractors.times.map do |consumer|
1818
Ractor.new(consumer, BACKLOG_GATE_SLEEP) do |consumer_id, gate|
19-
sleep gate
20-
taken = 0
21-
loop do
22-
message = Ractor.receive
23-
break if message == :done
24-
taken += 1
19+
measure_worker_gc do
20+
sleep gate
21+
taken = 0
22+
loop do
23+
message = Ractor.receive
24+
break if message == :done
25+
taken += 1
26+
end
27+
taken
2528
end
26-
[consumer_id, taken]
2729
end
2830
end
2931

@@ -36,7 +38,12 @@
3638
consumer.send :done
3739
end
3840

39-
results = consumers.map(&:value)
40-
raise "unexpected backlog drain" unless results.sum { |_, taken| taken } == num_ractors * BACKLOG_MESSAGES
41+
total = 0
42+
consumers.each_with_index do |consumer, consumer_id|
43+
taken, sample = consumer.value
44+
record_worker_gc(consumer_id, sample)
45+
total += taken
46+
end
47+
raise "unexpected backlog drain" unless total == num_ractors * BACKLOG_MESSAGES
4148
nil
4249
end

‎harness-ractor-mem/harness.rb‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
Warning[:experimental] = false
55

6+
RACTOR_GC_ENABLED = ENV["RUBY_BENCH_RACTOR_GC"] == "1"
7+
require_relative "../lib/gc_stats" if RACTOR_GC_ENABLED
8+
69
default_ractors = [1, 2, 4, 6, 8]
710
if rs = ENV["RUBY_BENCH_RACTORS"]
811
rs = rs.split(",").map(&:to_i)
@@ -17,6 +20,7 @@
1720
PEAK_SAMPLE_INTERVAL = Float(ENV.fetch("RACTOR_MEM_PEAK_SAMPLE_INTERVAL", 0.005))
1821

1922
GLOBAL_GC_START = GC.method(:start).parameters.include?([:key, :global])
23+
WORKER_GC_SAMPLES = []
2024

2125
puts RUBY_DESCRIPTION
2226

@@ -58,33 +62,65 @@ def measure_peak_rss
5862
[result, peak]
5963
end
6064

65+
def measure_worker_gc
66+
return [yield, nil] unless RACTOR_GC_ENABLED
67+
68+
result = nil
69+
sample = GCStats.measure { result = yield }
70+
[result, sample]
71+
end
72+
73+
def record_worker_gc(worker_index, sample)
74+
return unless sample
75+
76+
WORKER_GC_SAMPLES << sample.merge("worker_index" => worker_index)
77+
end
78+
6179
def run_benchmark(num_itrs_hint, &scenario)
6280
bench_itrs = Integer(ENV.fetch("MIN_BENCH_ITRS", num_itrs_hint))
6381
bench_itrs = MAX_ITERS if bench_itrs > MAX_ITERS
6482
bench_itrs = 1 if bench_itrs < 1
6583

84+
if RACTOR_GC_ENABLED
85+
GCStats.check_ractor_gc_support!
86+
gc_config = GC.config.transform_keys(&:to_s) if GC.respond_to?(:config)
87+
GCStats.with_measure_total_time { run_memory_trials(bench_itrs, gc_config, &scenario) }
88+
else
89+
run_memory_trials(bench_itrs, nil, &scenario)
90+
end
91+
end
92+
93+
def run_memory_trials(bench_itrs, gc_config, &scenario)
6694
gc_settle
6795
base_rss = get_rss
6896
puts format("base RSS: %.1f MiB", base_rss / 2**20.0)
6997

7098
metrics = Hash.new { |h, count| h[count] = { retained: [], peak: [] } }
7199
times = Hash.new { |h, count| h[count] = [] }
100+
gc_groups = Hash.new do |h, count|
101+
h[count] = { "gc_worker_samples" => [], "gc_controller_samples" => [] }
102+
end
72103

73104
RACTORS.each do |count|
74105
puts "ractors: #{count}"
75106
itr = 0
76107
while itr < bench_itrs
108+
WORKER_GC_SAMPLES.clear
109+
controller_before = GCStats.snapshot if RACTOR_GC_ENABLED
77110
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78111
finish, peak = measure_peak_rss { scenario.call(count) }
79112
times[count] << Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
113+
controller_sample = GCStats.delta(controller_before, GCStats.snapshot) if RACTOR_GC_ENABLED
80114
gc_settle
81115
retained = get_rss - base_rss
82116
finish&.call
83117
gc_settle
84118
metrics[count][:retained] << retained
85119
metrics[count][:peak] << peak
86-
puts format(" itr #%d: retained %.1f MiB, peak %.1f MiB",
120+
line = format(" itr #%d: retained %.1f MiB, peak %.1f MiB",
87121
itr + 1, retained / 2**20.0, peak / 2**20.0)
122+
line << record_trial_gc(gc_groups[count], count, controller_sample) if RACTOR_GC_ENABLED
123+
puts line
88124
itr += 1
89125
end
90126
end
@@ -109,11 +145,38 @@ def run_benchmark(num_itrs_hint, &scenario)
109145
puts format("BENCH_METRIC peak_mib=%.1f", worst_peak / 2**20.0)
110146
puts format("BENCH_METRIC worst_ractor_count=%d", worst_retained_count)
111147

112-
return_results([], times.values.flatten,
148+
extra = {
113149
bench_by_ractors: times,
114150
ractor_mem_settle: GLOBAL_GC_START ? "global" : "default",
115151
ractor_mem_base_rss: base_rss,
116152
ractor_mem_medians: medians,
117153
ractor_mem_samples: metrics,
118-
)
154+
}
155+
if RACTOR_GC_ENABLED
156+
extra[:gc_scope] = "ractor-local-workload"
157+
extra[:gc_stat_scope] = "ractor-local"
158+
extra[:gc_measure_total_time_scope] = "ractor-local"
159+
extra[:gc_by_ractors] = gc_groups
160+
extra[:gc_config] = gc_config if gc_config
161+
end
162+
return_results([], times.values.flatten, **extra)
163+
end
164+
165+
def record_trial_gc(group, count, controller_sample)
166+
workers = WORKER_GC_SAMPLES.sort_by { |s| s["worker_index"] }
167+
unless workers.map { |s| s["worker_index"] } == (0...count).to_a
168+
raise "scenario recorded worker GC samples #{workers.map { |s| s["worker_index"] }.inspect} for #{count} ractors"
169+
end
170+
171+
group["gc_worker_samples"] << workers
172+
group["gc_controller_samples"] << controller_sample
173+
agg = GCStats.aggregate(workers)
174+
GCStats::RACTOR_SERIES.each do |series, field|
175+
value = field == GCStats::TOTAL_TIME_FIELD ? agg[field]&.fdiv(1_000_000) : agg[field]
176+
(group[series] ||= []) << value
177+
end
178+
179+
format(", worker gc %s (minor %s major %s global %s) %s",
180+
agg["gc_count"], agg["gc_minor_count"], agg["gc_major_count"], agg["gc_global_count"],
181+
agg["gc_total_time_ns"] ? format("%.1fms", agg["gc_total_time_ns"] / 1_000_000.0) : "N/A")
119182
end

‎test/ractor_gc_harness_test.rb‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,32 @@ def GCStats.global_gc_attributed?(*) = false
103103
puts "workload_ran=#{workload_ran}"
104104
RUBY
105105

106+
MEM_WORKLOAD_BODY = <<~'RUBY'
107+
run_benchmark(2) do |count|
108+
workers = count.times.map do |worker|
109+
Ractor.new(worker) do |worker_id|
110+
measure_worker_gc do
111+
20_000.times { Object.new }
112+
GC.start(full_mark: true, immediate_sweep: true)
113+
worker_id
114+
end
115+
end
116+
end
117+
workers.each_with_index do |worker, worker_id|
118+
_, sample = worker.value
119+
record_worker_gc(worker_id, sample)
120+
end
121+
nil
122+
end
123+
RUBY
124+
125+
MEM_UNRECORDED_BODY = <<~'RUBY'
126+
run_benchmark(2) do |count|
127+
count.times.map { Ractor.new { 10_000.times { Object.new } } }.each(&:value)
128+
nil
129+
end
130+
RUBY
131+
106132
before do
107133
@explicit_target = !ENV['RACTOR_GC_TEST_RUBY'].nil?
108134
@ruby = ENV['RACTOR_GC_TEST_RUBY'] || RbConfig.ruby
@@ -132,7 +158,7 @@ def GCStats.global_gc_attributed?(*) = false
132158
end
133159
end
134160

135-
def run_workload(body)
161+
def run_workload(body, harness: 'harness-ractor')
136162
Dir.mktmpdir do |dir|
137163
result_path = File.join(dir, 'results.json')
138164
script = File.join(dir, 'workload.rb')
@@ -146,7 +172,7 @@ def run_workload(body)
146172
'MIN_BENCH_TIME' => '0',
147173
'RESULT_JSON_PATH' => result_path
148174
)
149-
stdout, stderr, status = Open3.capture3(env, @ruby, "-I#{File.join(ROOT, 'harness-ractor')}", script, chdir: ROOT)
175+
stdout, stderr, status = Open3.capture3(env, @ruby, "-I#{File.join(ROOT, harness)}", script, chdir: ROOT)
150176
yield stdout, stderr, status, result_path
151177
end
152178
end
@@ -294,6 +320,70 @@ def run_workload(body)
294320
end
295321
end
296322

323+
it 'collects per-worker GC samples from a ractor memory scenario' do
324+
run_workload(MEM_WORKLOAD_BODY, harness: 'harness-ractor-mem') do |stdout, stderr, status, result_path|
325+
assert status.success?, "workload failed:\n#{stdout}\n#{stderr}"
326+
327+
data = JSON.parse(File.read(result_path))
328+
assert_equal 'ractor-local-workload', data['gc_scope']
329+
assert_equal 'global', data['ractor_mem_settle']
330+
assert_equal %w[1 2], data['bench_by_ractors'].keys.sort
331+
assert_equal %w[1 2], data['gc_by_ractors'].keys.sort
332+
333+
data['gc_by_ractors'].each do |count, group|
334+
assert_equal 2, data['bench_by_ractors'][count].length, "count #{count} timing samples"
335+
assert_equal 2, group['gc_worker_samples'].length, "count #{count} measured iterations"
336+
assert_equal 2, group['gc_controller_samples'].length, "count #{count} controller samples"
337+
group['gc_worker_samples'].each_with_index do |workers, i|
338+
assert_equal (0...count.to_i).to_a, workers.map { |w| w['worker_index'] }, 'spawn-index order'
339+
workers.each { |w| assert_operator w['gc_count'], :>, 0, 'every worker records GC activity' }
340+
assert_equal workers.sum { |w| w['gc_count'] }, group['gc_count_bench'][i]
341+
assert_in_delta workers.sum { |w| w['gc_total_time_ns'] } / 1_000_000.0, group['gc_total_time_bench'][i], 1e-9
342+
end
343+
end
344+
end
345+
end
346+
347+
it 'fails a ractor memory scenario that does not record its worker GC samples' do
348+
run_workload(MEM_UNRECORDED_BODY, harness: 'harness-ractor-mem') do |stdout, stderr, status, result_path|
349+
refute status.success?, "expected an unrecorded scenario to fail:\n#{stdout}\n#{stderr}"
350+
assert_includes stderr, 'scenario recorded worker GC samples [] for 1 ractors'
351+
refute File.exist?(result_path), 'no results file may be written for a failed benchmark'
352+
end
353+
end
354+
355+
it 'reports every worker of each ractor memory benchmark' do
356+
%w[ractor-dead-set ractor-idle-garbage ractor-msg-backlog].each do |bench|
357+
Dir.mktmpdir do |dir|
358+
result_path = File.join(dir, 'results.json')
359+
env = CLEAN_ENV.merge(
360+
'RUBY_BENCH_RACTOR_GC' => '1',
361+
'RUBY_BENCH_RACTORS' => '1,2',
362+
'MIN_BENCH_ITRS' => '1',
363+
'MAX_BENCH_ITRS' => '1',
364+
'RACTOR_MEM_SETTLE_SLEEP' => '0',
365+
'RACTOR_DEAD_SET_ITEMS' => '5000',
366+
'RACTOR_IDLE_GARBAGE_ITEMS' => '5000',
367+
'RACTOR_BACKLOG_MESSAGES' => '500',
368+
'RACTOR_BACKLOG_GATE_SLEEP' => '0.05',
369+
'RESULT_JSON_PATH' => result_path
370+
)
371+
script = File.join(ROOT, 'benchmarks', bench, 'benchmark.rb')
372+
stdout, stderr, status = Open3.capture3(env, @ruby, "-I#{File.join(ROOT, 'harness-ractor-mem')}", script, chdir: ROOT)
373+
assert status.success?, "#{bench} failed:\n#{stdout}\n#{stderr}"
374+
375+
data = JSON.parse(File.read(result_path))
376+
assert_equal %w[1 2], data['gc_by_ractors'].keys.sort, bench
377+
data['gc_by_ractors'].each do |count, group|
378+
assert_equal 1, group['gc_controller_samples'].length, "#{bench} count #{count} controller samples"
379+
workers = group['gc_worker_samples'].fetch(0)
380+
assert_equal (0...count.to_i).to_a, workers.map { |w| w['worker_index'] }, "#{bench} count #{count} workers"
381+
workers.each { |w| assert_kind_of Integer, w['gc_count'], "#{bench} count #{count} worker gc_count" }
382+
end
383+
end
384+
end
385+
end
386+
297387
it 'propagates a worker failure without writing partial or dummy results' do
298388
run_workload(FAILING_WORKLOAD_BODY) do |stdout, stderr, status, result_path|
299389
refute status.success?, "expected worker failure to fail the run:\n#{stdout}\n#{stderr}"

0 commit comments

Comments
 (0)