duel 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/AGENT_PROMPT.md +211 -0
- data/CHANGELOG.md +281 -0
- data/LICENSE +21 -0
- data/README.md +354 -0
- data/docs/README.md +23 -0
- data/docs/benchmarks.md +289 -0
- data/docs/ci.md +141 -0
- data/docs/configuration.md +429 -0
- data/docs/how-it-works.md +207 -0
- data/docs/json.md +306 -0
- data/docs/plugins.md +408 -0
- data/docs/troubleshooting.md +105 -0
- data/exe/duel +6 -0
- data/lib/duel/across.rb +48 -0
- data/lib/duel/baseline.rb +166 -0
- data/lib/duel/cli.rb +264 -0
- data/lib/duel/config.rb +343 -0
- data/lib/duel/context.rb +630 -0
- data/lib/duel/init.rb +211 -0
- data/lib/duel/json_report.rb +107 -0
- data/lib/duel/lock.rb +38 -0
- data/lib/duel/metric.rb +496 -0
- data/lib/duel/native.rb +162 -0
- data/lib/duel/process_sample.rb +144 -0
- data/lib/duel/profile.rb +116 -0
- data/lib/duel/rake_task.rb +25 -0
- data/lib/duel/resource.rb +209 -0
- data/lib/duel/rubies.rb +149 -0
- data/lib/duel/runner.rb +1114 -0
- data/lib/duel/stats.rb +73 -0
- data/lib/duel/summary.rb +237 -0
- data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
- data/lib/duel/templates/duel.rb.erb +35 -0
- data/lib/duel/templates/perf.yml.erb +97 -0
- data/lib/duel/testing.rb +241 -0
- data/lib/duel/version.rb +5 -0
- data/lib/duel/worker.rb +211 -0
- data/lib/duel.rb +413 -0
- data/sig/duel/across.rbs +21 -0
- data/sig/duel/baseline.rbs +61 -0
- data/sig/duel/cli.rbs +95 -0
- data/sig/duel/config.rbs +181 -0
- data/sig/duel/context.rbs +387 -0
- data/sig/duel/init.rbs +97 -0
- data/sig/duel/json_report.rbs +47 -0
- data/sig/duel/lock.rbs +12 -0
- data/sig/duel/metric.rbs +351 -0
- data/sig/duel/native.rbs +98 -0
- data/sig/duel/process_sample.rbs +54 -0
- data/sig/duel/profile.rbs +64 -0
- data/sig/duel/rake_task.rbs +9 -0
- data/sig/duel/resource.rbs +106 -0
- data/sig/duel/rubies.rbs +45 -0
- data/sig/duel/runner.rbs +530 -0
- data/sig/duel/stats.rbs +26 -0
- data/sig/duel/summary.rbs +87 -0
- data/sig/duel/testing.rbs +91 -0
- data/sig/duel/version.rbs +3 -0
- data/sig/duel/worker.rbs +74 -0
- data/sig/duel.rbs +276 -0
- data/sig/manifest.yaml +4 -0
- metadata +124 -0
data/lib/duel/stats.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
# The statistics behind a verdict: a distribution-free confidence
|
|
5
|
+
# interval for the median of paired ratios (candidate / baseline), and
|
|
6
|
+
# the decision it supports.
|
|
7
|
+
# @api private
|
|
8
|
+
module Stats
|
|
9
|
+
class << self
|
|
10
|
+
# The 1-based ranks [lower, upper] of the order statistics that bound
|
|
11
|
+
# a +confidence+ interval for the median of +n+ samples, or nil when
|
|
12
|
+
# +n+ is too small for one (fewer than 8 samples, at 99%). It's
|
|
13
|
+
# distribution-free: the true median lies below the k-th smallest
|
|
14
|
+
# sample with probability P(Binomial(n, 1/2) <= k - 1), whatever the
|
|
15
|
+
# samples' distribution, so one outlier can't move it.
|
|
16
|
+
#
|
|
17
|
+
# The least k whose P(Binomial(n, 1/2) <= k) exceeds the tail, its
|
|
18
|
+
# terms each found from the last (C(n, k + 1) = C(n, k) * (n - k) /
|
|
19
|
+
# (k + 1), exactly), so that a thousand benchmarks across the board
|
|
20
|
+
# take a thousand steps, not a billion.
|
|
21
|
+
def median_ranks(n, confidence)
|
|
22
|
+
tail = (1 - confidence) / 2
|
|
23
|
+
whole = 2**n
|
|
24
|
+
k = 0
|
|
25
|
+
term = cumulative = 1
|
|
26
|
+
# (It ends: at k = n, cumulative is the whole.)
|
|
27
|
+
until cumulative.fdiv(whole) > tail
|
|
28
|
+
term = term * (n - k) / (k + 1)
|
|
29
|
+
k += 1
|
|
30
|
+
cumulative += term
|
|
31
|
+
end
|
|
32
|
+
[k, n + 1 - k] if k.positive?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# [low, high] bounds of the +confidence+ interval for the median of
|
|
36
|
+
# +samples+, or nil (see #median_ranks).
|
|
37
|
+
def median_interval(samples, confidence)
|
|
38
|
+
ranks = median_ranks(samples.size, confidence)
|
|
39
|
+
return unless ranks
|
|
40
|
+
|
|
41
|
+
sorted = samples.sort
|
|
42
|
+
lower, upper = ranks
|
|
43
|
+
[sorted.fetch(lower - 1), sorted.fetch(upper - 1)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def median(samples)
|
|
47
|
+
sorted = samples.sort
|
|
48
|
+
mid = sorted.size / 2
|
|
49
|
+
sorted.size.odd? ? sorted.fetch(mid) : (sorted.fetch(mid - 1) + sorted.fetch(mid)) / 2.0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# What paired ratios (candidate / baseline, so above 1 is slower) say
|
|
53
|
+
# against +limit+ (a ratio above 1): see #decide.
|
|
54
|
+
def verdict(ratios, limit:, confidence:) = decide(median_interval(ratios, confidence), limit)
|
|
55
|
+
|
|
56
|
+
# What a confidence +interval+ for the median ratio (or nil, for too
|
|
57
|
+
# few) says against +limit+: :slower when it lies above the limit,
|
|
58
|
+
# :faster when it lies below 1 / limit, :same when it lies below the
|
|
59
|
+
# limit (it isn't slower: nothing more needs proving), and :undecided
|
|
60
|
+
# while it straddles the limit.
|
|
61
|
+
def decide(interval, limit)
|
|
62
|
+
return :undecided unless interval
|
|
63
|
+
|
|
64
|
+
low, high = interval
|
|
65
|
+
return :slower if low > limit
|
|
66
|
+
return :faster if high < 1 / limit
|
|
67
|
+
return :same if high < limit
|
|
68
|
+
|
|
69
|
+
:undecided
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/duel/summary.rb
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runner"
|
|
4
|
+
|
|
5
|
+
module Duel
|
|
6
|
+
# A run's report in Markdown, for a pull request: GitHub Actions' job
|
|
7
|
+
# summary, or a comment (see CLI). It has the verdict, the two versions
|
|
8
|
+
# (and their Rubies, where they differ), a table of the benchmarks that
|
|
9
|
+
# changed, regressions first, each regression explained as the terminal
|
|
10
|
+
# report explains it, those unsettled and those that failed. The rest
|
|
11
|
+
# are collapsed, so it stays short with hundreds of benchmarks.
|
|
12
|
+
# @api private
|
|
13
|
+
class Summary
|
|
14
|
+
# The most characters it has (a GitHub comment has at most 65,536):
|
|
15
|
+
# past it, regressions go unexplained, from the last, and the job's
|
|
16
|
+
# log has the rest.
|
|
17
|
+
LIMIT = 60_000
|
|
18
|
+
|
|
19
|
+
# The most lines of an error it shows.
|
|
20
|
+
ERROR_LINES = 12
|
|
21
|
+
|
|
22
|
+
# A benchmark's verdict, and where it goes in the table (in that
|
|
23
|
+
# order).
|
|
24
|
+
VERDICTS = %w[regressed error unsettled faster changed skipped same].freeze
|
|
25
|
+
|
|
26
|
+
# The verdicts that fail a run, which stand out.
|
|
27
|
+
FAILED = %w[regressed error].freeze
|
|
28
|
+
|
|
29
|
+
# +text+ as code, in Markdown: in a span of more backticks than any it
|
|
30
|
+
# has (and spaced from one at either end).
|
|
31
|
+
def self.code(text)
|
|
32
|
+
ticks = "`" * ([0, *text.scan(/`+/).map(&:size)].max + 1)
|
|
33
|
+
padded = (text.start_with?("`") || text.end_with?("`")) ? " #{text} " : text
|
|
34
|
+
"#{ticks}#{padded}#{ticks}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A run that didn't compare (+title+ says why), with what it said.
|
|
38
|
+
def self.note(title, message) = "### Duel: #{title}\n\n#{block(message)}\n"
|
|
39
|
+
|
|
40
|
+
# +text+ in a fenced block of its own: fenced with more backticks than
|
|
41
|
+
# any run of them it has, three at least.
|
|
42
|
+
def self.block(text)
|
|
43
|
+
fence = "`" * [3, *text.scan(/`{3,}/).map { |run| run.size + 1 }].max
|
|
44
|
+
"#{fence}text\n#{text.chomp}\n#{fence}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# +result+'s verdict (see VERDICTS): the JSON report's too (see
|
|
48
|
+
# JSONReport).
|
|
49
|
+
def self.verdict(result)
|
|
50
|
+
if result.error then "error"
|
|
51
|
+
elsif result.skipped then "skipped"
|
|
52
|
+
elsif result.regressed.any? then "regressed"
|
|
53
|
+
elsif result.unsettled? then "unsettled"
|
|
54
|
+
elsif result.timed.any? { |_, t| t.fetch("verdict") == "faster" } then "faster"
|
|
55
|
+
elsif changed_counts(result).any? then "changed"
|
|
56
|
+
else "same"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# {metric => [baseline, candidate]}, an iteration, of the counted
|
|
61
|
+
# measures that differ.
|
|
62
|
+
def self.changed_counts(result)
|
|
63
|
+
result.counted.to_h do |metric, c|
|
|
64
|
+
n = c.fetch("n")
|
|
65
|
+
[metric, [c.fetch("baseline").fdiv(n), c.fetch("candidate").fdiv(n)]] #: [String, [Float, Float]]
|
|
66
|
+
end.reject { |_, (base, candidate)| base == candidate }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# +runner+ ran, and made +results+, comparing +baseline+ with
|
|
70
|
+
# +candidate+ (each a description, in Markdown) as +settings+ say.
|
|
71
|
+
def initialize(runner, results, settings:, baseline:, candidate:)
|
|
72
|
+
@runner = runner
|
|
73
|
+
@results = results
|
|
74
|
+
@settings = settings
|
|
75
|
+
@versions = {"baseline" => baseline, "candidate" => candidate}
|
|
76
|
+
@regressed = results.select { |r| Summary.verdict(r) == "regressed" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The Markdown: every regression explained, or as many as LIMIT has
|
|
80
|
+
# room for.
|
|
81
|
+
def markdown
|
|
82
|
+
explained = @regressed.size
|
|
83
|
+
text = render(explained)
|
|
84
|
+
text = render(explained -= 1) while text.size > LIMIT && explained.positive?
|
|
85
|
+
text
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# The Markdown, with the first +explained+ regressions explained.
|
|
91
|
+
def render(explained)
|
|
92
|
+
same, changed = sorted.partition { |r| Summary.verdict(r) == "same" }
|
|
93
|
+
[heading, versions, overview, table(changed), regressions(explained), across, unsettled, errors, unchanged(same)]
|
|
94
|
+
.compact.join("\n\n") + "\n"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def heading
|
|
98
|
+
errors = @results.count(&:error)
|
|
99
|
+
failed = [(Runner.count(@regressed.size, "regression") unless @regressed.empty?),
|
|
100
|
+
("slower across the board" unless @runner.regressed_across.empty?),
|
|
101
|
+
(Runner.count(errors, "error") unless errors.zero?)].compact
|
|
102
|
+
return "### Duel failed: #{failed.join(", ")}" unless failed.empty?
|
|
103
|
+
|
|
104
|
+
"### Duel passed: none of #{Runner.count(@results.size, "benchmark")} slower"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Each version, on its Ruby where theirs differ, and the plugins
|
|
108
|
+
# used.
|
|
109
|
+
def versions
|
|
110
|
+
rubies = @runner.rubies
|
|
111
|
+
plugins = @runner.plugins
|
|
112
|
+
lines = @versions.map do |side, described|
|
|
113
|
+
ruby = " on #{Summary.code(rubies.fetch(side))}" if rubies.values.uniq.size > 1
|
|
114
|
+
"- **#{side.capitalize}:** #{described}#{ruby}"
|
|
115
|
+
end
|
|
116
|
+
lines << "- **Plugins:** #{plugins.map { |plugin| Summary.code(plugin.to_s) }.join(", ")}" unless plugins.empty?
|
|
117
|
+
lines.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def overview
|
|
121
|
+
format("%s in %.0fs. A timing is slower when a %s%% confidence interval for its ratio, the candidate's to the " \
|
|
122
|
+
"baseline's, is above %s.", Runner.count(@results.size, "benchmark"), @runner.seconds,
|
|
123
|
+
(@settings.confidence * 100).round(1), @settings.limit)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The results, in the order of their VERDICTS, and the run's order in
|
|
127
|
+
# each (grouped, not sorted: Ruby's sort isn't stable everywhere).
|
|
128
|
+
def sorted
|
|
129
|
+
grouped = @results.group_by { |r| Summary.verdict(r) }
|
|
130
|
+
VERDICTS.flat_map { |shown| grouped.fetch(shown, []) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def table(changed)
|
|
134
|
+
return if changed.empty?
|
|
135
|
+
|
|
136
|
+
rows = changed.map do |r|
|
|
137
|
+
shown = Summary.verdict(r)
|
|
138
|
+
row(Summary.code(name(r)), FAILED.include?(shown) ? "**#{shown}**" : shown, timings(r), counts(r))
|
|
139
|
+
end
|
|
140
|
+
([row("Benchmark", "Verdict", "Timings (ratio [interval])", "Counts an iteration"), "|---|---|---|---|"] + rows).join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Those that didn't change, collapsed.
|
|
144
|
+
def unchanged(same)
|
|
145
|
+
return if same.empty?
|
|
146
|
+
|
|
147
|
+
rows = same.map { |r| row(Summary.code(name(r)), timings(r)) }
|
|
148
|
+
"<details>\n<summary>#{Runner.count(same.size, "benchmark")} unchanged</summary>\n\n" \
|
|
149
|
+
"#{[row("Benchmark", "Timings (ratio [interval])"), "|---|---|", *rows].join("\n")}\n\n</details>"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A table's row of +cells+.
|
|
153
|
+
def row(*cells) = "| #{cells.join(" | ")} |"
|
|
154
|
+
|
|
155
|
+
# A benchmark's name, for a table's cell.
|
|
156
|
+
def name(result) = result.name.tr("\n", " ").gsub("|", "\\|")
|
|
157
|
+
|
|
158
|
+
# Each timed measure's ratio and its interval, with its verdict where
|
|
159
|
+
# it isn't the same (or why it was skipped).
|
|
160
|
+
def timings(result)
|
|
161
|
+
skipped = result.skipped
|
|
162
|
+
return skipped.gsub("|", "\\|") if skipped
|
|
163
|
+
|
|
164
|
+
result.timed.map do |metric, t|
|
|
165
|
+
low, high = t.fetch("interval")
|
|
166
|
+
shown = t.fetch("verdict")
|
|
167
|
+
format("%s %.3f [%.3f, %.3f]%s", metric, t.fetch("median"), low, high, (" #{shown}" unless shown == "same"))
|
|
168
|
+
end.join("<br>")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Each counted measure that changed, an iteration, and peak memory
|
|
172
|
+
# that grew too much.
|
|
173
|
+
def counts(result)
|
|
174
|
+
counted = Summary.changed_counts(result).map do |metric, (base, candidate)|
|
|
175
|
+
format("%s %.4g → %.4g", metric, base, candidate)
|
|
176
|
+
end
|
|
177
|
+
if result.more_memory?
|
|
178
|
+
memory = result.memory #: Hash[String, Integer]
|
|
179
|
+
counted << "peak_memory #{mb(memory.fetch("baseline"))} → #{mb(memory.fetch("candidate"))}"
|
|
180
|
+
end
|
|
181
|
+
counted.join("<br>")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def mb(bytes) = format("%.1fMB", bytes / 1_048_576.0)
|
|
185
|
+
|
|
186
|
+
# The first +explained+ regressions, explained as the terminal report
|
|
187
|
+
# explains them (and the rest named).
|
|
188
|
+
def regressions(explained)
|
|
189
|
+
return if @regressed.empty?
|
|
190
|
+
|
|
191
|
+
shown = @regressed.first(explained).map do |r|
|
|
192
|
+
what = r.mixed? ? "slower in every measurement, by one of #{r.regressed.join(", ")}" : "worse #{r.regressed.join(", ")} in every measurement"
|
|
193
|
+
lines = @runner.explained(r)
|
|
194
|
+
said = "**#{Summary.code(r.name)}**: #{what}"
|
|
195
|
+
lines.empty? ? said : "#{said}\n\n#{Summary.block(lines.join("\n"))}"
|
|
196
|
+
end
|
|
197
|
+
rest = @regressed.size - shown.size
|
|
198
|
+
shown << "#{Runner.count(rest, shown.empty? ? "regression" : "more regression")} explained in the job's log." if rest.positive?
|
|
199
|
+
"#### Regressions\n\n#{shown.join("\n\n")}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The verdicts across the board, of the metrics with enough
|
|
203
|
+
# benchmarks to tell.
|
|
204
|
+
def across
|
|
205
|
+
lines = @runner.across.filter_map do |metric, v|
|
|
206
|
+
low, high = v.fetch("interval")
|
|
207
|
+
next unless low
|
|
208
|
+
|
|
209
|
+
confirmed = " in both measurements" if v["confirmed"]
|
|
210
|
+
format("- %s %.3f [%.3f, %.3f] %s%s (limit %.4f, %s)", metric, v.fetch("median"), low, high, v.fetch("verdict"), confirmed,
|
|
211
|
+
v.fetch("limit"), Runner.count(v.fetch("benchmarks"), "benchmark"))
|
|
212
|
+
end
|
|
213
|
+
"#### Across the board\n\n#{lines.join("\n")}" unless lines.empty?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def unsettled
|
|
217
|
+
names = @results.select { |r| Summary.verdict(r) == "unsettled" }.map { |r| Summary.code(r.name) }
|
|
218
|
+
return if names.empty?
|
|
219
|
+
|
|
220
|
+
"#### Unsettled\n\nA timing too close to the limit to settle doesn't fail the run, but one that's unsettled in run " \
|
|
221
|
+
"after run is noisy: #{names.join(", ")}."
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def errors
|
|
225
|
+
failed = @results.select(&:error)
|
|
226
|
+
return if failed.empty?
|
|
227
|
+
|
|
228
|
+
shown = failed.map do |r|
|
|
229
|
+
error = r.error #: String
|
|
230
|
+
lines = error.lines
|
|
231
|
+
cut = "\n(#{Runner.count(lines.size - ERROR_LINES, "more line")} in the job's log)" if lines.size > ERROR_LINES
|
|
232
|
+
"**#{Summary.code(r.name)}**\n\n#{Summary.block(lines.first(ERROR_LINES).join)}#{cut}"
|
|
233
|
+
end
|
|
234
|
+
"#### Errors\n\n#{shown.join("\n\n")}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Benchmarks, which Duel runs on two versions of the code, the baseline
|
|
4
|
+
# (where your branch left main) and the candidate (your changes), and
|
|
5
|
+
# fails if the candidate is slower at any of them. Each version's workers
|
|
6
|
+
# load <%= required ? "#{required} and " : "" %>every duel/**/*_duel.rb file. See
|
|
7
|
+
# https://github.com/sferik/duel-ruby/blob/main/docs/benchmarks.md.
|
|
8
|
+
#
|
|
9
|
+
# To write benchmarks that tell a regression from noise:
|
|
10
|
+
#
|
|
11
|
+
# - Benchmark the work that users depend on being fast: the main entry
|
|
12
|
+
# points, and whatever runs in a loop or once per request. Cover typical
|
|
13
|
+
# inputs, and the small and large extremes, as separate benchmarks.
|
|
14
|
+
# - Name each one "area: what it does". `duel --filter "^parse:"` picks
|
|
15
|
+
# out an area.
|
|
16
|
+
# - Time only the work. Build inputs and read fixtures in `once`, which
|
|
17
|
+
# isn't timed. To prepare each sample's work, take |n| (the
|
|
18
|
+
# iterations) and wrap the preparation in `untimed`.
|
|
19
|
+
# - Make every iteration alike: fixed inputs, seeded randomness
|
|
20
|
+
# (Random.new(42)), and caches warmed in `once`. Undo what an
|
|
21
|
+
# iteration changes with an `around_iteration` hook, or put it back
|
|
22
|
+
# with a `before_sample` hook.
|
|
23
|
+
# - Aim for microseconds to a few milliseconds an iteration, and split
|
|
24
|
+
# anything slower.
|
|
25
|
+
# - Use `only: :count` for work that's mostly waiting (on a socket, say),
|
|
26
|
+
# and `only: :timing` for work on threads of its own.
|
|
27
|
+
# - Use only the API that the main branch has: a benchmark that the
|
|
28
|
+
# baseline can't run is skipped.
|
|
29
|
+
|
|
30
|
+
# An example, to replace with benchmarks of your own code.
|
|
31
|
+
duel "example: join words" do
|
|
32
|
+
once { @words = %w[the quick brown fox jumps over the lazy dog] } # not timed
|
|
33
|
+
@words.join(" ") # timed
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md
|
|
4
|
+
#
|
|
5
|
+
# Duel follows conventions, so set only what differs: benchmarks are in
|
|
6
|
+
# duel/**/*_duel.rb, each version's lib/ is first on the load path, a
|
|
7
|
+
# change is compared when it touches lib/ or ext/, and releases are
|
|
8
|
+
# tagged v*.
|
|
9
|
+
Duel.configure do |c|
|
|
10
|
+
<% if library -%>
|
|
11
|
+
# The library each version's workers require (from the gemspec, unless
|
|
12
|
+
# this says otherwise).
|
|
13
|
+
# c.library = "<%= library %>"
|
|
14
|
+
<% elsif guess -%>
|
|
15
|
+
# The library each version's workers require, from its lib/.
|
|
16
|
+
c.library = "<%= guess %>"
|
|
17
|
+
<% else -%>
|
|
18
|
+
# The library each version's workers require, from its lib/ (there's no
|
|
19
|
+
# gemspec to name it). Or, to load each version some other way:
|
|
20
|
+
# c.load { |root| require File.join(root, "config", "boot") }
|
|
21
|
+
# c.library = "my_library"
|
|
22
|
+
<% end -%>
|
|
23
|
+
<% if ext -%>
|
|
24
|
+
|
|
25
|
+
# Builds each version's C extension, so that each is measured with its
|
|
26
|
+
# own build.
|
|
27
|
+
c.build = "bundle exec rake compile"
|
|
28
|
+
<% end -%>
|
|
29
|
+
<% if version_file -%>
|
|
30
|
+
|
|
31
|
+
# Each version runs on the Ruby that its own .ruby-version names, so a
|
|
32
|
+
# branch that upgrades Ruby is compared on each version's Ruby.
|
|
33
|
+
c.ruby = :version_file
|
|
34
|
+
<% end -%>
|
|
35
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
name: Perf
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
schedule:
|
|
8
|
+
- cron: "0 4 * * *"
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
pull-requests: write # to comment on a pull request with the report
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
duel:
|
|
17
|
+
name: Duel
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
<% if working_directory -%>
|
|
20
|
+
# The project is in a subdirectory of the repository: Duel runs there,
|
|
21
|
+
# and checks out the whole repository for the baseline.
|
|
22
|
+
defaults:
|
|
23
|
+
run:
|
|
24
|
+
working-directory: <%= working_directory %>
|
|
25
|
+
<% end -%>
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v7
|
|
28
|
+
with:
|
|
29
|
+
fetch-depth: 0 # the baseline comes from history, tags included
|
|
30
|
+
# (And whatever else your tests need set up: packages, services.)
|
|
31
|
+
<% if version_file -%>
|
|
32
|
+
# Each version runs on the Ruby its own .ruby-version names (see
|
|
33
|
+
# .duel.rb): for a pull request that upgrades Ruby, the main branch's
|
|
34
|
+
# too, linked where Duel finds it (as chruby would). It's set up
|
|
35
|
+
# before the project's own Ruby, which runs Duel.
|
|
36
|
+
- name: Find the baseline's Ruby
|
|
37
|
+
id: baseline
|
|
38
|
+
if: github.event_name == 'pull_request'
|
|
39
|
+
run: echo "ruby=$(git show "origin/$GITHUB_BASE_REF:./.ruby-version" 2>/dev/null)" >> "$GITHUB_OUTPUT"
|
|
40
|
+
- uses: ruby/setup-ruby@v1
|
|
41
|
+
id: baseline-ruby
|
|
42
|
+
if: steps.baseline.outputs.ruby != ''
|
|
43
|
+
with:
|
|
44
|
+
ruby-version: ${{ steps.baseline.outputs.ruby }}
|
|
45
|
+
- name: Link the baseline's Ruby where Duel finds it
|
|
46
|
+
if: steps.baseline.outputs.ruby != ''
|
|
47
|
+
run: |
|
|
48
|
+
prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
|
|
49
|
+
mkdir -p ~/.rubies
|
|
50
|
+
ln -sfn "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
|
|
51
|
+
<% end -%>
|
|
52
|
+
- uses: ruby/setup-ruby@v1
|
|
53
|
+
with:
|
|
54
|
+
<% if working_directory -%>
|
|
55
|
+
working-directory: <%= working_directory %>
|
|
56
|
+
<% end -%>
|
|
57
|
+
<% if ruby_version -%>
|
|
58
|
+
ruby-version: "<%= ruby_version %>"
|
|
59
|
+
<% end -%>
|
|
60
|
+
bundler-cache: true
|
|
61
|
+
# Against the main branch for a pull request, what was there before
|
|
62
|
+
# for a push, and the last release on a schedule (slow creep). Duel
|
|
63
|
+
# adds its report to the job's summary, and writes it to duel.md for
|
|
64
|
+
# the comment.
|
|
65
|
+
- name: Compare
|
|
66
|
+
env:
|
|
67
|
+
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
|
|
68
|
+
# (Run by hand, it compares anyway: with nothing changed, the code
|
|
69
|
+
# with itself, which checks the setup.)
|
|
70
|
+
DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
|
|
71
|
+
run: |
|
|
72
|
+
# (A branch's first push, or one that rewrote history, has no
|
|
73
|
+
# commit before it here to compare against.)
|
|
74
|
+
if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
|
|
75
|
+
echo "::notice::No commit before this push to compare against"
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
|
|
79
|
+
# The report, as a comment on the pull request: one comment, updated
|
|
80
|
+
# on every push. (A pull request from a fork gets no comment, as its
|
|
81
|
+
# token can't write one: the job's summary has the report.)
|
|
82
|
+
- name: Comment on the pull request
|
|
83
|
+
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
|
84
|
+
env:
|
|
85
|
+
GH_TOKEN: ${{ github.token }}
|
|
86
|
+
PR: ${{ github.event.pull_request.number }}
|
|
87
|
+
run: |
|
|
88
|
+
[ -f "$RUNNER_TEMP/duel.md" ] || exit 0
|
|
89
|
+
marker="<!-- <%= marker %> -->"
|
|
90
|
+
{ echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md"
|
|
91
|
+
ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
|
|
92
|
+
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
|
|
93
|
+
if [ -n "$ids" ]; then
|
|
94
|
+
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
|
|
95
|
+
else
|
|
96
|
+
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
|
|
97
|
+
fi
|