Skip to content

Commit b784e71

Browse files
authored
Support running Spinel as a benchmark target (#540)
1 parent 6364711 commit b784e71

6 files changed

Lines changed: 181 additions & 13 deletions

File tree

‎harness-spinel/harness.rb‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# A harness for Ruby implementations that compile the whole program ahead of
2+
# time, such as Spinel (https://github.com/matz/spinel). harness/harness.rb
3+
# relies on CRuby-only libraries and reflection (RubyVM, RbConfig, Fiddle,
4+
# Bundler, $LOAD_PATH) that a whole-program compiler refuses to compile, so
5+
# this file reimplements the same protocol in a portable subset of Ruby:
6+
#
7+
# * WARMUP_ITRS, MIN_BENCH_ITRS and MIN_BENCH_TIME select the iteration count.
8+
# * RESULT_JSON_PATH receives the JSON that run_benchmarks.rb reads.
9+
#
10+
# harness/loader.rb selects this harness automatically when RUBY_ENGINE is
11+
# "spinel", so `spinel -E benchmarks/fib.rb` works without any -I option. It
12+
# also runs on CRuby (`ruby -Iharness-spinel benchmarks/fib.rb`), which is how
13+
# its output is kept compatible with the default harness.
14+
require "json"
15+
16+
# Warmup iterations
17+
WARMUP_ITRS = Integer(ENV.fetch('WARMUP_ITRS', '15'))
18+
19+
# Minimum number of benchmarking iterations
20+
MIN_BENCH_ITRS = Integer(ENV.fetch('MIN_BENCH_ITRS', '10'))
21+
22+
# Minimum benchmarking time in seconds
23+
MIN_BENCH_TIME = Integer(ENV.fetch('MIN_BENCH_TIME', '10'))
24+
25+
# Do expand_path at require-time, not when returning results, before the benchmark is likely to chdir
26+
default_path = File.expand_path("../data/results-#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}-#{Time.now.strftime('%F-%H%M%S')}.json", __dir__)
27+
YB_OUTPUT_FILE = File.expand_path(ENV.fetch("RESULT_JSON_PATH", default_path))
28+
29+
puts RUBY_DESCRIPTION
30+
31+
# Ractor.make_shareable is unavailable; benchmarks call this to share constants with Ractors.
32+
def make_shareable(obj, copy: false)
33+
obj
34+
end
35+
36+
def realtime
37+
r0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38+
yield
39+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - r0
40+
end
41+
42+
# This returns its best estimate of the Resident Set Size in bytes.
43+
def get_rss
44+
mem_rollup_file = "/proc/#{Process.pid}/smaps_rollup"
45+
if File.exist?(mem_rollup_file)
46+
# Rss is always reported in kB by the Linux kernel, e.g. "Rss: 62796 kB"
47+
rss_line = File.read(mem_rollup_file).lines.find { |line| line.start_with?("Rss") }
48+
1024 * rss_line.split(":")[1].to_i
49+
else
50+
1024 * `ps -o rss= -p #{Process.pid}`.to_i
51+
end
52+
end
53+
54+
# Takes a block as input
55+
def run_benchmark(_num_itrs_hint, &block)
56+
times = []
57+
rss_samples = []
58+
total_time = 0.0
59+
num_itrs = 0
60+
61+
puts "itr: time"
62+
begin
63+
time = realtime(&block)
64+
num_itrs += 1
65+
66+
time_ms = (1000 * time).to_i
67+
puts "%4s %6s" % ["##{num_itrs}:", "#{time_ms}ms"]
68+
69+
# We internally save the time in seconds to avoid loss of precision
70+
times << time
71+
total_time += time
72+
# Sample current RSS between iterations (outside the timed block)
73+
rss_samples << get_rss
74+
end until num_itrs >= WARMUP_ITRS + MIN_BENCH_ITRS && total_time >= MIN_BENCH_TIME
75+
76+
warmup = times[0...WARMUP_ITRS]
77+
bench = times[WARMUP_ITRS..-1]
78+
return_results(warmup, bench, rss_samples[WARMUP_ITRS..-1])
79+
80+
if bench.size > 1
81+
bench_ms = ((bench.sum / bench.size) * 1000.0).to_i
82+
puts "Average of last #{bench.size}, non-warmup iters: #{bench_ms}ms"
83+
end
84+
end
85+
86+
def return_results(warmup_iterations, bench_iterations, rss_samples)
87+
# Full GC before measuring RSS to lower GC variance.
88+
GC.start
89+
90+
rss = get_rss
91+
ruby_bench_results = {
92+
"RUBY_DESCRIPTION" => RUBY_DESCRIPTION,
93+
"warmup" => warmup_iterations,
94+
"bench" => bench_iterations,
95+
"rss_samples" => rss_samples,
96+
"rss" => rss,
97+
}
98+
99+
puts "RSS: %.1fMiB" % (rss / 1024.0 / 1024.0)
100+
101+
out_path = YB_OUTPUT_FILE
102+
system('mkdir', '-p', File.dirname(out_path))
103+
104+
# Using default path? Print where we put it.
105+
puts "Writing file #{out_path}" unless ENV["RESULT_JSON_PATH"]
106+
107+
File.write(out_path, JSON.generate(ruby_bench_results))
108+
end

‎harness/loader.rb‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
# Use harness/harness.rb by default. You can change it with -I option.
22
# i.e. ruby -Iharness benchmarks/railsbench/benchmark.rb
3-
retries = 0
4-
begin
5-
require "harness"
6-
rescue LoadError => e
7-
if retries == 0 && e.path == "harness"
8-
retries += 1
9-
$LOAD_PATH << File.expand_path(__dir__)
10-
retry
3+
if RUBY_ENGINE == "spinel"
4+
require_relative "../harness-spinel/harness"
5+
else
6+
retries = 0
7+
begin
8+
require "harness"
9+
rescue LoadError => e
10+
if retries == 0 && e.path == "harness"
11+
retries += 1
12+
$LOAD_PATH << File.expand_path(__dir__)
13+
retry
14+
end
15+
raise
1116
end
12-
raise
1317
end

‎lib/argument_parser.rb‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def parse(argv)
6262
version, *options = version.shellsplit
6363
executable = find_chruby_ruby(version)
6464
abort "Cannot find '#{version}' in chruby paths" unless executable
65-
args.executables[name] = [executable, *options]
65+
args.executables[name] = [*executable, *options]
6666
end
6767
end
6868

@@ -208,9 +208,23 @@ def parse(argv)
208208

209209
private
210210

211+
# Returns the command (an Array) to run a script with the given chruby version, or nil.
211212
def find_chruby_ruby(version)
212213
rubies_dir = ENV["RUBIES_DIR"] || "#{ENV["HOME"]}/.rubies"
213-
chruby_search_paths(version, rubies_dir).find { |path| File.executable?(path) }
214+
search_paths = chruby_search_paths(version, rubies_dir)
215+
if (ruby = search_paths.find { |path| File.executable?(path) })
216+
return [ruby]
217+
end
218+
219+
# Spinel (https://github.com/matz/spinel) installs `bin/spinel` instead of `bin/ruby`,
220+
# e.g. with `make install PREFIX=/opt/rubies/spinel`. Its -E option compiles a script
221+
# and runs the binary, like `go run`, so it can take the place of a `ruby` executable.
222+
spinel_paths = search_paths.map { |path| File.join(File.dirname(path), "spinel") }
223+
if (spinel = spinel_paths.find { |path| File.executable?(path) })
224+
return [spinel, "-E"]
225+
end
226+
227+
nil
214228
end
215229

216230
def chruby_search_paths(version, rubies_dir)

‎lib/benchmark_suite.rb‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ def compute_benchmark_env(ruby)
243243
env = {}
244244
ruby_path = IO.popen([*ruby, "-e", "print RbConfig.ruby"], err: File::NULL, &:read)
245245

246-
if ruby_path != RbConfig.ruby
246+
# Implementations without RbConfig (e.g. Spinel) print nothing; leave PATH alone for them.
247+
if !ruby_path.empty? && ruby_path != RbConfig.ruby
247248
env["PATH"] = "#{File.dirname(ruby_path)}#{File::PATH_SEPARATOR}#{ENV["PATH"]}"
248249

249250
# chruby sets GEM_HOME and GEM_PATH in your shell. We have to unset it in the child
@@ -277,8 +278,9 @@ def base_cmd(ruby_description, benchmark_name)
277278
cmd = setarch_prefix
278279

279280
# Pin the process to one given core to improve caching and reduce variance on CRuby
281+
# and Spinel, which compiles the benchmark to a single-threaded native binary.
280282
# Other Rubies need to use multiple cores, e.g., for JIT threads
281-
if ruby_description.start_with?('ruby ') && should_pin?(benchmark_name)
283+
if (ruby_description.start_with?('ruby ') || ruby_description.start_with?('spinel ')) && should_pin?(benchmark_name)
282284
# Up to the last two thirds of Intel CPU cores may be slow E-Cores, so avoid using them.
283285
cpu = [(Etc.nprocessors / 3) - 1, 0].max
284286
cmd.concat(["taskset", "-c", "#{cpu}"])

‎test/argument_parser_test.rb‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,20 @@ def setup_mock_ruby(path)
171171
end
172172
end
173173

174+
it 'runs spinel with -E when a version has bin/spinel instead of bin/ruby' do
175+
Dir.mktmpdir do |tmpdir|
176+
spinel_path = File.join(tmpdir, 'opt/rubies/spinel/bin/spinel')
177+
setup_mock_ruby(spinel_path)
178+
179+
parser = ArgumentParser.new
180+
parser.stub :chruby_search_paths, ->(version, rubies_dir) { [File.join(tmpdir, 'opt/rubies', version, 'bin/ruby')] } do
181+
args = parser.parse(['--chruby=spinel::spinel --int-overflow=wrap'])
182+
183+
assert_equal [spinel_path, '-E', '--int-overflow=wrap'], args.executables['spinel']
184+
end
185+
end
186+
end
187+
174188
it 'aborts when ruby version not found' do
175189
Dir.mktmpdir do |tmpdir|
176190
@temp_home = tmpdir

‎test/benchmark_suite_test.rb‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,32 @@
180180
end
181181
end
182182

183+
describe '#base_cmd' do
184+
def base_cmd_for(ruby_description)
185+
suite = BenchmarkSuite.new(
186+
categories: ['micro'],
187+
name_filters: [],
188+
out_path: @out_path,
189+
harness: 'harness'
190+
)
191+
suite.stub :linux?, true do
192+
suite.stub :setarch_prefix, [] do
193+
suite.send(:base_cmd, ruby_description, 'simple')
194+
end
195+
end
196+
end
197+
198+
it 'pins CRuby and Spinel to one core' do
199+
assert_includes base_cmd_for('ruby 4.0.7 (2026-09-15 revision 229531a6cf) +ZJIT [x86_64-linux]'), 'taskset'
200+
assert_includes base_cmd_for('spinel 2026.09.12+3194 (0949a47fb) [gcc 13.3.0 (cc)]'), 'taskset'
201+
end
202+
203+
it 'does not pin other Rubies, which use multiple cores' do
204+
refute_includes base_cmd_for('jruby 10.1.1.0 (3.4.5) 2025-08-27 OpenJDK 64-Bit Server VM [x86_64-linux]'), 'taskset'
205+
refute_includes base_cmd_for('truffleruby 34.0.1 [x86_64-linux]'), 'taskset'
206+
end
207+
end
208+
183209
describe '#run' do
184210
it 'returns bench_data and bench_failures as a tuple' do
185211
suite = BenchmarkSuite.new(

0 commit comments

Comments
 (0)