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/config.rb
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
# What Duel knows about a project. Most of it follows from conventions,
|
|
5
|
+
# so most projects need no configuration at all:
|
|
6
|
+
#
|
|
7
|
+
# - benchmarks are in files named *_duel.rb in duel/ (see
|
|
8
|
+
# Duel::DSL#duel)
|
|
9
|
+
# - the library is the gem the project's .gemspec names, required from
|
|
10
|
+
# each version's lib/ (so benchmarks needn't require it)
|
|
11
|
+
# - a project with ext/ is built with `bundle exec rake compile` when
|
|
12
|
+
# a baseline is checked out
|
|
13
|
+
# - a change is compared with where it left the branch origin/HEAD
|
|
14
|
+
# points to (origin/main, say), when lib/ or ext/ changed
|
|
15
|
+
# - releases are tagged v*
|
|
16
|
+
#
|
|
17
|
+
# .duel.rb, at the root, changes what doesn't fit:
|
|
18
|
+
#
|
|
19
|
+
# Duel.configure do |c|
|
|
20
|
+
# c.benchmarks = "perf/**/*_duel.rb"
|
|
21
|
+
# c.build = "make"
|
|
22
|
+
# c.use :redis # a plugin: see #use
|
|
23
|
+
# c.metric MyMetric # see Duel::Metric
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# The file is the candidate's, and every worker loads it too: the
|
|
27
|
+
# baseline's worker runs the candidate's benchmarks and plugins against
|
|
28
|
+
# the baseline's code.
|
|
29
|
+
class Config
|
|
30
|
+
# Where the project is (and its config file).
|
|
31
|
+
attr_reader :root
|
|
32
|
+
|
|
33
|
+
# A glob, from the root, of the files defining benchmarks.
|
|
34
|
+
attr_accessor :benchmarks
|
|
35
|
+
|
|
36
|
+
# Paths, from the root, whose changes a comparison is about: when none
|
|
37
|
+
# of them changed since the baseline, there's nothing to compare.
|
|
38
|
+
attr_accessor :watch
|
|
39
|
+
|
|
40
|
+
# The library to require from each version, as `require` takes it,
|
|
41
|
+
# or nil for none (benchmarks require what they need).
|
|
42
|
+
attr_writer :library
|
|
43
|
+
|
|
44
|
+
# A shell command that builds a checked-out tree, run in it, or nil
|
|
45
|
+
# for none.
|
|
46
|
+
attr_writer :build
|
|
47
|
+
|
|
48
|
+
# The branch a comparison defaults to (nil: the one origin/HEAD names).
|
|
49
|
+
attr_accessor :main_branch
|
|
50
|
+
|
|
51
|
+
# The glob release tags match (for `--base last-release`).
|
|
52
|
+
attr_accessor :release_tags
|
|
53
|
+
|
|
54
|
+
# The Ruby each version runs on (`duel --ruby` overrides it). It's nil
|
|
55
|
+
# for the runner's own, or :version_file for the one the version's own
|
|
56
|
+
# version file names (.ruby-version, .tool-versions or mise.toml, in
|
|
57
|
+
# its tree or above), installed with rbenv, chruby, mise or asdf (see
|
|
58
|
+
# Rubies). It can also be a path to a Ruby for both, or a block given
|
|
59
|
+
# the version's tree, returning the path to its Ruby.
|
|
60
|
+
#
|
|
61
|
+
# c.ruby = :version_file
|
|
62
|
+
# c.ruby = ->(root) { File.join(Dir.home, ".rubies", File.read(File.join(root, ".ruby-version")).strip, "bin", "ruby") }
|
|
63
|
+
attr_accessor :ruby
|
|
64
|
+
|
|
65
|
+
# The metrics #metric added, in order (classes: a Duel::Metric
|
|
66
|
+
# subclass, or running totals' Snapshot), for a plugin's tests to
|
|
67
|
+
# look at (their .measures and .settings).
|
|
68
|
+
attr_reader :metrics
|
|
69
|
+
|
|
70
|
+
# What #prepare (by scope), #in_worker, the hooks (#setup, say: by
|
|
71
|
+
# name) and #measure were given.
|
|
72
|
+
# @api private
|
|
73
|
+
attr_reader :prepares, :in_workers, :hooks, :measures
|
|
74
|
+
|
|
75
|
+
# A configuration of the conventions, for the project at +root+ (a
|
|
76
|
+
# plugin's tests make one to use it in).
|
|
77
|
+
def initialize(root = Dir.pwd)
|
|
78
|
+
@root = root
|
|
79
|
+
@benchmarks = "duel/**/*_duel.rb"
|
|
80
|
+
@watch = %w[lib ext]
|
|
81
|
+
@library = :conventional
|
|
82
|
+
@build = :conventional
|
|
83
|
+
@release_tags = "v*"
|
|
84
|
+
@prepares = SCOPES.to_h { |scope| [scope, []] }
|
|
85
|
+
@metrics = []
|
|
86
|
+
@in_workers = []
|
|
87
|
+
@hooks = HOOKS.to_h { |hook| [hook, []] }
|
|
88
|
+
@used = {}
|
|
89
|
+
@measures = Hash.new do |all, name|
|
|
90
|
+
all[name] = {} #: Hash[Symbol, untyped]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# What #prepare prepares: each version, or each worker.
|
|
95
|
+
# @api private
|
|
96
|
+
SCOPES = %i[version worker].freeze
|
|
97
|
+
|
|
98
|
+
# The library: the gem the project's only .gemspec names (its name's
|
|
99
|
+
# dashes are directories: my-gem is my/gem), unless set.
|
|
100
|
+
def library
|
|
101
|
+
given = @library
|
|
102
|
+
return given unless given == :conventional
|
|
103
|
+
|
|
104
|
+
specs = Dir[File.join(root, "*.gemspec")]
|
|
105
|
+
return unless specs.size == 1
|
|
106
|
+
|
|
107
|
+
name = File.basename(specs.first, ".gemspec")
|
|
108
|
+
File.exist?(File.join(root, "lib", "#{name}.rb")) ? name : name.tr("-", "/")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The build command: `bundle exec rake compile` for a project with
|
|
112
|
+
# ext/ and a Rakefile, unless set.
|
|
113
|
+
def build
|
|
114
|
+
given = @build
|
|
115
|
+
return given unless given == :conventional
|
|
116
|
+
|
|
117
|
+
"bundle exec rake compile" if Dir.exist?(File.join(root, "ext")) && File.exist?(File.join(root, "Rakefile"))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# How a worker loads one version of the code, from the tree at the
|
|
121
|
+
# root it's given, instead of the convention (see #load_into).
|
|
122
|
+
#
|
|
123
|
+
# c.load { |root| require File.join(root, "config", "boot") }
|
|
124
|
+
#
|
|
125
|
+
# (It hides Kernel#load from the configuration's own methods, which
|
|
126
|
+
# never call it: a file is loaded by Duel.load_config.)
|
|
127
|
+
def load(&block) = @loader = block
|
|
128
|
+
|
|
129
|
+
# Loads the version at +root+: its lib/ goes first on the load path,
|
|
130
|
+
# and the library is required from it. Under `bundle exec`, Bundler
|
|
131
|
+
# has already loaded the candidate's gemspec, which may have required
|
|
132
|
+
# files of the candidate's (its version, say): for another version
|
|
133
|
+
# they're forgotten, and constants they defined are redefined quietly.
|
|
134
|
+
# @api private
|
|
135
|
+
def load_into(version_root)
|
|
136
|
+
loader = @loader
|
|
137
|
+
return loader.call(version_root) if loader
|
|
138
|
+
|
|
139
|
+
$LOAD_PATH.unshift(File.join(version_root, "lib"))
|
|
140
|
+
return unless library
|
|
141
|
+
return require(library) if File.expand_path(version_root) == File.expand_path(root)
|
|
142
|
+
|
|
143
|
+
candidate_lib = File.join(File.expand_path(root), "lib", "")
|
|
144
|
+
$LOADED_FEATURES.reject! { |feature| feature.start_with?(candidate_lib) }
|
|
145
|
+
QuietRedefinition.during { require library }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Uses a plugin, by its name: requires it (duel/+name+, the plugin's
|
|
149
|
+
# gem's file: duel-redis's duel/redis, say), gives files of
|
|
150
|
+
# benchmarks its DSL methods, if it has any, and configures Duel for
|
|
151
|
+
# it, with +options+ (see Duel.plugin). Returns what that returned
|
|
152
|
+
# (the plugin's own connection to its server, say).
|
|
153
|
+
#
|
|
154
|
+
# c.use :redis
|
|
155
|
+
# c.use :rails, database: :postgresql
|
|
156
|
+
#
|
|
157
|
+
# A plugin is configured once, the first time it's used: using it
|
|
158
|
+
# again (as a plugin that needs another uses it) returns what that
|
|
159
|
+
# did. Options given again must be the first's, and others fail, as
|
|
160
|
+
# they would be ignored.
|
|
161
|
+
def use(name, **options)
|
|
162
|
+
used = @used[name]
|
|
163
|
+
return used.again(name, options) if used
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
require "duel/#{name}"
|
|
167
|
+
rescue LoadError => e
|
|
168
|
+
raise unless e.path == "duel/#{name}"
|
|
169
|
+
|
|
170
|
+
raise Error, "c.use #{name.inspect}: no duel/#{name} to require: add the duel-#{name} gem to the Gemfile"
|
|
171
|
+
end
|
|
172
|
+
plugin = Duel.plugins.fetch(name) { raise Error, "c.use #{name.inspect}: duel/#{name} registers no plugin #{name.inspect} (Duel.plugin)" }
|
|
173
|
+
DSL.add(plugin::DSL) if plugin.const_defined?(:DSL, false)
|
|
174
|
+
used = @used[name] = Used.new(options)
|
|
175
|
+
used.value = plugin.configure(self, **options)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The plugins used (see #use), by name, in the order they were first
|
|
179
|
+
# used.
|
|
180
|
+
def plugins = @used.keys
|
|
181
|
+
|
|
182
|
+
# A plugin as #use configured it: with +options+, returning +value+.
|
|
183
|
+
# @api private
|
|
184
|
+
class Used
|
|
185
|
+
attr_reader :options
|
|
186
|
+
|
|
187
|
+
attr_accessor :value
|
|
188
|
+
|
|
189
|
+
def initialize(options)
|
|
190
|
+
@options = options
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# What using it again returns: #value, unless +given+ (options)
|
|
194
|
+
# has any but the first's, which fails.
|
|
195
|
+
def again(name, given)
|
|
196
|
+
ignored = given.reject { |option, value| @options.key?(option) && @options.fetch(option) == value }
|
|
197
|
+
return @value if ignored.empty?
|
|
198
|
+
|
|
199
|
+
raise Error, "c.use #{name.inspect}: used already, without #{ignored.map { |option, value| "#{option}: #{value.inspect}" }.join(", ")}: " \
|
|
200
|
+
"give a plugin's options the first time it's used"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Prepares each version, or each worker (+scope+: :version or
|
|
205
|
+
# :worker), by the block, given a Duel::Resource for it: a directory
|
|
206
|
+
# of its own, and help running commands and starting servers there,
|
|
207
|
+
# stopped when it's done with. The block returns the environment
|
|
208
|
+
# variables it gets: a worker's, under its version's.
|
|
209
|
+
#
|
|
210
|
+
# A version's (named "baseline" or "candidate", its #root the
|
|
211
|
+
# version's tree and its #ruby the Ruby it runs on) is prepared once
|
|
212
|
+
# for a run, in the runner, before any of its workers start:
|
|
213
|
+
# installing its bundle, say. Its environment is its workers' (and
|
|
214
|
+
# its process benchmarks').
|
|
215
|
+
#
|
|
216
|
+
# c.prepare(:version) do |v|
|
|
217
|
+
# v.cached("bundle-#{Digest::SHA256.file(File.join(v.root, "Gemfile.lock"))}") { |_| v.run("bundle", "install", chdir: v.root) }
|
|
218
|
+
# {"BUNDLE_GEMFILE" => File.join(v.root, "Gemfile")}
|
|
219
|
+
# end
|
|
220
|
+
#
|
|
221
|
+
# A worker's (named "baseline-1", say) is prepared by the runner as
|
|
222
|
+
# the worker starts: a server of its own, say.
|
|
223
|
+
#
|
|
224
|
+
# c.prepare(:worker) do |w|
|
|
225
|
+
# socket = w.path("redis.sock")
|
|
226
|
+
# w.spawn("redis-server", "--port", "0", "--unixsocket", socket)
|
|
227
|
+
# w.wait_until { File.socket?(socket) }
|
|
228
|
+
# {"REDIS_URL" => "unix://#{socket}"}
|
|
229
|
+
# end
|
|
230
|
+
def prepare(scope, &block)
|
|
231
|
+
blocks = @prepares.fetch(scope) { raise ArgumentError, "prepare(:version) or prepare(:worker), not prepare(#{scope.inspect})" }
|
|
232
|
+
raise ArgumentError, "prepare(#{scope.inspect}): give a block" unless block
|
|
233
|
+
|
|
234
|
+
blocks << block
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Adds a metric to every benchmark: a Duel::Metric subclass, or
|
|
238
|
+
# running totals that the block reads (see Duel::Snapshot), one by
|
|
239
|
+
# name or several by their settings:
|
|
240
|
+
#
|
|
241
|
+
# c.metric("sql_queries") { queries }
|
|
242
|
+
# c.metric("redis_cpu" => {kind: :ratio, unit: "µs"}, "redis_commands" => {kind: :counted}) do
|
|
243
|
+
# {"redis_cpu" => usec, "redis_commands" => calls}
|
|
244
|
+
# end
|
|
245
|
+
#
|
|
246
|
+
# Returns the metric: running totals' takes what explains a sample
|
|
247
|
+
# (see Snapshot.details), given each stretch's readings, with the
|
|
248
|
+
# context the block read with them (+[totals, context]+).
|
|
249
|
+
#
|
|
250
|
+
# c.metric("sql_queries") { [queries, statements] }.details { |stretches, sample| ... }
|
|
251
|
+
def metric(metric = nil, **settings, &read)
|
|
252
|
+
made = case metric
|
|
253
|
+
when String then Snapshot.define({metric => settings}, read)
|
|
254
|
+
when Hash, nil then Snapshot.define(metric || settings, read) # (several, without braces, are keywords)
|
|
255
|
+
else metric
|
|
256
|
+
end
|
|
257
|
+
@metrics << made
|
|
258
|
+
made
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Runs the block in each worker, once its version and the benchmarks
|
|
262
|
+
# have loaded and before its metrics are made: for what only a worker
|
|
263
|
+
# does (loading its data, say: .duel.rb itself is loaded by the runner
|
|
264
|
+
# too). It gets the worker's Stops: what it gives at_stop runs when
|
|
265
|
+
# the worker ends. Returns an InWorker, whose #value is what the block
|
|
266
|
+
# returned, in that worker: a connection, say.
|
|
267
|
+
#
|
|
268
|
+
# redis = c.in_worker { |w| Redis.new.tap { |r| w.at_stop { r.close } } }
|
|
269
|
+
# c.metric("redis_commands") { redis.value.info["total_commands_processed"] }
|
|
270
|
+
def in_worker(&block)
|
|
271
|
+
made = InWorker.new
|
|
272
|
+
@in_workers << lambda do |worker|
|
|
273
|
+
made.made(block.call(worker))
|
|
274
|
+
worker.at_stop { made.forget }
|
|
275
|
+
end
|
|
276
|
+
made
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The hooks (see Duel::HOOKS), for every benchmark, run in each worker
|
|
280
|
+
# before its file's and its own: setup once before a benchmark's
|
|
281
|
+
# samples, and teardown after them (last first), and before_sample
|
|
282
|
+
# before every sample (the warm-up's too), not measured, each given
|
|
283
|
+
# the benchmark (see Sample#benchmark) if it takes it.
|
|
284
|
+
# around_iteration runs around each iteration, given it to run, and
|
|
285
|
+
# after_iterations after each sample's iterations (inside each
|
|
286
|
+
# iteration, with around_iteration), measured with them: to do the
|
|
287
|
+
# work they left to be done later (jobs they queued, say), which is
|
|
288
|
+
# theirs. Unlike a file's, they run where they were written (not in
|
|
289
|
+
# the benchmark's context: they're a plugin's, say).
|
|
290
|
+
def setup(&block) = add_hook(:setup, block)
|
|
291
|
+
|
|
292
|
+
def teardown(&block) = add_hook(:teardown, block)
|
|
293
|
+
|
|
294
|
+
def before_sample(&block) = add_hook(:before_sample, block)
|
|
295
|
+
|
|
296
|
+
def around_iteration(&block) = add_hook(:around_iteration, block)
|
|
297
|
+
|
|
298
|
+
def after_iterations(&block) = add_hook(:after_iterations, block)
|
|
299
|
+
|
|
300
|
+
# Changes a measure's settings, by its name: any metric's, a plugin's
|
|
301
|
+
# or Duel's own (`c.measure("cpu", limit: 1.05)`). `skip: true` leaves
|
|
302
|
+
# it out.
|
|
303
|
+
def measure(name, **settings)
|
|
304
|
+
Metric.check(name, settings)
|
|
305
|
+
@measures[name].merge!(settings)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Whether a measure is skipped (see #measure): for a plugin's metric,
|
|
309
|
+
# to set up nothing for it.
|
|
310
|
+
def skip?(name) = @measures.key?(name) && @measures.fetch(name).fetch(:skip, false)
|
|
311
|
+
|
|
312
|
+
# The files that define benchmarks, in order.
|
|
313
|
+
# @api private
|
|
314
|
+
def benchmark_files = Dir[File.join(root, benchmarks)]
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
def add_hook(name, block) = @hooks.fetch(name) << Duel.hook(name, block)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Silences warnings about redefined constants, while loading a version
|
|
322
|
+
# over files of another's (see Config#load_into).
|
|
323
|
+
# @api private
|
|
324
|
+
module QuietRedefinition
|
|
325
|
+
REDEFINITION = /already initialized constant|previous definition of/
|
|
326
|
+
|
|
327
|
+
# Silences them in this thread (what's silenced, while it is: see
|
|
328
|
+
# #warn) during the block.
|
|
329
|
+
def self.during
|
|
330
|
+
Thread.current[:duel_quiet] = REDEFINITION
|
|
331
|
+
yield
|
|
332
|
+
ensure
|
|
333
|
+
Thread.current[:duel_quiet] = nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def warn(message, ...)
|
|
337
|
+
return if Thread.current[:duel_quiet]&.match?(message)
|
|
338
|
+
|
|
339
|
+
super
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
Warning.singleton_class.prepend(QuietRedefinition)
|
|
343
|
+
end
|