Skip to content

Commit 5936824

Browse files
committed
feat(bench): integrate SVG reporting
1 parent 27bd2cb commit 5936824

File tree

5 files changed

+140
-37
lines changed

5 files changed

+140
-37
lines changed

.github/workflows/ci.yml

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,43 @@ jobs:
3737
uses: extractions/setup-just@v1
3838
- name: Build extension
3939
run: just build-extension
40-
- name: Run benchmark
41-
run: just bench
40+
- name: Run benchmarks and generate report
41+
run: just bench heavy_work reports/benchmark_report.svg
42+
- name: Post or update PR comment
43+
if: github.event_name == 'pull_request'
44+
uses: actions/github-script@v6
45+
with:
46+
github-token: ${{ secrets.GITHUB_TOKEN }}
47+
script: |
48+
const fs = require('fs');
49+
const path = 'reports/benchmark_report.svg';
50+
let content = fs.readFileSync(path, 'utf8');
51+
content = '<details>\n<summary>Benchmark Report</summary>\n\n' +
52+
content + '\n</details>';
53+
const issue_number = context.payload.pull_request.number;
54+
const header = '<!-- benchmark-report -->\n';
55+
const comments = await github.rest.issues.listComments({
56+
owner: context.repo.owner,
57+
repo: context.repo.repo,
58+
issue_number
59+
});
60+
const existing = comments.data.find(c => c.body.startsWith(header));
61+
const body = header + content;
62+
if (existing) {
63+
await github.rest.issues.updateComment({
64+
owner: context.repo.owner,
65+
repo: context.repo.repo,
66+
comment_id: existing.id,
67+
body
68+
});
69+
} else {
70+
await github.rest.issues.createComment({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
issue_number,
74+
body
75+
});
76+
}
4277
4378
nix:
4479
runs-on: ubuntu-latest

Justfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ alias t := test
33
test:
44
ruby -Itest test/test_tracer.rb
55

6-
bench name="heavy_work":
7-
ruby test/benchmarks/run_benchmark.rb {{name}}
6+
bench name="heavy_work" write_report="console":
7+
ruby test/benchmarks/run_benchmark.rb {{name}} --write-report={{write_report}}
88

99
build-extension:
1010
cargo build --release --manifest-path gems/native-tracer/ext/native_tracer/Cargo.toml

MAINTANERS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ the generated traces with the fixtures under `test/fixtures`.
3939
Benchmarks can be executed with:
4040

4141
```bash
42-
just bench
42+
just bench heavy_work
43+
```
44+
45+
Passing a second argument writes a report instead of printing the runtime:
46+
47+
```bash
48+
just bench heavy_work reports/bench.svg
4349
```
4450

4551
## Publishing gems

test/benchmarks/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ These benchmarks are **not** executed in CI because they may take longer to run
55
The reference traces are stored via Git LFS so the repository stays lightweight. `run_benchmark.rb` verifies the SHA-256 hash of each fixture and downloads it with `git lfs` on demand if missing.
66

77
At the moment there is a single benchmark (`heavy_work`) that exercises a mixture of array and hash operations while computing prime numbers. More benchmarks will be added as we expand the suite.
8+
9+
Use `run_benchmark.rb --write-report=console BENCHMARK` to execute a single benchmark and print the runtime. Passing a path ending with `.json` or `.svg` will run all benchmarks and write a report in the chosen format.

test/benchmarks/run_benchmark.rb

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,112 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
14
require 'json'
25
require 'fileutils'
36
require 'digest'
47
require 'benchmark'
8+
require 'optparse'
59

6-
USAGE = "Usage: ruby run_benchmark.rb BENCHMARK_NAME"
7-
8-
BENCHMARK = ARGV.shift || abort(USAGE)
910
HASHES = {
1011
'heavy_work' => '912fc0347cb8a57abd94a7defd76b147f3a79e556745e45207b89529f8a59d8b'
11-
}
12+
}.freeze
1213

13-
unless HASHES.key?(BENCHMARK)
14-
abort("Unknown benchmark '#{BENCHMARK}'")
15-
end
16-
17-
PROGRAM = File.join('test', 'benchmarks', 'programs', "#{BENCHMARK}.rb")
18-
FIXTURE = File.expand_path("fixtures/#{BENCHMARK}_trace.json", __dir__)
14+
PROGRAMS_DIR = File.expand_path('programs', __dir__)
15+
FIXTURES_DIR = File.expand_path('fixtures', __dir__)
1916
TMP_DIR = File.expand_path('tmp', __dir__)
20-
OUTPUT_DIR = File.join(TMP_DIR, BENCHMARK)
21-
EXPECTED_HASH = HASHES[BENCHMARK]
22-
23-
FileUtils.mkdir_p(TMP_DIR)
24-
FileUtils.mkdir_p(OUTPUT_DIR)
2517

26-
unless File.exist?(FIXTURE) && Digest::SHA256.file(FIXTURE).hexdigest == EXPECTED_HASH
27-
warn "Reference trace missing or corrupt. Attempting to fetch via git lfs..."
28-
system('git', 'lfs', 'pull', '--include', FIXTURE)
29-
end
30-
31-
raise 'reference trace unavailable' unless File.exist?(FIXTURE)
32-
raise 'reference trace hash mismatch' unless Digest::SHA256.file(FIXTURE).hexdigest == EXPECTED_HASH
18+
WRITE_REPORT_DEFAULT = 'console'
3319

34-
elapsed = Benchmark.realtime do
35-
system('ruby', File.expand_path('../../gems/pure-ruby-tracer/lib/trace.rb', __dir__), '--out-dir', OUTPUT_DIR, PROGRAM)
36-
raise 'trace failed' unless $?.success?
37-
end
38-
puts "Benchmark runtime: #{(elapsed * 1000).round} ms"
20+
options = { write_report: WRITE_REPORT_DEFAULT }
21+
OptionParser.new do |opts|
22+
opts.banner = 'Usage: ruby run_benchmark.rb BENCHMARK_NAME [options]'
23+
opts.on('--write-report=DEST', 'console or path to .json/.svg report') do |dest|
24+
options[:write_report] = dest
25+
end
26+
end.parse!
3927

4028
def files_identical?(a, b)
4129
cmp_result = system('cmp', '-s', a, b)
4230
return $?.success? if !cmp_result.nil?
4331
File.binread(a) == File.binread(b)
4432
end
4533

46-
OUTPUT_TRACE = File.join(OUTPUT_DIR, 'trace.json')
47-
if files_identical?(FIXTURE, OUTPUT_TRACE)
48-
puts 'Trace matches reference.'
34+
def run_benchmark(name)
35+
program = File.join('test', 'benchmarks', 'programs', "#{name}.rb")
36+
fixture = File.expand_path("fixtures/#{name}_trace.json", __dir__)
37+
output_dir = File.join(TMP_DIR, name)
38+
expected_hash = HASHES[name]
39+
40+
FileUtils.mkdir_p(TMP_DIR)
41+
FileUtils.mkdir_p(output_dir)
42+
43+
unless File.exist?(fixture) && Digest::SHA256.file(fixture).hexdigest == expected_hash
44+
warn 'Reference trace missing or corrupt. Attempting to fetch via git lfs...'
45+
system('git', 'lfs', 'pull', '--include', fixture)
46+
end
47+
48+
raise 'reference trace unavailable' unless File.exist?(fixture)
49+
raise 'reference trace hash mismatch' unless Digest::SHA256.file(fixture).hexdigest == expected_hash
50+
51+
elapsed = Benchmark.realtime do
52+
system('ruby', File.expand_path('../../gems/pure-ruby-tracer/lib/trace.rb', __dir__), '--out-dir', output_dir, program)
53+
raise 'trace failed' unless $?.success?
54+
end
55+
runtime_ms = (elapsed * 1000).round
56+
57+
output_trace = File.join(output_dir, 'trace.json')
58+
success = files_identical?(fixture, output_trace)
59+
size_bytes = File.size(output_trace)
60+
61+
{ name: name, runtime_ms: runtime_ms, trace_size: size_bytes, success: success }
62+
end
63+
64+
if options[:write_report] == 'console'
65+
bench = ARGV.shift || abort('Usage: ruby run_benchmark.rb BENCHMARK_NAME [options]')
66+
abort("Unknown benchmark '#{bench}'") unless HASHES.key?(bench)
67+
result = run_benchmark(bench)
68+
puts "Benchmark runtime: #{result[:runtime_ms]} ms"
69+
if result[:success]
70+
puts 'Trace matches reference.'
71+
else
72+
warn 'Trace differs from reference!'
73+
exit 1
74+
end
4975
else
50-
warn 'Trace differs from reference!'
51-
exit 1
76+
benches = ARGV.empty? ? HASHES.keys.sort : ARGV
77+
benches.each { |b| abort("Unknown benchmark '#{b}'") unless HASHES.key?(b) }
78+
results = benches.map { |b| run_benchmark(b) }
79+
80+
dest = options[:write_report]
81+
FileUtils.mkdir_p(File.dirname(dest))
82+
case File.extname(dest)
83+
when '.json'
84+
data = results.map { |r| { benchmark: r[:name], runtime_ms: r[:runtime_ms], trace_bytes: r[:trace_size] } }
85+
File.write(dest, JSON.pretty_generate(data))
86+
when '.svg'
87+
row_height = 25
88+
height = 40 + row_height * results.size
89+
svg = +"<svg xmlns='http://www.w3.org/2000/svg' width='500' height='#{height}'>\n"
90+
svg << " <foreignObject width='100%' height='100%'>\n"
91+
svg << " <style>table{border-collapse:collapse;font-family:sans-serif;}td,th{border:1px solid #999;padding:4px;}</style>\n"
92+
svg << " <table>\n"
93+
svg << " <thead><tr><th>Benchmark</th><th>Runtime (ms)</th><th>Trace size (bytes)</th></tr></thead>\n"
94+
svg << " <tbody>\n"
95+
results.each do |r|
96+
svg << " <tr><td>#{r[:name]}</td><td>#{r[:runtime_ms]}</td><td>#{r[:trace_size]}</td></tr>\n"
97+
end
98+
svg << " </tbody>\n"
99+
svg << " </table>\n"
100+
svg << " </foreignObject>\n"
101+
svg << "</svg>\n"
102+
File.write(dest, svg)
103+
else
104+
abort "Unknown report format '#{dest}'"
105+
end
106+
107+
unless results.all? { |r| r[:success] }
108+
warn 'One or more traces differ from reference!'
109+
exit 1
110+
end
52111
end
112+

0 commit comments

Comments
 (0)