Skip to content

Commit d68fb31

Browse files
authored
Merge pull request #241 from fastruby/feature/jit-variants-and-comparable-results
Save results as JSON and run YJIT and ZJIT variants
2 parents 88c2b6d + f4b5a55 commit d68fb31

6 files changed

Lines changed: 229 additions & 3 deletions

File tree

‎.github/workflows/benchmarks.yml‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
run: .github/scripts/pick-benchmarks.sh "${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.before }}"
2626

2727
rake:
28+
name: rake (${{ matrix.ruby }}${{ matrix.variant && format('+{0}', matrix.variant) || '' }})
2829
needs: changes
2930
if: needs.changes.outputs.run == 'true'
3031
runs-on: ubuntu-latest
@@ -41,20 +42,50 @@ jobs:
4142
'jruby_head', 'jruby_9.1',
4243
'truffleruby_head', 'truffleruby_22'
4344
]
45+
# '' is the plain Ruby. Each include below overwrites it, so GitHub
46+
# adds it as a separate job instead of merging it into the plain one.
47+
variant: ['']
48+
include:
49+
- { ruby: ruby_3.1, variant: yjit, flags: --yjit }
50+
- { ruby: ruby_3.2, variant: yjit, flags: --yjit }
51+
- { ruby: ruby_3.3, variant: yjit, flags: --yjit }
52+
- { ruby: ruby_3.4, variant: yjit, flags: --yjit }
53+
- { ruby: ruby_4.0, variant: yjit, flags: --yjit }
54+
- { ruby: ruby_head, variant: yjit, flags: --yjit }
55+
- { ruby: ruby_4.0, variant: zjit, flags: --zjit }
56+
- { ruby: ruby_head, variant: zjit, flags: --zjit }
57+
58+
# Read by docker/run-benchmarks.sh and docker/collect_results.rb.
59+
env:
60+
RUBY_VARIANT: ${{ matrix.variant }}
61+
RUBY_VARIANT_FLAGS: ${{ matrix.flags }}
62+
RESULTS_DIR: results
63+
RESULTS_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
64+
RESULTS_PR: ${{ github.event.pull_request.number }}
65+
RESULTS_LABEL: ${{ matrix.ruby }}${{ matrix.variant && format('+{0}', matrix.variant) || '' }}
4466

4567
steps:
68+
# Plain Rubies only: shared reports do not record the Ruby or its flags,
69+
# so variant runs would be indistinguishable on ips.fastruby.io.
4670
- name: Set Share Env
47-
if: github.ref_name == 'main'
71+
if: github.ref_name == 'main' && matrix.variant == ''
4872
run: |
4973
echo "SHARE=1" >> "$GITHUB_ENV"
5074
- uses: actions/checkout@v4
51-
- name: Run benchmarks on ${{ matrix.ruby }}
75+
- name: Run benchmarks on ${{ env.RESULTS_LABEL }}
5276
env:
5377
FILES: ${{ needs.changes.outputs.files }}
5478
run: .github/scripts/run-benchmarks.sh ${{ matrix.ruby }}
5579
- name: Report failed benchmarks
5680
if: failure()
57-
run: .github/scripts/report-failures.sh benchmarks.log ${{ matrix.ruby }}
81+
run: .github/scripts/report-failures.sh benchmarks.log ${{ env.RESULTS_LABEL }}
82+
- name: Upload results
83+
uses: actions/upload-artifact@v4
84+
if: always()
85+
with:
86+
name: results-${{ env.RESULTS_LABEL }}
87+
path: results/
88+
if-no-files-found: warn
5889

5990
# The check to require on main. Passes when every benchmark job passed, or
6091
# when there was nothing to benchmark.

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/.bundle/
22
*.bundle
33
/Gemfile.lock
4+
/results/

‎CONTRIBUTING.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ the head builds go stale. To get the latest nightly build:
6060
docker compose build --no-cache ruby_head
6161
```
6262

63+
To run it with a JIT, pass the variant and its flags. The run stops if the
64+
Ruby does not have that JIT, instead of quietly running without it:
65+
66+
```
67+
RUBY_VARIANT=yjit RUBY_VARIANT_FLAGS=--yjit docker compose run --rm ruby_3.4 code/your-new/entry.rb
68+
RUBY_VARIANT=zjit RUBY_VARIANT_FLAGS=--zjit docker compose run --rm ruby_4.0 code/your-new/entry.rb
69+
```
70+
71+
To keep the results, set `RESULTS_DIR`. Each benchmark then also writes its
72+
report as JSON to `results/<label>/`, with the Ruby, its flags and the machine
73+
it ran on:
74+
75+
```
76+
RESULTS_DIR=results RESULTS_LABEL=ruby_3.4 docker compose run --rm ruby_3.4 code/your-new/entry.rb
77+
```
78+
6379
## Benchmarks that need a newer Ruby
6480

6581
CI runs every benchmark on every Ruby in `compose.yaml`, back to Ruby 2.1, and

‎compose.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ x-benchmark: &benchmark
1414
# for code/array/bsearch-vs-find.rb's 100M element array: JRuby 9.1 fails
1515
# even on 16 GB CI runners, JRuby 10 on smaller machines.
1616
- JRUBY_OPTS=-J-Xmx6g
17+
# Variants (docker/run-benchmarks.sh) and results (docker/collect_results.rb).
18+
- RUBY_VARIANT
19+
- RUBY_VARIANT_FLAGS
20+
- RESULTS_DIR
21+
- RESULTS_LABEL
22+
- RESULTS_COMMIT
23+
- RESULTS_PR
24+
- GITHUB_RUN_ID
25+
- GITHUB_RUN_ATTEMPT
26+
- RUNNER_NAME
1727
entrypoint: ["/app/docker/run-benchmarks.sh"]
1828

1929
services:

‎docker/collect_results.rb‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Loaded with `ruby -r` (through RUBYOPT) when RESULTS_DIR is set. Every
2+
# Benchmark.ips call in a benchmark file also writes its report as JSON to
3+
# RESULTS_DIR/<label>/<benchmark>.json, so the benchmark files stay untouched.
4+
# Each result also records where it ran, so only comparable numbers get
5+
# compared.
6+
#
7+
# Keep this file compatible with the oldest Ruby in the CI matrix (2.1).
8+
#
9+
# JRuby 9.1 can load this file before bundler/setup from RUBYOPT, so set up
10+
# the bundle here to find benchmark-ips either way.
11+
require "bundler/setup"
12+
require "benchmark/ips"
13+
require "fileutils"
14+
require "json"
15+
require "rbconfig"
16+
17+
module CollectResults
18+
@calls = 0
19+
20+
class << self
21+
attr_accessor :calls
22+
23+
def label
24+
env("RESULTS_LABEL") || ["#{RUBY_ENGINE}-#{engine_version}", env("RUBY_VARIANT")].compact.join("+")
25+
end
26+
27+
def engine_version
28+
defined?(RUBY_ENGINE_VERSION) ? RUBY_ENGINE_VERSION : RUBY_VERSION
29+
end
30+
31+
def write(report)
32+
self.calls += 1
33+
benchmark = $0.sub(%r{\A\./}, "")
34+
name = benchmark.sub(%r{\Acode/}, "").sub(/\.rb\z/, "").gsub("/", "--")
35+
name += "--#{calls}" if calls > 1
36+
37+
path = File.join(ENV["RESULTS_DIR"], label, "#{name}.json")
38+
FileUtils.mkdir_p(File.dirname(path))
39+
File.write(path, JSON.pretty_generate({
40+
"benchmark" => benchmark,
41+
"part" => calls,
42+
"label" => label,
43+
"ruby_description" => RUBY_DESCRIPTION,
44+
"engine" => RUBY_ENGINE,
45+
"engine_version" => engine_version,
46+
"ruby_version" => RUBY_VERSION,
47+
"variant" => env("RUBY_VARIANT"),
48+
"flags" => env("RUBY_VARIANT_FLAGS"),
49+
"jit" => jit,
50+
"commit" => env("RESULTS_COMMIT"),
51+
"pr" => env("RESULTS_PR") && env("RESULTS_PR").to_i,
52+
"run_id" => env("GITHUB_RUN_ID"),
53+
"run_attempt" => env("GITHUB_RUN_ATTEMPT"),
54+
"runner" => env("RUNNER_NAME"),
55+
"environment" => environment,
56+
"entries" => report.data
57+
}))
58+
end
59+
60+
# An empty value counts as unset: CI passes an empty matrix field (no
61+
# variant) as an empty string.
62+
def env(name)
63+
value = ENV[name]
64+
value unless value.nil? || value.empty?
65+
end
66+
67+
def jit
68+
if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
69+
"yjit"
70+
elsif defined?(RubyVM::ZJIT) && RubyVM::ZJIT.respond_to?(:enabled?) && RubyVM::ZJIT.enabled?
71+
"zjit"
72+
end
73+
end
74+
75+
# The same for every benchmark in a run, so work it out once. A field that
76+
# cannot be read (no /etc/os-release or ldd outside Docker) is nil; the
77+
# others are kept.
78+
def environment
79+
@environment ||= {
80+
"arch" => RbConfig::CONFIG["host_cpu"],
81+
"cpu" => safely { cpu },
82+
"nproc" => safely { nproc },
83+
"os" => safely { File.read("/etc/os-release")[/^PRETTY_NAME="?([^"\n]+)/, 1] },
84+
"glibc" => safely { `ldd --version 2>&1`[/\d+\.\d+/] },
85+
"cc" => cc_version,
86+
"optflags" => RbConfig::CONFIG["optflags"],
87+
"configure_args" => RbConfig::CONFIG["configure_args"]
88+
}
89+
end
90+
91+
def safely
92+
yield
93+
rescue SystemCallError
94+
nil
95+
end
96+
97+
# x86 lists a model name. arm64 only lists implementer and part codes,
98+
# which name the CPU design on servers; inside Docker Desktop's VM on a
99+
# Mac they only say "Apple", so different Macs look the same.
100+
def cpu
101+
model = cpuinfo[/^model name\s*:\s*(.+)$/, 1]
102+
return model if model
103+
104+
implementer = cpuinfo[/^CPU implementer\s*:\s*(\S+)/, 1]
105+
part = cpuinfo[/^CPU part\s*:\s*(\S+)/, 1]
106+
"CPU implementer #{implementer}, part #{part}" if implementer && part
107+
end
108+
109+
def cpuinfo
110+
File.exist?("/proc/cpuinfo") ? File.read("/proc/cpuinfo") : ""
111+
end
112+
113+
# Etc.nprocessors is Ruby 2.2+.
114+
def nproc
115+
require "etc"
116+
Etc.respond_to?(:nprocessors) ? Etc.nprocessors : cpuinfo.scan(/^processor\s*:/).size
117+
end
118+
119+
# CC_VERSION_MESSAGE is only set by newer CRubies; nil elsewhere.
120+
def cc_version
121+
message = RbConfig::CONFIG["CC_VERSION_MESSAGE"]
122+
message && message.lines.first.strip
123+
end
124+
end
125+
126+
def ips(*args, &block)
127+
report = super
128+
CollectResults.write(report) if report.respond_to?(:data)
129+
report
130+
end
131+
end
132+
133+
# Benchmark extends Benchmark::IPS, so the hook goes on Benchmark's singleton
134+
# class. Prepending to Benchmark::IPS would not reach it before Ruby 3.0.
135+
Benchmark.singleton_class.send(:prepend, CollectResults)

‎docker/run-benchmarks.sh‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@ export BUNDLE_GEMFILE=/tmp/bundle/Gemfile
1010

1111
bundle install
1212

13+
# A variant runs the same Ruby with flags, for example YJIT. The Rakefile starts
14+
# one process per benchmark, so the flags go through the variable each engine
15+
# reads, which every child process inherits.
16+
if [ -n "$RUBY_VARIANT" ] || [ -n "$RUBY_VARIANT_FLAGS" ]; then
17+
engine=$(ruby -e 'print RUBY_ENGINE')
18+
if [ -n "$RUBY_VARIANT_FLAGS" ]; then
19+
case "$engine" in
20+
jruby) export JRUBY_OPTS="$JRUBY_OPTS $RUBY_VARIANT_FLAGS" ;;
21+
truffleruby) export TRUFFLERUBYOPT="$RUBY_VARIANT_FLAGS $TRUFFLERUBYOPT" ;;
22+
*) export RUBYOPT="$RUBY_VARIANT_FLAGS $RUBYOPT" ;;
23+
esac
24+
fi
25+
26+
# Fail here rather than silently benchmark without the variant. TruffleRuby
27+
# prints nothing for its flags, so only an invalid one fails there.
28+
description=$(ruby -e 'print RUBY_DESCRIPTION')
29+
case "$engine:$RUBY_VARIANT" in
30+
ruby:yjit) expected="+YJIT" ;;
31+
ruby:zjit) expected="+ZJIT" ;;
32+
jruby:dev|jruby:nojit) expected="-jit" ;;
33+
*) expected="" ;;
34+
esac
35+
case "$description" in
36+
*"$expected"*) echo "Variant $RUBY_VARIANT: $description" ;;
37+
*) echo "Variant $RUBY_VARIANT is not on: $description" >&2; exit 1 ;;
38+
esac
39+
fi
40+
41+
# Also write every result as JSON, see docker/collect_results.rb.
42+
if [ -n "$RESULTS_DIR" ]; then
43+
export RUBYOPT="-r/app/docker/collect_results.rb $RUBYOPT"
44+
fi
45+
1346
if [ "$#" -eq 0 ]; then
1447
exec bundle exec rake
1548
fi

0 commit comments

Comments
 (0)