Skip to content

Commit e30b5a0

Browse files
authored
ZJIT: Add diff tool for running benchmarks between git refs (ruby#16160)
For Shopify#822 Adds a small script for automatically running benchmarks between two git refs. Example output (doubled an increment to `inline_cfunc_optimized_send_count` to illustrate stat diffs): ``` $ ./zjit_diff --before master I, [2026-03-03T10:24:52.691660 #48554] INFO -- : Existing worktree found at /var/folders/l_/p20zlp5j3wx76vr9ggzgzcmr0000gn/T/ruby-zjit-before HEAD is now at 7a3940e Unify rb_node_list_new and rb_node_list_new2 I, [2026-03-03T10:24:52.729815 #48554] INFO -- : Found existing build for master, skipping build I, [2026-03-03T10:24:52.729881 #48554] INFO -- : Existing worktree found at /var/folders/l_/p20zlp5j3wx76vr9ggzgzcmr0000gn/T/ruby-zjit-after HEAD is now at 3859f12966 dup I, [2026-03-03T10:24:52.801287 #48554] INFO -- : Found existing build for HEAD, skipping build I, [2026-03-03T10:24:52.801354 #48554] INFO -- : Running benchmarks I, [2026-03-03T10:24:52.801404 #48554] INFO -- : ruby-bench already cloned, pulling from upstream <... benchmark run logs ...> before: ruby 4.1.0dev (2026-03-03T14:05:00Z master 7a3940e) +ZJIT dev +PRISM [arm64-darwin25] last_commit=Unify rb_node_list_new and rb_node_list_new2 after: ruby 4.1.0dev (2026-03-03T15:17:20Z :detached: 3859f12966) +ZJIT dev +PRISM [arm64-darwin25] ---------- ------------- ------------- ------------- ------------ bench before (ms) after (ms) after 1st itr before/after lobsters 747.9 ± 2.9% 757.7 ± 2.4% 0.983 0.987 railsbench 1183.4 ± 1.9% 1214.5 ± 2.4% 1.001 0.974 (*) ---------- ------------- ------------- ------------- ------------ Legend: - after 1st itr: ratio of before/after time for the first benchmarking iteration. - before/after: ratio of before/after time. Higher is better for after. Above 1 represents a speedup. - ***: p < 0.001, **: p < 0.01, *: p < 0.05 (Welch's t-test) Output: data/zjit_diff.json ================================================================================ ZJIT Stats Comparison ================================================================================ before (baseline): ruby 4.1.0dev (2026-03-03T14:05:00Z master 7a3940e) +ZJIT dev +PRISM [arm64-darwin25] last_commit=Unify rb_node_list_new and rb_node_list_new2 after: ruby 4.1.0dev (2026-03-03T15:17:20Z :detached: 3859f12966) +ZJIT dev +PRISM [arm64-darwin25] BENCHMARK TIMINGS (lower is better) -------------------------------------------------------------------------------- lobsters: before avg: 0.748s min: 0.706s ★ (baseline) after avg: 0.758s min: 0.735s +1.3% (slower) railsbench: before avg: 1.183s min: 1.158s ★ (baseline) after avg: 1.214s min: 1.182s +2.6% (slower) MEMORY USAGE -------------------------------------------------------------------------------- lobsters: before maxrss: 548.3MB zjit_mem: 68.6MB after maxrss: 520.8MB zjit_mem: 68.3MB railsbench: before maxrss: 208.3MB zjit_mem: 29.3MB after maxrss: 205.7MB zjit_mem: 29.3MB SEND COUNTERS (showing differences > 5.0%) -------------------------------------------------------------------------------- lobsters: inline_cfunc_optimized_send_count 64,066,387 ( 37.3%) → 105,498,619 ( 61.4%) ▲ +64.7% optimized_send_count 136,557,743 ( 79.5%) → 177,989,922 (103.6%) ▲ +30.3% send_count 171,791,578 (100.0%) → 213,223,697 (124.1%) ▲ +24.1% railsbench: inline_cfunc_optimized_send_count 130,638,873 ( 36.3%) → 225,643,322 ( 62.8%) ▲ +72.7% optimized_send_count 306,690,230 ( 85.3%) → 401,694,689 (111.8%) ▲ +31.0% send_count 359,393,477 (100.0%) → 454,397,936 (126.4%) ▲ +26.4% SUMMARY COUNTERS (showing differences > 5.0%) -------------------------------------------------------------------------------- railsbench: invalidation_time 7ms → 7ms ▲ +5.3% COMPILE HIR PASS TIMINGS (showing differences > 5.0%) -------------------------------------------------------------------------------- lobsters: eliminate_dead_code_time 245ms → 232ms ▼ -5.3% ```
1 parent 7a3940e commit e30b5a0

File tree

1 file changed

+272
-0
lines changed

1 file changed

+272
-0
lines changed

tool/zjit_diff.rb

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'fileutils'
5+
require 'optparse'
6+
require 'tmpdir'
7+
require 'logger'
8+
require 'digest'
9+
require 'shellwords'
10+
11+
GitRef = Struct.new(:ref, :commit_hash)
12+
13+
RUBIES_DIR = File.join(Dir.home, '.zjit-diff')
14+
BEFORE_NAME = 'ruby-zjit-before'
15+
AFTER_NAME = 'ruby-zjit-after'
16+
17+
LOG = Logger.new($stderr)
18+
19+
def macos?
20+
Gem::Platform.local == 'darwin'
21+
end
22+
23+
class CommandRunner
24+
def initialize(quiet: false)
25+
@quiet = quiet
26+
end
27+
28+
def cmd(*args, **options)
29+
options[:out] ||= @quiet ? File::NULL : $stderr
30+
options = options.merge(exception: true)
31+
system(*args, **options)
32+
end
33+
end
34+
35+
class ZJITDiff
36+
DATA_FILENAME = File.join('data', 'zjit_diff')
37+
RUBY_BENCH_REPO_URL = 'https://github.com/ruby/ruby-bench.git'
38+
39+
def initialize(before_hash:, after_hash:, runner:, options:)
40+
@before_hash = before_hash
41+
@after_hash = after_hash
42+
@runner = runner
43+
@options = options
44+
end
45+
46+
def bench!
47+
LOG.info('Running benchmarks')
48+
ruby_bench_path = @options[:bench_path] || setup_ruby_bench
49+
run_benchmarks(ruby_bench_path)
50+
end
51+
52+
private
53+
54+
def run_benchmarks(ruby_bench_path)
55+
Dir.chdir(ruby_bench_path) do
56+
@runner.cmd({ 'RUBIES_DIR' => RUBIES_DIR },
57+
'./run_benchmarks.rb',
58+
'--chruby',
59+
"before::#{@before_hash} --zjit-stats;after::#{@after_hash} --zjit-stats",
60+
'--out-name',
61+
DATA_FILENAME,
62+
*@options[:bench_args],
63+
*@options[:name_filters])
64+
65+
@runner.cmd('./misc/zjit_diff.rb', "#{DATA_FILENAME}.json", out: $stdout)
66+
end
67+
end
68+
69+
def setup_ruby_bench
70+
path = File.join(Dir.tmpdir, 'ruby-bench')
71+
if Dir.exist?(path)
72+
LOG.info('ruby-bench already cloned, pulling from upstream')
73+
Dir.chdir(path) do
74+
@runner.cmd('git', 'pull')
75+
end
76+
else
77+
LOG.info("ruby-bench not cloned yet, cloning repository to #{path}")
78+
@runner.cmd('git', 'clone', RUBY_BENCH_REPO_URL, path)
79+
end
80+
path
81+
end
82+
end
83+
84+
class RubyWorktree
85+
attr_reader :hash
86+
87+
BREW_REQUIRED_PACKAGES = %w[openssl readline libyaml].freeze
88+
89+
def initialize(name:, ref:, runner:, force_rebuild: false)
90+
@path = File.join(Dir.tmpdir, name)
91+
@ref = ref
92+
@force_rebuild = force_rebuild
93+
@runner = runner
94+
@hash = nil
95+
96+
setup_worktree
97+
end
98+
99+
def build!
100+
Dir.chdir(@path) do
101+
configure_cmd_args = ['--enable-zjit=dev', '--disable-install-doc']
102+
if macos?
103+
brew_prefixes = BREW_REQUIRED_PACKAGES.map do |pkg|
104+
`brew --prefix #{pkg}`.strip
105+
end
106+
configure_cmd_args << "--with-opt-dir=#{brew_prefixes.join(':')}"
107+
end
108+
configure_cmd_hash = Digest::MD5.hexdigest(configure_cmd_args.join(''))
109+
110+
build_cmd_args = ['-j', 'miniruby']
111+
build_cmd_hash = Digest::MD5.hexdigest(build_cmd_args.join(''))
112+
113+
@hash = "#{configure_cmd_hash}-#{build_cmd_hash}-#{@ref.commit_hash}"
114+
prefix = File.join(RUBIES_DIR, @hash)
115+
116+
if Dir.exist?(prefix) && !@force_rebuild
117+
LOG.info("Found existing build for #{@ref.ref}, skipping build")
118+
return
119+
end
120+
121+
@runner.cmd('./autogen.sh')
122+
123+
cmd = [
124+
'./configure',
125+
*configure_cmd_args,
126+
"--prefix=#{prefix}"
127+
]
128+
129+
@runner.cmd(*cmd)
130+
@runner.cmd('make', *build_cmd_args)
131+
@runner.cmd('make', 'install')
132+
end
133+
end
134+
135+
private
136+
137+
def setup_worktree
138+
if Dir.exist?(@path)
139+
LOG.info("Existing worktree found at #{@path}")
140+
Dir.chdir(@path) do
141+
@runner.cmd('git', 'checkout', @ref.commit_hash)
142+
end
143+
else
144+
LOG.info("Creating worktree for ref '#{@ref.ref}' at #{@path}")
145+
@runner.cmd('git', 'worktree', 'add', '--detach', @path, @ref.commit_hash)
146+
end
147+
end
148+
end
149+
150+
def clean!
151+
[BEFORE_NAME, AFTER_NAME].each do |name|
152+
path = File.join(Dir.tmpdir, name)
153+
if Dir.exist?(path)
154+
LOG.info("Removing worktree at #{path}")
155+
system('git', 'worktree', 'remove', '--force', path)
156+
end
157+
end
158+
159+
if Dir.exist?(RUBIES_DIR)
160+
LOG.info("Removing ruby installations from #{RUBIES_DIR}")
161+
FileUtils.rm_rf(RUBIES_DIR)
162+
end
163+
164+
bench_path = File.join(Dir.tmpdir, 'ruby-bench')
165+
return unless Dir.exist?(bench_path)
166+
167+
LOG.info("Removing ruby-bench clone at #{bench_path}")
168+
FileUtils.rm_rf(bench_path)
169+
end
170+
171+
def parse_ref(ref)
172+
out = `git rev-parse --verify #{ref}`
173+
return nil unless $?.success?
174+
175+
GitRef.new(ref: ref, commit_hash: out.strip)
176+
end
177+
178+
DEFAULT_BENCHMARKS = %w[lobsters railsbench].freeze
179+
180+
options = {}
181+
182+
subtext = <<~HELP
183+
Subcommands:
184+
bench : Run benchmarks
185+
clean : Clean temporary files created by benchmarks
186+
See '#{$PROGRAM_NAME} COMMAND --help' for more information on a specific command.
187+
HELP
188+
189+
top_level = OptionParser.new do |opts|
190+
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
191+
opts.separator('')
192+
opts.separator(subtext)
193+
end
194+
195+
subcommands = {
196+
'bench' => OptionParser.new do |opts|
197+
opts.banner = "Usage: #{$PROGRAM_NAME} [options] <benchmarks to run>"
198+
199+
opts.on('--before REF', 'Git ref for ruby (before)') do |ref|
200+
git_ref = parse_ref ref
201+
if git_ref.nil?
202+
warn "Error: '#{ref}' is not a valid git ref"
203+
exit 1
204+
end
205+
206+
options[:before] = git_ref
207+
end
208+
209+
opts.on('--after REF', 'Git ref for ruby (after)') do |ref|
210+
git_ref = parse_ref ref
211+
if git_ref.nil?
212+
warn "Error: '#{ref}' is not a valid git ref"
213+
exit 1
214+
end
215+
216+
options[:after] = git_ref
217+
end
218+
219+
opts.on('--bench-path PATH',
220+
'Path to an existing ruby-bench repository clone ' \
221+
'(if not specified, ruby-bench will be cloned automatically to a temporary directory)') do |path|
222+
options[:bench_path] = path
223+
end
224+
225+
opts.on('--bench-args ARGS', 'Args to pass to ruby-bench') do |bench_args|
226+
options[:bench_args] = bench_args.shellsplit
227+
end
228+
229+
opts.on('--force-rebuild',
230+
'Force building ruby again instead of using even if existing builds exist in the cache at ~/.diffs') do
231+
options[:force_rebuild] = true
232+
end
233+
234+
opts.on('--quiet', 'Silence output of commands except for benchmark result') do
235+
options[:quiet] = true
236+
end
237+
238+
opts.separator('')
239+
opts.separator('If no benchmarks are specified, the benchmarks that will be run are:')
240+
opts.separator(DEFAULT_BENCHMARKS.join(', '))
241+
end,
242+
'clean' => OptionParser.new do |opts|
243+
end
244+
}
245+
246+
top_level.order!
247+
command = ARGV.shift
248+
subcommands[command].order!
249+
250+
case command
251+
when 'bench'
252+
options[:name_filters] = ARGV.empty? ? DEFAULT_BENCHMARKS : ARGV
253+
options[:after] ||= parse_ref('HEAD')
254+
255+
runner = CommandRunner.new(quiet: options[:quiet])
256+
257+
before = RubyWorktree.new(name: BEFORE_NAME,
258+
ref: options[:before],
259+
runner: runner,
260+
force_rebuild: options[:force_rebuild])
261+
before.build!
262+
after = RubyWorktree.new(name: AFTER_NAME,
263+
ref: options[:after],
264+
runner: runner,
265+
force_rebuild: options[:force_rebuild])
266+
after.build!
267+
268+
zjit_diff = ZJITDiff.new(runner: runner, before_hash: before.hash, after_hash: after.hash, options: options)
269+
zjit_diff.bench!
270+
when 'clean'
271+
clean!
272+
end

0 commit comments

Comments
 (0)