Skip to content

Commit 8d56eeb

Browse files
committed
Support running Spinel as a benchmark target
1 parent 17dcac9 commit 8d56eeb

7 files changed

Lines changed: 209 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ you should enable it before running `./run_benchmarks.rb`:
3535
chruby ruby-yjit
3636
```
3737

38+
### Benchmarking Spinel
39+
40+
[Spinel](https://github.com/matz/spinel) compiles a whole Ruby program into a native binary ahead of time.
41+
Its `-E` option compiles a script and runs the binary, like `go run`, so `spinel -E` can be passed to `-e`
42+
in place of a `ruby` executable:
43+
44+
```
45+
./run_benchmarks.rb -e "zjit::ruby --zjit;spinel::spinel -E" fib nqueens matmul
46+
```
47+
48+
Spinel cannot compile [harness/harness.rb](harness/harness.rb), which relies on CRuby-only features,
49+
so [harness/loader.rb](harness/loader.rb) switches to [harness-spinel/harness.rb](harness-spinel/harness.rb)
50+
when `RUBY_ENGINE` is `"spinel"`. That harness also runs on CRuby (`ruby -Iharness-spinel benchmarks/fib.rb`).
51+
Only benchmarks without a Gemfile that are written in the subset of Ruby Spinel supports can be compiled.
52+
53+
If you install Spinel under a chruby directory, e.g. `make install PREFIX=/opt/rubies/spinel`
54+
in a Spinel checkout, `--chruby` accepts it too:
55+
56+
```
57+
./run_benchmarks.rb --chruby "zjit::4.0.7 --zjit;spinel" fib
58+
```
59+
3860
## Usage
3961

4062
To run all the benchmarks and record the data:
@@ -101,6 +123,8 @@ It's also convenient for profiling, debugging, etc, especially since all benchma
101123
ruby benchmarks/some_benchmark.rb
102124
```
103125

126+
The same works with Spinel, which compiles the benchmark and runs the binary: `spinel -E benchmarks/some_benchmark.rb`.
127+
104128
### Benchmark organization
105129

106130
Benchmarks can be organized in three ways:

‎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: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
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+
# Spinel (https://github.com/matz/spinel) compiles the whole program ahead of time and
5+
# rejects the CRuby-only code in harness/harness.rb (RubyVM, RbConfig, Fiddle, ...).
6+
# It drops the other branch of a RUBY_ENGINE check before analysis, so only this
7+
# harness is compiled: `spinel -E benchmarks/fib.rb` needs no -I option at all.
8+
require_relative "../harness-spinel/harness"
9+
else
10+
retries = 0
11+
begin
12+
require "harness"
13+
rescue LoadError => e
14+
if retries == 0 && e.path == "harness"
15+
retries += 1
16+
$LOAD_PATH << File.expand_path(__dir__)
17+
retry
18+
end
19+
raise
1120
end
12-
raise
1321
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

@@ -204,9 +204,23 @@ def parse(argv)
204204

205205
private
206206

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

212226
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
@@ -200,7 +200,8 @@ def compute_benchmark_env(ruby)
200200
env = {}
201201
ruby_path = `#{ruby.shelljoin} -e 'print RbConfig.ruby' 2> #{File::NULL}`
202202

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

206207
# chruby sets GEM_HOME and GEM_PATH in your shell. We have to unset it in the child
@@ -234,8 +235,9 @@ def base_cmd(ruby_description, benchmark_name)
234235
cmd = setarch_prefix
235236

236237
# Pin the process to one given core to improve caching and reduce variance on CRuby
238+
# and Spinel, which compiles the benchmark to a single-threaded native binary.
237239
# Other Rubies need to use multiple cores, e.g., for JIT threads
238-
if ruby_description.start_with?('ruby ') && should_pin?(benchmark_name)
240+
if (ruby_description.start_with?('ruby ') || ruby_description.start_with?('spinel ')) && should_pin?(benchmark_name)
239241
# Up to the last two thirds of Intel CPU cores may be slow E-Cores, so avoid using them.
240242
cpu = [(Etc.nprocessors / 3) - 1, 0].max
241243
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)