benchtrack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e2ea31f63cdc62a5b3863f56d1a7f081ae877cda4645396b2d0b2ad67c910f1
4
+ data.tar.gz: 06f9f09c0a6d518bb7aa7aa036f25db9d60d4a85b461e877638272dbd45012a1
5
+ SHA512:
6
+ metadata.gz: d93c457d1873ff66b149fa88ab072683ea6f76985b162f4a89fb5598227d3c52a9d045bf6049831bf0f055bd01ccf395f61866694f294e17a6c98e0ab94467c7
7
+ data.tar.gz: 9d395f2019f6fc4b0f52247bf568aa98128dafaec3d37ae914511ad9788750de220fe50c55fb9833315ee9c39a8aba32b9243063d2df29248f7a1076da389901
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ojus Chugh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # BenchTrack
2
+
3
+ [![CI](https://github.com/ojuschugh1/benchtrack/actions/workflows/ci.yml/badge.svg)](https://github.com/ojuschugh1/benchtrack/actions/workflows/ci.yml)
4
+
5
+ BenchTrack is pull-request-time performance regression testing for Ruby libraries. It takes an existing benchmark-ips suite, unmodified, and turns it into a paired base-versus-head comparison: both git refs are checked out into worktrees, measured in interleaved randomized blocks under one pinned Ruby, and the check fails only when a slowdown is both larger than a practical threshold and statistically supported. BenchTrack is Ruby-native and service-free: no hosted backend, no runtime gem dependencies, everything runs on the machine you invoke it on.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ gem install benchtrack
11
+ ```
12
+
13
+ Requires Ruby 3.1 or newer, Linux or macOS, and git. The measured project needs benchmark-ips in its bundle; BenchTrack itself declares zero runtime dependencies.
14
+
15
+ You can also run it straight from a checkout without installing anything: clone the repository and invoke `ruby -I/path/to/benchtrack/lib /path/to/benchtrack/exe/benchtrack ...`. There is nothing to build and no dependency beyond a Ruby that can load benchmark-ips for the project being measured.
16
+
17
+ ## Quickstart
18
+
19
+ Run your suite once to check that BenchTrack can execute and parse it:
20
+
21
+ ```
22
+ benchtrack run bench/ips.rb
23
+ ```
24
+
25
+ Then compare two refs:
26
+
27
+ ```
28
+ benchtrack compare main my-branch --suite bench/ips.rb
29
+ ```
30
+
31
+ `--suite` is required and is a path relative to the repository root. Optional flags: `--blocks N` (measurement blocks, default 10), `--threshold PCT` (practical slowdown threshold in percent, default 5.0), `--seed N` (integer seed for reproducible runs, generated and recorded when absent), `--json PATH` (report path, default `benchtrack-report.json`), and `--prepare CMD` (shell command run in each worktree after bundle install).
32
+
33
+ Gems with C extensions need a build step in each worktree before the suite can load them, for example `--prepare 'bundle exec rake compile'`. The command runs with the worktree as its working directory and with the Ruby being measured first on `PATH`, and a nonzero exit aborts the comparison.
34
+
35
+ A suite file may call `Benchmark.ips` more than once. A report name that repeats across calls gets the call number appended, so the `json` report of the third call is labelled `json [3]`, while names that appear only once in the file are left alone.
36
+
37
+ The exit status is 0 when every benchmark passes, 1 when at least one supported regression is found, and 2 on operational errors such as unresolvable refs, failed dependency installs, or unparseable suite output. Every completed comparison also writes a JSON report with per-entry statistics and an environment fingerprint, so CI can archive it and scripts can parse it.
38
+
39
+ ## How it measures
40
+
41
+ Measurements are collected in paired randomized blocks: each block runs one base invocation and one head invocation in random order, so time-varying system noise hits both sides roughly equally instead of biasing one. Every invocation is a fresh Ruby subprocess running your suite with the benchmark-ips warmup phase, so JIT, GC, and heap state never carry over between measurements. For each benchmark entry, BenchTrack computes the per-block log ratio of head to base, then applies an exact paired sign-flip permutation test with Holm adjustment across entries. An entry fails only when the estimated slowdown exceeds the practical threshold and the adjusted p-value is below 0.05, so neither noise alone nor a trivial difference alone can fail your build.
42
+
43
+ The full rationale is in [methodology.md](methodology.md). Reproducible pass and fail demonstrations, including a repeated no-change control, are in [validation/README.md](validation/README.md).
44
+
45
+ ## Statistical floor
46
+
47
+ With n blocks, the minimum achievable two-sided permutation p-value is 2/2^n. Block counts below 6 therefore cannot produce a fail verdict: at 5 blocks the floor is 0.0625, which is above the 0.05 significance level, so the gate mathematically cannot fire. The default of 10 blocks gives a floor of about 0.002.
48
+
49
+ ## Roadmap
50
+
51
+ None of the following exists yet; it is planned next work:
52
+
53
+ - benchmark-driver adapter
54
+ - packaged GitHub Action
55
+ - cross-runner calibration study
56
+ - real-gem case studies
57
+ - historical tracking and dashboards
58
+ - allocation tracking
59
+ - JIT-mode matrices (YJIT/ZJIT/no-JIT)
60
+
61
+ ## License
62
+
63
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/benchtrack ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "benchtrack"
4
+
5
+ exit(BenchTrack::CLI.start(ARGV))
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module BenchTrack
6
+ class CLI
7
+ EXIT_OK = 0
8
+ EXIT_REGRESSION = 1
9
+ EXIT_ERROR = 2
10
+
11
+ USAGE = <<~TEXT
12
+ usage: benchtrack <command> [options]
13
+
14
+ commands:
15
+ run SUITE_FILE
16
+ run a benchmark-ips suite once and print each entry
17
+
18
+ compare BASE HEAD --suite PATH [options]
19
+ measure BASE vs HEAD and fail on supported regressions
20
+ --suite PATH benchmark-ips suite, relative to the repository root
21
+ --blocks N measurement blocks (default 10)
22
+ --threshold PCT practical slowdown threshold in percent (default 5.0)
23
+ --seed N integer seed for reproducible runs (default: generated)
24
+ --json PATH JSON report path (default benchtrack-report.json)
25
+ --prepare CMD shell command run in each worktree after bundle install (e.g. 'bundle exec rake compile')
26
+ TEXT
27
+
28
+ def self.start(argv)
29
+ case argv.first
30
+ when "run" then run(argv.drop(1))
31
+ when "compare" then compare(argv.drop(1))
32
+ else
33
+ warn USAGE
34
+ EXIT_ERROR
35
+ end
36
+ rescue OptionParser::ParseError => e
37
+ warn "benchtrack: #{e.message}"
38
+ EXIT_ERROR
39
+ rescue Error => e
40
+ warn "benchtrack: #{e.message}"
41
+ EXIT_ERROR
42
+ end
43
+
44
+ def self.run(argv)
45
+ suite = OptionParser.new.parse(argv).first
46
+ raise ConfigError, "missing required argument: SUITE_FILE" unless suite
47
+
48
+ entries = SuiteRunner.run_suite(suite, chdir: Dir.pwd, env: SuiteRunner.bundler_env(Dir.pwd))
49
+ print Report.run_text(entries)
50
+ EXIT_OK
51
+ end
52
+
53
+ def self.compare(argv)
54
+ options = { threshold_pct: 5.0, blocks: 10, json_path: "benchtrack-report.json" }
55
+ base, head = compare_parser(options).parse(argv)
56
+ raise ConfigError, "missing required argument: BASE" unless base
57
+ raise ConfigError, "missing required argument: HEAD" unless head
58
+ raise ConfigError, "missing required option: --suite" unless options[:suite]
59
+
60
+ config = Config.new(command: "compare", suite: options[:suite], base: base, head: head,
61
+ threshold_pct: options[:threshold_pct], blocks: options[:blocks],
62
+ seed: options[:seed] || Random.new_seed % (2**32),
63
+ json_path: options[:json_path], prepare: options[:prepare])
64
+
65
+ comparison = Orchestrator.compare(config)
66
+ print Report.terminal(comparison)
67
+ write_report(Report.json(comparison), config.json_path)
68
+ comparison.overall == "fail" ? EXIT_REGRESSION : EXIT_OK
69
+ end
70
+
71
+ def self.compare_parser(options)
72
+ OptionParser.new do |o|
73
+ o.on("--suite PATH") { |v| options[:suite] = v }
74
+ o.on("--blocks N") { |v| options[:blocks] = parse_blocks(v) }
75
+ o.on("--threshold PCT") { |v| options[:threshold_pct] = parse_threshold(v) }
76
+ o.on("--seed N") { |v| options[:seed] = parse_seed(v) }
77
+ o.on("--json PATH") { |v| options[:json_path] = v }
78
+ o.on("--prepare CMD") { |v| options[:prepare] = v }
79
+ end
80
+ end
81
+
82
+ def self.parse_blocks(value)
83
+ blocks = begin
84
+ Integer(value)
85
+ rescue ArgumentError, TypeError
86
+ nil
87
+ end
88
+ raise ConfigError, "invalid blocks: #{value} (must be an integer >= 1)" if blocks.nil? || blocks < 1
89
+
90
+ blocks
91
+ end
92
+
93
+ def self.parse_threshold(value)
94
+ threshold = begin
95
+ Float(value)
96
+ rescue ArgumentError, TypeError
97
+ nil
98
+ end
99
+ if threshold.nil? || !threshold.finite? || threshold <= 0
100
+ raise ConfigError, "invalid threshold: #{value} (must be a number greater than zero)"
101
+ end
102
+
103
+ threshold
104
+ end
105
+
106
+ def self.parse_seed(value)
107
+ Integer(value)
108
+ rescue ArgumentError, TypeError
109
+ raise ConfigError, "invalid seed: #{value} (must be an integer)"
110
+ end
111
+
112
+ def self.write_report(json, path)
113
+ File.write(path, json)
114
+ rescue SystemCallError, IOError => e
115
+ raise Error, "cannot write JSON report to #{path}: #{e.message}"
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,22 @@
1
+ require "benchmark/ips"
2
+ require "json"
3
+
4
+ module BenchTrackIpsCapture
5
+ CALLS = []
6
+
7
+ def ips(*args, &block)
8
+ report = super
9
+ CALLS << report.entries.to_h { |e| [e.label.to_s, e.ips.to_f] }
10
+ counts = CALLS.flat_map(&:keys).tally
11
+ entries = CALLS.each_with_index.flat_map do |call, i|
12
+ call.map do |label, ips|
13
+ { "label" => counts[label] > 1 ? "#{label} [#{i + 1}]" : label, "ips" => ips }
14
+ end
15
+ end
16
+ File.write(ENV.fetch("BENCHTRACK_RESULT_PATH"),
17
+ JSON.generate({ "format" => 1, "entries" => entries }))
18
+ report
19
+ end
20
+ end
21
+
22
+ Benchmark.singleton_class.prepend(BenchTrackIpsCapture)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BenchTrack
4
+ module Measurement
5
+ Pairing = Struct.new(:series, :base_only, :head_only, keyword_init: true)
6
+
7
+ module_function
8
+
9
+ def collect(blocks:, rng:)
10
+ runs = { base: [], head: [] }
11
+ blocks.times do
12
+ first = rng.rand(2).zero? ? :base : :head
13
+ second = first == :base ? :head : :base
14
+ runs[first] << yield(first)
15
+ runs[second] << yield(second)
16
+ end
17
+ runs
18
+ end
19
+
20
+ def pair(base_runs, head_runs)
21
+ base_labels = consistent_labels(:base, base_runs)
22
+ head_labels = consistent_labels(:head, head_runs)
23
+
24
+ base_ips = base_runs.map { |run| run.to_h { |e| [e.label, e.ips] } }
25
+ head_ips = head_runs.map { |run| run.to_h { |e| [e.label, e.ips] } }
26
+
27
+ series = (base_labels & head_labels).sort.map do |label|
28
+ PairedSeries.new(label,
29
+ base_ips.map { |h| h.fetch(label) },
30
+ head_ips.map { |h| h.fetch(label) })
31
+ end
32
+
33
+ Pairing.new(series: series,
34
+ base_only: (base_labels - head_labels).sort,
35
+ head_only: (head_labels - base_labels).sort)
36
+ end
37
+
38
+ def consistent_labels(side, runs)
39
+ labels = runs.first.map(&:label)
40
+ runs.each do |run|
41
+ got = run.map(&:label)
42
+ next if got.sort == labels.sort
43
+
44
+ diff = ((labels - got) | (got - labels)).sort
45
+ raise SuiteError,
46
+ "#{side} suite produced inconsistent labels across runs (differing: #{diff.join(", ")})"
47
+ end
48
+ labels
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "digest"
7
+ require "rbconfig"
8
+ require "bundler"
9
+
10
+ module BenchTrack
11
+ module Orchestrator
12
+ module_function
13
+
14
+ def compare(config)
15
+ repo = Dir.pwd
16
+ _, status = Open3.capture2e("git", "rev-parse", "--git-dir", chdir: repo)
17
+ raise GitError, "not inside a git repository: #{repo}" unless status.success?
18
+
19
+ shas = { base: resolve_ref!(config.base), head: resolve_ref!(config.head) }
20
+
21
+ Dir.mktmpdir("benchtrack") do |tmp|
22
+ created = []
23
+ begin
24
+ worktrees = shas.to_h do |side, sha|
25
+ dir = File.join(tmp, side.to_s)
26
+ git("worktree", "add", "--detach", dir, sha, chdir: repo)
27
+ created << dir
28
+ [side, dir]
29
+ end
30
+
31
+ envs = {}
32
+ lockfile_hashes = {}
33
+ worktrees.each do |side, dir|
34
+ envs[side] = install_bundle(side, dir, tmp)
35
+ lockfile_hashes[side.to_s] = lockfile_hash(dir)
36
+ prepare_worktree(side, dir, envs[side], config.prepare) if config.prepare
37
+ end
38
+
39
+ worktrees.each do |side, dir|
40
+ next if File.file?(File.expand_path(config.suite, dir))
41
+
42
+ raise SuiteError, "suite file not found in #{side} worktree: #{config.suite}"
43
+ end
44
+
45
+ invokers = worktrees.to_h do |side, dir|
46
+ [side, -> { SuiteRunner.run_suite(config.suite, chdir: dir, env: envs[side]) }]
47
+ end
48
+
49
+ runs = Measurement.collect(blocks: config.blocks, rng: Random.new(config.seed)) do |side|
50
+ invokers[side].call
51
+ end
52
+ pairing = Measurement.pair(runs[:base], runs[:head])
53
+ results = Stats.analyze(pairing.series, seed: config.seed,
54
+ threshold_pct: config.threshold_pct)
55
+
56
+ Comparison.new(
57
+ base_ref: config.base,
58
+ head_ref: config.head,
59
+ base_sha: shas[:base],
60
+ head_sha: shas[:head],
61
+ results: results,
62
+ base_only: pairing.base_only,
63
+ head_only: pairing.head_only,
64
+ overall: results.any? { |result| result.verdict == "fail" } ? "fail" : "pass",
65
+ fingerprint: Report.fingerprint(seed: config.seed, blocks: config.blocks,
66
+ lockfile_hashes: lockfile_hashes),
67
+ config: config
68
+ )
69
+ ensure
70
+ created.each { |dir| remove_worktree(repo, dir) }
71
+ prune_worktrees(repo)
72
+ end
73
+ end
74
+ end
75
+
76
+ def resolve_ref!(ref)
77
+ out, status = Open3.capture2e("git", "rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
78
+ raise GitError, "cannot resolve git ref: #{ref}" unless status.success?
79
+
80
+ out.strip
81
+ end
82
+
83
+ def git(*args, chdir:)
84
+ out, status = Open3.capture2e("git", *args, chdir: chdir)
85
+ raise GitError, "git #{args.join(' ')} failed:\n#{out}" unless status.success?
86
+
87
+ out
88
+ end
89
+
90
+ def run_command(env, *argv, chdir:)
91
+ Bundler.with_unbundled_env { Open3.capture2e(env, *argv, chdir: chdir) }
92
+ end
93
+
94
+ def install_bundle(side, worktree, tmp)
95
+ gemfile = File.join(worktree, "Gemfile")
96
+ return {} unless File.file?(gemfile)
97
+
98
+ env = { "BUNDLE_GEMFILE" => gemfile, "BUNDLE_PATH" => File.join(tmp, "bundle-#{side}") }
99
+ out, status = run_command(env, RbConfig.ruby, Gem.bin_path("bundler", "bundle"), "install",
100
+ chdir: worktree)
101
+ raise BundleError, "bundle install failed for #{side}:\n#{out}" unless status.success?
102
+
103
+ env
104
+ end
105
+
106
+ def prepare_worktree(side, worktree, env, command)
107
+ path = "#{File.dirname(RbConfig.ruby)}#{File::PATH_SEPARATOR}#{ENV['PATH']}"
108
+ out, status = run_command(env.merge("PATH" => path), "sh", "-c", command, chdir: worktree)
109
+ raise PrepareError, "prepare command failed for #{side}:\n#{out}" unless status.success?
110
+ end
111
+
112
+ def lockfile_hash(worktree)
113
+ lockfile = File.join(worktree, "Gemfile.lock")
114
+ return "unknown" unless File.file?(lockfile)
115
+
116
+ Digest::SHA256.hexdigest(File.read(lockfile))
117
+ end
118
+
119
+ def remove_worktree(repo, worktree)
120
+ git("worktree", "remove", "--force", worktree, chdir: repo)
121
+ rescue StandardError => e
122
+ warn "benchtrack: could not remove worktree #{worktree}: #{e.message}"
123
+ begin
124
+ FileUtils.rm_rf(worktree)
125
+ rescue StandardError => rm_error
126
+ warn "benchtrack: could not delete #{worktree}: #{rm_error.message}"
127
+ end
128
+ end
129
+
130
+ def prune_worktrees(repo)
131
+ git("worktree", "prune", chdir: repo)
132
+ rescue StandardError => e
133
+ warn "benchtrack: git worktree prune failed: #{e.message}"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+ require "time"
6
+ require "rbconfig"
7
+
8
+ module BenchTrack
9
+ module Report
10
+ ROW_FORMAT = "%-26s%-12s%-21s%-10s%s"
11
+
12
+ module_function
13
+
14
+ def fingerprint(seed:, blocks:, lockfile_hashes:)
15
+ {
16
+ "cpu_model" => probe { cpu_model },
17
+ "cpu_cores" => probe { Etc.nprocessors },
18
+ "os" => probe { os_description },
19
+ "kernel" => probe { "#{Etc.uname[:sysname]} #{Etc.uname[:release]}" },
20
+ "ruby" => probe { RUBY_DESCRIPTION },
21
+ "ruby_binary" => probe { RbConfig.ruby },
22
+ "jit" => probe { jit_state },
23
+ "lockfile_sha256" => lockfile_hashes,
24
+ "seed" => seed,
25
+ "blocks" => blocks,
26
+ "timestamp" => probe { Time.now.utc.iso8601 },
27
+ "benchtrack_version" => VERSION
28
+ }
29
+ end
30
+
31
+ def terminal(comparison)
32
+ config = comparison.config
33
+ lines = [
34
+ "benchtrack compare: #{comparison.base_ref} (#{comparison.base_sha.to_s[0, 7]}) " \
35
+ "vs #{comparison.head_ref} (#{comparison.head_sha.to_s[0, 7]})",
36
+ "blocks: #{config.blocks} threshold: #{config.threshold_pct}% " \
37
+ "alpha: #{Stats::ALPHA} seed: #{config.seed}",
38
+ ""
39
+ ]
40
+
41
+ unless comparison.results.empty?
42
+ lines << format(ROW_FORMAT, "label", "change", "95% CI", "holm p", "verdict")
43
+ comparison.results.each { |result| lines << table_row(result) }
44
+ lines << ""
45
+ end
46
+
47
+ unless comparison.base_only.empty?
48
+ lines << "unmatched labels: base only: #{comparison.base_only.join(', ')}"
49
+ end
50
+ unless comparison.head_only.empty?
51
+ lines << "unmatched labels: head only: #{comparison.head_only.join(', ')}"
52
+ end
53
+ lines << "no benchmark labels were paired; nothing was compared" if comparison.results.empty?
54
+ lines << "overall: #{display_verdict(comparison.overall)}"
55
+ lines.join("\n") + "\n"
56
+ end
57
+
58
+ def json(comparison)
59
+ config = comparison.config
60
+ report = {
61
+ "benchtrack_version" => VERSION,
62
+ "overall_verdict" => comparison.overall,
63
+ "refs" => {
64
+ "base" => comparison.base_ref,
65
+ "head" => comparison.head_ref,
66
+ "base_sha" => comparison.base_sha,
67
+ "head_sha" => comparison.head_sha
68
+ },
69
+ "config" => { "threshold_pct" => config.threshold_pct, "alpha" => Stats::ALPHA,
70
+ "prepare" => config.prepare },
71
+ "entries" => comparison.results.map { |result| entry_hash(result) },
72
+ "unmatched_labels" => {
73
+ "base_only" => comparison.base_only,
74
+ "head_only" => comparison.head_only
75
+ },
76
+ "environment" => comparison.fingerprint
77
+ }
78
+ JSON.generate(report)
79
+ end
80
+
81
+ def run_text(entries)
82
+ width = entries.map { |entry| entry.label.length }.max || 0
83
+ lines = entries.map { |entry| format("%-*s %.1f i/s", width, entry.label, entry.ips) }
84
+ lines.join("\n") + "\n"
85
+ end
86
+
87
+ def entry_hash(result)
88
+ {
89
+ "label" => result.label,
90
+ "effect_log_ratio" => result.effect,
91
+ "percent_change" => result.percent_change,
92
+ "ci_log_ratio" => [result.ci_low, result.ci_high],
93
+ "p_value" => result.p_value,
94
+ "holm_p" => result.holm_p,
95
+ "verdict" => result.verdict
96
+ }
97
+ end
98
+
99
+ def table_row(result)
100
+ low_pct = (Math.exp(result.ci_low) - 1) * 100
101
+ high_pct = (Math.exp(result.ci_high) - 1) * 100
102
+ format(ROW_FORMAT,
103
+ result.label,
104
+ format("%+.1f%%", result.percent_change),
105
+ format("[%+.1f%%, %+.1f%%]", low_pct, high_pct),
106
+ format("%.3f", result.holm_p),
107
+ display_verdict(result.verdict))
108
+ end
109
+
110
+ def display_verdict(verdict)
111
+ verdict == "fail" ? "FAIL" : verdict
112
+ end
113
+
114
+ def probe
115
+ yield
116
+ rescue StandardError
117
+ "unknown"
118
+ end
119
+
120
+ def cpu_model
121
+ if darwin?
122
+ command_output("sysctl", "-n", "machdep.cpu.brand_string")
123
+ else
124
+ line = File.foreach("/proc/cpuinfo").find { |l| l.start_with?("model name") }
125
+ line.split(":", 2).last.strip
126
+ end
127
+ end
128
+
129
+ def os_description
130
+ if darwin?
131
+ "#{command_output('sw_vers', '-productName')} #{command_output('sw_vers', '-productVersion')}"
132
+ else
133
+ line = File.foreach("/etc/os-release").find { |l| l.start_with?("PRETTY_NAME=") }
134
+ line.split("=", 2).last.strip.delete_prefix('"').delete_suffix('"')
135
+ end
136
+ end
137
+
138
+ def jit_state
139
+ return "yjit" if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
140
+ return "rjit" if defined?(RubyVM::RJIT) && RubyVM::RJIT.enabled?
141
+ return "mjit" if defined?(RubyVM::MJIT) && RubyVM::MJIT.enabled?
142
+ "none"
143
+ end
144
+
145
+ def darwin?
146
+ RUBY_PLATFORM.include?("darwin")
147
+ end
148
+
149
+ def command_output(*argv)
150
+ output = IO.popen(argv, err: File::NULL, &:read).strip
151
+ raise Error, "no output from #{argv.first}" if !$?.success? || output.empty?
152
+ output
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BenchTrack
4
+ module Stats
5
+ ALPHA = 0.05
6
+ ENUM_LIMIT = 20
7
+ MC_ROUNDS = 10_000
8
+ BOOT_ROUNDS = 10_000
9
+
10
+ module_function
11
+
12
+ def log_ratios(series)
13
+ series.base.zip(series.head).map { |base, head| Math.log(head / base) }
14
+ end
15
+
16
+ def mean(xs)
17
+ xs.sum / xs.length
18
+ end
19
+
20
+ def permutation_pvalue(xs, rng)
21
+ n = xs.length
22
+ observed = 0.0
23
+ xs.each { |x| observed += x }
24
+ observed = observed.abs
25
+
26
+ if n <= ENUM_LIMIT
27
+ total = 1 << n
28
+ count = 0
29
+ total.times do |mask|
30
+ sum = 0.0
31
+ n.times { |i| sum += mask[i] == 1 ? -xs[i] : xs[i] }
32
+ count += 1 if sum.abs >= observed
33
+ end
34
+ count.fdiv(total)
35
+ else
36
+ hits = 0
37
+ MC_ROUNDS.times do
38
+ sum = 0.0
39
+ n.times { |i| sum += rng.rand(2).zero? ? xs[i] : -xs[i] }
40
+ hits += 1 if sum.abs >= observed
41
+ end
42
+ (hits + 1.0) / (MC_ROUNDS + 1)
43
+ end
44
+ end
45
+
46
+ def bootstrap_ci(xs, rng)
47
+ n = xs.length
48
+ means = Array.new(BOOT_ROUNDS) { mean(Array.new(n) { xs[rng.rand(n)] }) }
49
+ means.sort!
50
+ [means[((BOOT_ROUNDS - 1) * 0.025).round], means[((BOOT_ROUNDS - 1) * 0.975).round]]
51
+ end
52
+
53
+ def holm(pvalues)
54
+ return pvalues.dup if pvalues.length < 2
55
+
56
+ m = pvalues.length
57
+ order = pvalues.each_index.sort_by { |i| [pvalues[i], i] }
58
+ adjusted = Array.new(m)
59
+ running_max = 0.0
60
+ order.each_with_index do |original, rank|
61
+ candidate = (m - rank) * pvalues[original]
62
+ running_max = candidate if candidate > running_max
63
+ adjusted[original] = [running_max, 1.0].min
64
+ end
65
+ adjusted
66
+ end
67
+
68
+ def verdict(slowdown_pct, holm_p, threshold_pct)
69
+ slowdown_pct > threshold_pct && holm_p < ALPHA ? "fail" : "pass"
70
+ end
71
+
72
+ def analyze(series_list, seed:, threshold_pct:)
73
+ rng = Random.new(seed)
74
+ partials = series_list.sort_by(&:label).map do |series|
75
+ perm_seed = rng.rand(2**32)
76
+ boot_seed = rng.rand(2**32)
77
+ xs = log_ratios(series)
78
+ p_value = permutation_pvalue(xs, Random.new(perm_seed))
79
+ ci_low, ci_high = bootstrap_ci(xs, Random.new(boot_seed))
80
+ { label: series.label, effect: mean(xs), p_value: p_value,
81
+ ci_low: ci_low, ci_high: ci_high }
82
+ end
83
+
84
+ holm_ps = holm(partials.map { |partial| partial[:p_value] })
85
+
86
+ partials.zip(holm_ps).map do |partial, holm_p|
87
+ effect = partial[:effect]
88
+ slowdown_pct = (1 - Math.exp(effect)) * 100
89
+ EntryResult.new(
90
+ label: partial[:label],
91
+ effect: effect,
92
+ ci_low: partial[:ci_low],
93
+ ci_high: partial[:ci_high],
94
+ p_value: partial[:p_value],
95
+ holm_p: holm_p,
96
+ percent_change: (Math.exp(effect) - 1) * 100,
97
+ slowdown_pct: slowdown_pct,
98
+ verdict: verdict(slowdown_pct, holm_p, threshold_pct)
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "tmpdir"
6
+ require "rbconfig"
7
+ require "bundler"
8
+
9
+ module BenchTrack
10
+ module SuiteRunner
11
+ SHIM = File.expand_path("ips_capture.rb", __dir__)
12
+
13
+ module_function
14
+
15
+ def run_suite(suite_path, chdir:, env: {})
16
+ unless File.file?(File.expand_path(suite_path, chdir))
17
+ raise SuiteError, "suite file not found: #{suite_path}"
18
+ end
19
+
20
+ Dir.mktmpdir("benchtrack") do |tmp|
21
+ result_path = File.join(tmp, "result.json")
22
+ argv = [RbConfig.ruby]
23
+ argv << "-rbundler/setup" if env.key?("BUNDLE_GEMFILE")
24
+ argv.push("-r", SHIM, suite_path)
25
+ out, status = Bundler.with_unbundled_env do
26
+ Open3.capture2e(env.merge("BENCHTRACK_RESULT_PATH" => result_path), *argv, chdir: chdir)
27
+ end
28
+ unless status.success?
29
+ raise SuiteError, "suite exited with status #{status.exitstatus || status}:\n#{out}"
30
+ end
31
+ unless File.file?(result_path)
32
+ raise SuiteError, "suite produced no result file (no Benchmark.ips call?)\nsuite output:\n#{out}"
33
+ end
34
+ parse_result(File.read(result_path), output: out)
35
+ end
36
+ end
37
+
38
+ def bundler_env(dir, bundle_path: nil)
39
+ gemfile = File.expand_path("Gemfile", dir)
40
+ gemfile = ENV["BUNDLE_GEMFILE"] unless File.file?(gemfile)
41
+ return {} if gemfile.nil? || gemfile.empty? || !File.file?(gemfile)
42
+
43
+ env = { "BUNDLE_GEMFILE" => File.expand_path(gemfile) }
44
+ env["BUNDLE_PATH"] = bundle_path if bundle_path
45
+ env
46
+ end
47
+
48
+ def parse_result(text, output: "")
49
+ suffix = output.empty? ? "" : "\nsuite output:\n#{output}"
50
+ data = begin
51
+ JSON.parse(text)
52
+ rescue JSON::ParserError => e
53
+ raise SuiteError, "invalid benchmark result JSON: #{e.message}#{suffix}"
54
+ end
55
+
56
+ unless data.is_a?(Hash) && data["format"] == 1 && data["entries"].is_a?(Array)
57
+ raise SuiteError, "unrecognized benchmark result format#{suffix}"
58
+ end
59
+
60
+ seen = {}
61
+ data["entries"].map do |item|
62
+ label = item.is_a?(Hash) ? item["label"] : nil
63
+ ips = item.is_a?(Hash) ? item["ips"] : nil
64
+ unless label.is_a?(String) && !label.empty?
65
+ raise SuiteError, "benchmark entry has a missing or empty label#{suffix}"
66
+ end
67
+ unless ips.is_a?(Numeric) && ips.to_f.finite? && ips.to_f.positive?
68
+ raise SuiteError, "benchmark entry #{label.inspect} has invalid ips #{ips.inspect}#{suffix}"
69
+ end
70
+ raise SuiteError, "duplicate benchmark label #{label.inspect}#{suffix}" if seen[label]
71
+
72
+ seen[label] = true
73
+ Entry.new(label, ips.to_f)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BenchTrack
4
+ VERSION = "0.1.0"
5
+ end
data/lib/benchtrack.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "benchtrack/version"
4
+
5
+ module BenchTrack
6
+ Error = Class.new(StandardError)
7
+ ConfigError = Class.new(Error)
8
+ SuiteError = Class.new(Error)
9
+ GitError = Class.new(Error)
10
+ BundleError = Class.new(Error)
11
+ PrepareError = Class.new(Error)
12
+
13
+ Entry = Struct.new(:label, :ips)
14
+ Config = Struct.new(:command, :suite, :base, :head, :threshold_pct,
15
+ :blocks, :seed, :json_path, :prepare, keyword_init: true)
16
+ PairedSeries = Struct.new(:label, :base, :head)
17
+ EntryResult = Struct.new(:label, :effect, :ci_low, :ci_high, :p_value, :holm_p,
18
+ :percent_change, :slowdown_pct, :verdict, keyword_init: true)
19
+ Comparison = Struct.new(:base_ref, :head_ref, :base_sha, :head_sha, :results,
20
+ :base_only, :head_only, :overall, :fingerprint, :config,
21
+ keyword_init: true)
22
+ end
23
+
24
+ require_relative "benchtrack/cli"
25
+ require_relative "benchtrack/suite_runner"
26
+ require_relative "benchtrack/orchestrator"
27
+ require_relative "benchtrack/measurement"
28
+ require_relative "benchtrack/stats"
29
+ require_relative "benchtrack/report"
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: benchtrack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ojus Chugh
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Turns an existing benchmark-ips suite into a paired base-versus-head
14
+ performance comparison over git worktrees. Fails CI only when a slowdown is both
15
+ practically large and statistically supported.
16
+ email:
17
+ executables:
18
+ - benchtrack
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE.txt
23
+ - README.md
24
+ - exe/benchtrack
25
+ - lib/benchtrack.rb
26
+ - lib/benchtrack/cli.rb
27
+ - lib/benchtrack/ips_capture.rb
28
+ - lib/benchtrack/measurement.rb
29
+ - lib/benchtrack/orchestrator.rb
30
+ - lib/benchtrack/report.rb
31
+ - lib/benchtrack/stats.rb
32
+ - lib/benchtrack/suite_runner.rb
33
+ - lib/benchtrack/version.rb
34
+ homepage: https://github.com/ojuschugh1/benchtrack
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/ojuschugh1/benchtrack
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.1'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.0.3.1
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: Pull-request-time performance regression testing for Ruby libraries
58
+ test_files: []