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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -0
- data/exe/benchtrack +5 -0
- data/lib/benchtrack/cli.rb +118 -0
- data/lib/benchtrack/ips_capture.rb +22 -0
- data/lib/benchtrack/measurement.rb +51 -0
- data/lib/benchtrack/orchestrator.rb +136 -0
- data/lib/benchtrack/report.rb +155 -0
- data/lib/benchtrack/stats.rb +103 -0
- data/lib/benchtrack/suite_runner.rb +77 -0
- data/lib/benchtrack/version.rb +5 -0
- data/lib/benchtrack.rb +29 -0
- metadata +58 -0
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
|
+
[](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,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
|
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: []
|