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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/AGENT_PROMPT.md +211 -0
  3. data/CHANGELOG.md +281 -0
  4. data/LICENSE +21 -0
  5. data/README.md +354 -0
  6. data/docs/README.md +23 -0
  7. data/docs/benchmarks.md +289 -0
  8. data/docs/ci.md +141 -0
  9. data/docs/configuration.md +429 -0
  10. data/docs/how-it-works.md +207 -0
  11. data/docs/json.md +306 -0
  12. data/docs/plugins.md +408 -0
  13. data/docs/troubleshooting.md +105 -0
  14. data/exe/duel +6 -0
  15. data/lib/duel/across.rb +48 -0
  16. data/lib/duel/baseline.rb +166 -0
  17. data/lib/duel/cli.rb +264 -0
  18. data/lib/duel/config.rb +343 -0
  19. data/lib/duel/context.rb +630 -0
  20. data/lib/duel/init.rb +211 -0
  21. data/lib/duel/json_report.rb +107 -0
  22. data/lib/duel/lock.rb +38 -0
  23. data/lib/duel/metric.rb +496 -0
  24. data/lib/duel/native.rb +162 -0
  25. data/lib/duel/process_sample.rb +144 -0
  26. data/lib/duel/profile.rb +116 -0
  27. data/lib/duel/rake_task.rb +25 -0
  28. data/lib/duel/resource.rb +209 -0
  29. data/lib/duel/rubies.rb +149 -0
  30. data/lib/duel/runner.rb +1114 -0
  31. data/lib/duel/stats.rb +73 -0
  32. data/lib/duel/summary.rb +237 -0
  33. data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
  34. data/lib/duel/templates/duel.rb.erb +35 -0
  35. data/lib/duel/templates/perf.yml.erb +97 -0
  36. data/lib/duel/testing.rb +241 -0
  37. data/lib/duel/version.rb +5 -0
  38. data/lib/duel/worker.rb +211 -0
  39. data/lib/duel.rb +413 -0
  40. data/sig/duel/across.rbs +21 -0
  41. data/sig/duel/baseline.rbs +61 -0
  42. data/sig/duel/cli.rbs +95 -0
  43. data/sig/duel/config.rbs +181 -0
  44. data/sig/duel/context.rbs +387 -0
  45. data/sig/duel/init.rbs +97 -0
  46. data/sig/duel/json_report.rbs +47 -0
  47. data/sig/duel/lock.rbs +12 -0
  48. data/sig/duel/metric.rbs +351 -0
  49. data/sig/duel/native.rbs +98 -0
  50. data/sig/duel/process_sample.rbs +54 -0
  51. data/sig/duel/profile.rbs +64 -0
  52. data/sig/duel/rake_task.rbs +9 -0
  53. data/sig/duel/resource.rbs +106 -0
  54. data/sig/duel/rubies.rbs +45 -0
  55. data/sig/duel/runner.rbs +530 -0
  56. data/sig/duel/stats.rbs +26 -0
  57. data/sig/duel/summary.rbs +87 -0
  58. data/sig/duel/testing.rbs +91 -0
  59. data/sig/duel/version.rbs +3 -0
  60. data/sig/duel/worker.rbs +74 -0
  61. data/sig/duel.rbs +276 -0
  62. data/sig/manifest.yaml +4 -0
  63. metadata +124 -0
@@ -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