Skip to content

Commit bdd28b8

Browse files
Merge pull request #488 from ruby/mvh-gc-harness
Introduce GC benchmarks
2 parents 19b9ba2 + de3a939 commit bdd28b8

8 files changed

Lines changed: 303 additions & 12 deletions

File tree

benchmarks.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ binarytrees:
7474
blurhash:
7575
desc: blurhash (blurred preview image) calculation
7676
ractor: true
77+
gcbench:
78+
desc: Ellis-Kovac-Boehm GCBench builds binary trees of various depths to exercise GC marking, sweeping, and write barriers.
79+
category: gc
80+
single_file: true
81+
default_harness: harness-gc
7782
erubi:
7883
desc: erubi compiles a simple Erb template into a method with erubi, then times evaluating that method.
7984
ractor: true

benchmarks/gcbench.rb

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Ellis-Kovac-Boehm GCBench
2+
#
3+
# Adapted from the benchmark by John Ellis and Pete Kovac (Post Communications),
4+
# modified by Hans Boehm (Silicon Graphics), translated to Ruby by Noel Padavan
5+
# and Chris Seaton. Adapted for yjit-bench by Matt Valentine-House.
6+
#
7+
# Builds balanced binary trees of various depths to generate objects with a range
8+
# of lifetimes. Two long-lived structures (a tree and a float array) are kept
9+
# alive throughout to model applications that maintain persistent heap data.
10+
#
11+
# Tree construction uses both top-down (populate — creates old-to-young pointers,
12+
# exercises write barriers) and bottom-up (make_tree — young-to-young only).
13+
14+
require_relative '../harness/loader'
15+
16+
class GCBench
17+
class Node
18+
attr_accessor :left, :right, :i, :j
19+
20+
def initialize(left = nil, right = nil)
21+
@left = left
22+
@right = right
23+
@i = 0
24+
@j = 0
25+
end
26+
end
27+
28+
STRETCH_TREE_DEPTH = 18
29+
LONG_LIVED_TREE_DEPTH = 16
30+
ARRAY_SIZE = 500_000
31+
MIN_TREE_DEPTH = 4
32+
MAX_TREE_DEPTH = 16
33+
34+
def self.tree_size(depth)
35+
(1 << (depth + 1)) - 1
36+
end
37+
38+
def self.num_iters(depth)
39+
2 * tree_size(STRETCH_TREE_DEPTH) / tree_size(depth)
40+
end
41+
42+
# Top-down: assigns children to an existing (older) node — old-to-young pointers.
43+
def self.populate(depth, node)
44+
if depth > 0
45+
depth -= 1
46+
node.left = Node.new
47+
node.right = Node.new
48+
populate(depth, node.left)
49+
populate(depth, node.right)
50+
end
51+
end
52+
53+
# Bottom-up: children allocated before parent — young-to-young pointers only.
54+
def self.make_tree(depth)
55+
if depth <= 0
56+
Node.new
57+
else
58+
Node.new(make_tree(depth - 1), make_tree(depth - 1))
59+
end
60+
end
61+
62+
def self.time_construction(depth)
63+
n = num_iters(depth)
64+
65+
n.times do
66+
node = Node.new
67+
populate(depth, node)
68+
end
69+
70+
n.times do
71+
make_tree(depth)
72+
end
73+
end
74+
end
75+
76+
# Stretch the heap before measurement
77+
GCBench.make_tree(GCBench::STRETCH_TREE_DEPTH)
78+
79+
# Long-lived objects that persist across all iterations
80+
long_lived_tree = GCBench::Node.new
81+
GCBench.populate(GCBench::LONG_LIVED_TREE_DEPTH, long_lived_tree)
82+
83+
long_lived_array = Array.new(GCBench::ARRAY_SIZE)
84+
(GCBench::ARRAY_SIZE / 2).times { |i| long_lived_array[i + 1] = 1.0 / (i + 1) }
85+
86+
run_benchmark(10) do
87+
GCBench::MIN_TREE_DEPTH.step(GCBench::MAX_TREE_DEPTH, 2) do |depth|
88+
GCBench.time_construction(depth)
89+
end
90+
end

harness-gc/harness.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
require_relative "../harness/harness-common"
2+
3+
WARMUP_ITRS = Integer(ENV.fetch('WARMUP_ITRS', 15))
4+
MIN_BENCH_ITRS = Integer(ENV.fetch('MIN_BENCH_ITRS', 10))
5+
MIN_BENCH_TIME = Integer(ENV.fetch('MIN_BENCH_TIME', 10))
6+
7+
puts RUBY_DESCRIPTION
8+
9+
def realtime
10+
r0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11+
yield
12+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - r0
13+
end
14+
15+
def gc_stat_heap_snapshot
16+
return {} unless GC.respond_to?(:stat_heap)
17+
GC.stat_heap
18+
end
19+
20+
def gc_stat_heap_delta(before, after)
21+
delta = {}
22+
after.each do |heap_idx, after_stats|
23+
before_stats = before[heap_idx] || {}
24+
heap_delta = {}
25+
after_stats.each do |key, val|
26+
next unless val.is_a?(Numeric) && before_stats.key?(key)
27+
heap_delta[key] = val - before_stats[key]
28+
end
29+
delta[heap_idx] = heap_delta unless heap_delta.empty?
30+
end
31+
delta
32+
end
33+
34+
def run_benchmark(_num_itrs_hint, **, &block)
35+
times = []
36+
marking_times = []
37+
sweeping_times = []
38+
gc_counts = []
39+
gc_heap_deltas = []
40+
total_time = 0
41+
num_itrs = 0
42+
43+
has_marking = GC.stat.key?(:marking_time)
44+
has_sweeping = GC.stat.key?(:sweeping_time)
45+
46+
header = "itr: time"
47+
header << " marking" if has_marking
48+
header << " sweeping" if has_sweeping
49+
header << " gc_count"
50+
puts header
51+
52+
begin
53+
gc_before = GC.stat
54+
heap_before = gc_stat_heap_snapshot
55+
56+
time = realtime(&block)
57+
num_itrs += 1
58+
59+
gc_after = GC.stat
60+
heap_after = gc_stat_heap_snapshot
61+
62+
time_ms = (1000 * time).to_i
63+
mark_delta = has_marking ? gc_after[:marking_time] - gc_before[:marking_time] : 0
64+
sweep_delta = has_sweeping ? gc_after[:sweeping_time] - gc_before[:sweeping_time] : 0
65+
count_delta = gc_after[:count] - gc_before[:count]
66+
67+
itr_str = "%4s %6s" % ["##{num_itrs}:", "#{time_ms}ms"]
68+
itr_str << " %9.1fms" % mark_delta if has_marking
69+
itr_str << " %9.1fms" % sweep_delta if has_sweeping
70+
itr_str << " %9d" % count_delta
71+
puts itr_str
72+
73+
times << time
74+
marking_times << mark_delta
75+
sweeping_times << sweep_delta
76+
gc_counts << count_delta
77+
gc_heap_deltas << gc_stat_heap_delta(heap_before, heap_after)
78+
total_time += time
79+
end until num_itrs >= WARMUP_ITRS + MIN_BENCH_ITRS and total_time >= MIN_BENCH_TIME
80+
81+
warmup_range = 0...WARMUP_ITRS
82+
bench_range = WARMUP_ITRS..-1
83+
84+
extra = {}
85+
extra["gc_marking_time_warmup"] = marking_times[warmup_range]
86+
extra["gc_marking_time_bench"] = marking_times[bench_range]
87+
extra["gc_sweeping_time_warmup"] = sweeping_times[warmup_range]
88+
extra["gc_sweeping_time_bench"] = sweeping_times[bench_range]
89+
extra["gc_count_warmup"] = gc_counts[warmup_range]
90+
extra["gc_count_bench"] = gc_counts[bench_range]
91+
extra["gc_stat_heap_deltas"] = gc_heap_deltas[bench_range]
92+
93+
return_results(times[warmup_range], times[bench_range], **extra)
94+
95+
non_warmups = times[bench_range]
96+
if non_warmups.size > 1
97+
non_warmups_ms = ((non_warmups.sum / non_warmups.size) * 1000.0).to_i
98+
puts "Average of last #{non_warmups.size}, non-warmup iters: #{non_warmups_ms}ms"
99+
100+
if has_marking
101+
mark_bench = marking_times[bench_range]
102+
avg_mark = mark_bench.sum / mark_bench.size
103+
puts "Average marking time: %.1fms" % avg_mark
104+
end
105+
106+
if has_sweeping
107+
sweep_bench = sweeping_times[bench_range]
108+
avg_sweep = sweep_bench.sum / sweep_bench.size
109+
puts "Average sweeping time: %.1fms" % avg_sweep
110+
end
111+
end
112+
end

harness/harness-common.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,13 @@ def get_rss
7575
# Collect our own peak mem usage as soon as reasonable after finishing the last iteration.
7676
# This method is only accurate to kilobytes, but is nicely portable and doesn't require
7777
# any extra gems/dependencies.
78-
mem = `ps -o rss= -p #{Process.pid}`
79-
1024 * Integer(mem)
78+
begin
79+
mem = `ps -o rss= -p #{Process.pid}`
80+
1024 * Integer(mem)
81+
rescue ArgumentError, Errno::ENOENT
82+
# ps failed (e.g. Nix procps on macOS). Fall back to peak RSS via getrusage.
83+
get_maxrss || 0
84+
end
8085
end
8186
end
8287

@@ -135,11 +140,12 @@ def get_maxrss
135140
yb_env_var = ENV.fetch("RESULT_JSON_PATH", default_path)
136141
YB_OUTPUT_FILE = File.expand_path yb_env_var
137142

138-
def return_results(warmup_iterations, bench_iterations)
143+
def return_results(warmup_iterations, bench_iterations, **extra)
139144
ruby_bench_results = {
140145
"RUBY_DESCRIPTION" => RUBY_DESCRIPTION,
141146
"warmup" => warmup_iterations,
142147
"bench" => bench_iterations,
148+
**extra,
143149
}
144150

145151
# Collect JIT stats before loading any additional code.

lib/benchmark_runner.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def write_csv(output_path, ruby_descriptions, table)
4848
end
4949

5050
# Build output text string with metadata, table, and legend
51-
def build_output_text(ruby_descriptions, table, format, bench_failures, include_rss: false)
51+
def build_output_text(ruby_descriptions, table, format, bench_failures, include_rss: false, include_gc: false)
5252
base_name, *other_names = ruby_descriptions.keys
5353

5454
output_str = +""
@@ -68,6 +68,10 @@ def build_output_text(ruby_descriptions, table, format, bench_failures, include_
6868
if include_rss
6969
output_str << "- RSS #{base_name}/#{name}: ratio of #{base_name}/#{name} RSS. Higher is better for #{name}. Above 1 means lower memory usage.\n"
7070
end
71+
if include_gc
72+
output_str << "- mark #{base_name}/#{name}: ratio of GC marking time. Higher is better for #{name}. Above 1 represents faster marking.\n"
73+
output_str << "- sweep #{base_name}/#{name}: ratio of GC sweeping time. Higher is better for #{name}. Above 1 represents faster sweeping.\n"
74+
end
7175
end
7276
output_str << "- ***: p < 0.001, **: p < 0.01, *: p < 0.05 (Welch's t-test)\n"
7377
end

lib/benchmark_runner/cli.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def run
109109
BenchmarkRunner.write_csv(output_path, ruby_descriptions, table)
110110

111111
# Save the output in a text file that we can easily refer to
112-
output_str = BenchmarkRunner.build_output_text(ruby_descriptions, table, format, bench_failures, include_rss: args.rss)
112+
output_str = BenchmarkRunner.build_output_text(ruby_descriptions, table, format, bench_failures, include_rss: args.rss, include_gc: builder.include_gc?)
113113
out_txt_path = output_path + ".txt"
114114
File.open(out_txt_path, "w") { |f| f.write output_str }
115115

0 commit comments

Comments
 (0)