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
data/docs/plugins.md ADDED
@@ -0,0 +1,408 @@
1
+ # Write a Duel plugin
2
+
3
+ A plugin packages what [Duel](../README.md) needs for a server or a
4
+ library, such as a server for each worker, hooks, and metrics, so that
5
+ any project can use it. This document describes
6
+ how to write a plugin and how to test it. For the plugins that are
7
+ available, and how to use one, see [Plugins](../README.md#plugins).
8
+
9
+ ## Plugin conventions
10
+
11
+ By convention, a plugin is a gem named `duel-` and what it's for, such
12
+ as `duel-redis`. A project uses it by name, with `c.use :redis` in
13
+ `.duel.rb`, which requires `duel/redis`. So the plugin's file is
14
+ `lib/duel/redis.rb`, and it registers the plugin under the same name,
15
+ `:redis`.
16
+
17
+ The plugin's module registers itself with `Duel.plugin`, and defines
18
+ `configure`, which `c.use` calls with the configuration and the options
19
+ that it's given. `configure` calls `c.prepare`, `c.in_worker`,
20
+ `c.metric`, and the hooks (`c.before_sample`, say), the same way that
21
+ any `.duel.rb` does, and returns what the project's tests or benchmarks
22
+ might want, such as the plugin's own connection to its server:
23
+
24
+ ```ruby
25
+ # lib/duel/redis.rb
26
+ require "duel"
27
+
28
+ module Duel
29
+ module Redis
30
+ Duel.plugin(:redis, self)
31
+
32
+ # Configures Duel for the plugin: c.use :redis, server: "redis-server"
33
+ def self.configure(config, server: "redis-server")
34
+ config.prepare(:worker) do |w|
35
+ socket = w.path("redis.sock")
36
+ w.spawn(server, "--port", "0", "--unixsocket", socket)
37
+ w.wait_until { File.socket?(socket) }
38
+ {"REDIS_URL" => "unix://#{socket}"}
39
+ end
40
+ connection = config.in_worker { |w| Connection.new.tap { |redis| w.at_stop { redis.close } } }
41
+ config.metric("redis_commands") { connection.value.commands }.costly
42
+ connection
43
+ end
44
+ end
45
+ end
46
+ ```
47
+
48
+ Duel calls `configure` once for each configuration, the first time
49
+ that the plugin is used. A plugin that needs another, such as a plugin
50
+ for an app that needs a database's, calls `config.use` itself, with the
51
+ options it needs. If the project used that plugin first, with options
52
+ of its own, the project's use stands. A later use can repeat the
53
+ options of the first, but another option raises `Duel::Error`, so pass
54
+ only the options that you need. A plugin can also add its own kind of
55
+ benchmark to the DSL. For more information, see
56
+ [Add to the DSL](#add-to-the-dsl).
57
+
58
+ Duel puts the require paths of every gem named `duel` or `duel-*` that
59
+ the runner loaded on each worker's load path, ahead of the version's own
60
+ bundle. A worker that runs a version's own bundle (with
61
+ `BUNDLE_GEMFILE` set to the version's `Gemfile`, say) can still require
62
+ your plugin, even if that bundle doesn't have it, and both versions are
63
+ measured with the runner's copy. Duel doesn't do the same for your
64
+ plugin's dependencies, such as the library that it measures: those come
65
+ from the version's bundle.
66
+
67
+ When you write a plugin, follow these guidelines:
68
+
69
+ - Name its measures after the plugin, such as `redis_*`, so that two
70
+ plugins' measures never clash.
71
+ - Give benchmarks helpers as module functions, such as
72
+ `Duel::Redis.url`.
73
+ - Inside `module Duel`, write `::Redis` for the library's own constants,
74
+ because `Redis` is the plugin's there.
75
+ - Make what a worker needs for itself with `c.in_worker`, and return
76
+ what it returns from `configure`. Don't keep it in the module.
77
+ - Set up what a whole version needs once, such as its bundle, with
78
+ `c.prepare(:version)`, not in each worker's `c.prepare(:worker)`. Run
79
+ its commands on the version's Ruby: `v.env` puts it first on `PATH`.
80
+
81
+ > [!IMPORTANT]
82
+ > A helper that's meant for setup, such as one that runs a query, must
83
+ > call `Duel.unmeasured!` first, for example
84
+ > `Duel.unmeasured!("Duel::Redis.flush")`. It raises an error during a
85
+ > timed stretch (when `Duel.measuring?` is true), where what the helper
86
+ > does would be counted. That includes the block of
87
+ > `Duel::Testing.measure`, but not the block's `untimed` blocks.
88
+
89
+ Duel raises `Duel::Error` when something is used wrong, such as a
90
+ setting that doesn't exist, or a helper that's called where it would be
91
+ counted. A prepare block that fails (`w.failed`, `w.run`, or `w.wait_until`),
92
+ or a worker that dies or hangs, raises `Duel::Failed`, a subclass of
93
+ `Duel::Error`. A plugin's prepare block can raise it too.
94
+
95
+ ## Add to the DSL
96
+
97
+ To give benchmarks a kind of benchmark of the plugin's own, such as a
98
+ request to an app, define a module named `DSL` inside the plugin's
99
+ module. Its public methods are DSL methods: once `.duel.rb` uses the
100
+ plugin, files of benchmarks can call them. Duel loads the files of
101
+ benchmarks after `.duel.rb`, so the plugin's registration with
102
+ `Duel.plugin` is all that a plugin needs. Duel calls a DSL method on the
103
+ object that files of benchmarks run on, so it can call `duel`:
104
+
105
+ ```ruby
106
+ # lib/duel/my_app.rb
107
+ module Duel
108
+ module MyApp
109
+ Duel.plugin(:my_app, self)
110
+
111
+ def self.configure(config)
112
+ # ...
113
+ end
114
+
115
+ module DSL
116
+ def duel_request(name, path, **options, &prepare)
117
+ duel(name, around_iteration: ::MyApp::ROLLBACK, **options) do
118
+ env = ::MyApp.env_for(path)
119
+ instance_exec(env, &prepare) if prepare # in the benchmark's context
120
+ ::MyApp.call(env)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ # .duel.rb
128
+ Duel.configure { |c| c.use :my_app }
129
+
130
+ # duel/requests_duel.rb
131
+ duel_request "posts: index", "/posts"
132
+ ```
133
+
134
+ Inside the plugin's module, `DSL` is the plugin's own module, so write
135
+ `Duel::DSL` for Duel's.
136
+
137
+ A benchmark that the method defines, with `duel` or `duel_process`,
138
+ belongs to the file of benchmarks that called the method, not to the
139
+ plugin's file. It gets that file's hooks, the same as a benchmark that
140
+ the file defines with `duel`. That holds even if the block that the
141
+ method gives `duel` is the plugin's, as in the previous example, or if
142
+ the method calls another DSL method. A hook that the method adds, such
143
+ as `before_sample { ... }`, is the calling file's too.
144
+
145
+ Pass the benchmark's own hooks on to `duel`, as options, such as
146
+ `setup:`, so that your method takes them the same way that `duel` does.
147
+
148
+ If a method's name is taken, `c.use` raises `Duel::Error` and names
149
+ what took it. A name is taken if the DSL already has a method of that
150
+ name, Duel's own or another plugin's, or if every object has one, such
151
+ as Kernel's `test`, which the DSL method would hide. So name the method
152
+ after the plugin, such as `duel_request` for a plugin for apps. A DSL
153
+ method is also a name that a project's helpers can't have. For more
154
+ information, see
155
+ [Share code between benchmarks](benchmarks.md#share-code-between-benchmarks).
156
+
157
+ Reopening `Duel::DSL` instead isn't supported: a method that you define
158
+ that way can silently replace Duel's or another plugin's.
159
+
160
+ ## Run code around every benchmark
161
+
162
+ A plugin can give hooks for every benchmark that runs in a worker,
163
+ with the same names that `.duel.rb` uses. For what each hook does, and
164
+ the order of hooks at several levels, see
165
+ [Run code around a benchmark](benchmarks.md#run-code-around-a-benchmark).
166
+ A plugin's hooks are the configuration's, so they run before a file's
167
+ and a benchmark's own, in the order that they were given, and they run
168
+ where they're written, with the plugin's module as `self`:
169
+
170
+ - `c.before_sample { |benchmark| ... }` runs before every sample,
171
+ warm-up samples included, and isn't measured. Use it to put back what
172
+ earlier samples changed, such as reloading a database's fixtures, so
173
+ that every sample starts alike. The block receives the benchmark,
174
+ with its `name`, `only`, and `peak_memory`, if it takes it.
175
+ - `c.after_iterations { ... }` runs after each sample's iterations, and
176
+ is the one hook that Duel measures, with them. Use it to do the work
177
+ that the iterations started and left to be done later, such as jobs
178
+ that they enqueued, so that the work counts as the benchmark's. For a
179
+ benchmark with an `around_iteration` hook, Duel runs it after each
180
+ iteration, inside the iteration, before the hook undoes anything.
181
+ - `c.around_iteration { |iteration| ... }` runs around each iteration of
182
+ every benchmark, and calls `iteration.call` once. Only the iteration
183
+ is measured. A benchmark whose block takes the number of iterations
184
+ can't run inside one, so a plugin that gives one for every benchmark
185
+ rules those out. To apply one to some benchmarks, such as rolling back
186
+ each request, make it a lambda that a project can give to a file or
187
+ to one benchmark (`around_iteration: MyApp::ROLLBACK`), or pass it to
188
+ `duel` from the plugin's DSL method.
189
+
190
+ ```ruby
191
+ def self.configure(config)
192
+ config.before_sample { MyJobs.clear }
193
+ config.after_iterations { MyJobs.run_pending }
194
+ end
195
+ ```
196
+
197
+ A process benchmark (`duel_process`) runs no hooks.
198
+
199
+ ## Set up and tear down a metric in each worker
200
+
201
+ A metric often needs something of its own in each worker, such as a
202
+ subscription to notifications. Running totals and a metric class use
203
+ the same names for it:
204
+
205
+ - For running totals, call `open` and `close` with blocks on what
206
+ `c.metric` returns. Duel runs the `open` block when the worker opens
207
+ the metric, before the metric's first reading, and the `close` block
208
+ when the worker ends. `open`, `close`, `details`, and `costly` each
209
+ return the metric, so you can chain them.
210
+ - For a subclass of `Duel::Metric`, define `open` and `close` methods.
211
+ Duel makes and opens each metric once in each worker, after the
212
+ `c.in_worker` blocks, and calls `close` when the worker ends. Its
213
+ per-sample methods are `start_sample`, `stop_sample`,
214
+ `pause_sample`, and `resume_sample`.
215
+
216
+ ```ruby
217
+ def self.configure(config)
218
+ counter = Counter.new
219
+ config.metric(MEASURES) { counter.totals }
220
+ .open { counter.subscribe }
221
+ .close { counter.unsubscribe }
222
+ .details { |stretches, sample| counter.details(stretches, sample.n) }
223
+ counter
224
+ end
225
+ ```
226
+
227
+ Duel closes a worker's metrics last first, and then runs what its
228
+ `c.in_worker` blocks gave `at_stop`. A metric whose measures are all
229
+ left out (`c.measure(name, skip: true)`) isn't made, so it's neither
230
+ opened nor closed. To set up nothing for one measure that's left out,
231
+ `open` can call `c.skip?(name)`.
232
+
233
+ ## Explain a sample
234
+
235
+ A metric can say what it saw in a sample, so that a regression comes
236
+ with its cause, such as the statements that a benchmark sent most. Duel
237
+ shows what the metric said under each regression, for each version.
238
+
239
+ For running totals, call `details` with a block on what `c.metric`
240
+ returns. The block receives the following:
241
+
242
+ - `stretches`: an Array of the sample's measured stretches. Each
243
+ stretch is a pair of readings, `[started, stopped]`, taken when Duel
244
+ started measuring and when it stopped. A sample has one stretch, and
245
+ one more for each `untimed` block in it. A sample of a benchmark with
246
+ an `around_iteration` hook has one stretch for each iteration, and two
247
+ more, before the first iteration and after the last, which measure
248
+ next to nothing.
249
+ For a costly metric (see
250
+ [Account for the cost of reading](configuration.md#account-for-the-cost-of-reading)),
251
+ it has one stretch, and one more for each `untimed` block, as any
252
+ other sample does.
253
+ - `sample`: the sample. `sample.n` is its number of iterations, and
254
+ `sample.benchmark` is the benchmark, with its `name`, `only`, and
255
+ `peak_memory`.
256
+
257
+ A reading has the following:
258
+
259
+ - `totals`: the totals that the metric's block read, by name. A total
260
+ that the block couldn't read is `nil`.
261
+ - `total(name)`: one total. It raises `Duel::Error` for a total that
262
+ couldn't be read, or one that the metric doesn't measure.
263
+ - `context`: what else the block read with the totals, if it returned
264
+ `[totals, context]`, or `nil`.
265
+
266
+ What a sample measured is the sum, over its stretches, of what each
267
+ total grew by, less what reading the totals costs. For a subclass of
268
+ `Duel::Metric`, `details` is a method, which explains the sample that
269
+ the metric last stopped, from what its `start_sample` and `stop_sample`
270
+ kept.
271
+
272
+ The block, or method, returns one of the following:
273
+
274
+ - A String, which explains each of the metric's measures.
275
+ - A Hash of Strings by measure name, which explains only those measures.
276
+ - `nil`, which explains nothing.
277
+
278
+ Duel calls it in the worker, right after each sample stops, outside what
279
+ it measures. That's after every sample, including warm-up samples and
280
+ those that a worker takes to calibrate an `around_iteration` hook, not
281
+ only after a regression. Duel doesn't call it after a stretch that
282
+ `untimed` paused.
283
+ So keep it cheap: return `nil` unless something looks worse, for
284
+ example `return if within?(more, sample.n)`. For a count, Duel shows
285
+ what the metric said of the sample that the count came from. For a
286
+ timing, it shows what the metric said of the latest sample.
287
+
288
+ ## Test a plugin
289
+
290
+ `require "duel/testing"` provides what a plugin's tests need:
291
+
292
+ ```ruby
293
+ config = Duel::Config.new
294
+ config.use :redis
295
+ Duel::Testing.prepare(config) do |env| # prepared as a worker is, env set
296
+ Duel::Testing.measure(config) { Redis.new.set("k", "v") } # => {"redis_commands" => 1, ...}, with #details
297
+ end
298
+
299
+ base = Duel::Testing.project(dir, "lib/my_cache.rb" => "...", "duel/cache_duel.rb" => "...", ".duel.rb" => "...")
300
+ Duel::Testing.compare(base, candidate).passed? # whole comparisons, in workers
301
+ ```
302
+
303
+ `Duel::Testing` has the following methods:
304
+
305
+ - `prepare(config)`: runs the configuration's `c.prepare(:version)`
306
+ blocks, named for the version (`"candidate"`), and then its
307
+ `c.prepare(:worker)` blocks, as Duel prepares a worker. It sets the
308
+ environment that they return while its block runs, and stops what
309
+ they started after. `name:` names the worker (`"candidate-1"` by
310
+ default), and `root:` is the version's tree (the configuration's root
311
+ by default). `cache:` is where `w.cached` builds go. By default,
312
+ that's one directory for the whole test process. To look inside it,
313
+ give your own.
314
+ - `measure(config)`: measures a sample of its block in a worker of its
315
+ own, which runs its `c.in_worker` blocks and makes its metrics the
316
+ way that a worker does, and which ends after the sample. It runs the
317
+ configuration's hooks as a worker runs a benchmark's. Its block
318
+ receives the benchmark's context, with `untimed`, as a benchmark's
319
+ does: `measure(config) { |s| s.untimed { ... } }`. `n:` is the
320
+ sample's number of iterations, and `only:` makes the sample a
321
+ benchmark's that has, for example, `only: :timing`. It also takes the
322
+ benchmark's own hooks, as `duel` does, such as `before_sample:`. It
323
+ returns a Hash of what was measured, by name, whose `details` are
324
+ what the metrics said of the sample, by measure. With an
325
+ `around_iteration` hook, the configuration's or its own, the block is
326
+ one iteration: `measure` runs it `n` times, each inside the hook, the
327
+ way that a worker runs a benchmark with one, including what it learns
328
+ first to subtract the hook's work from a costly metric.
329
+ - `in_worker(config) { ... }`: runs the block as a worker, and ends it
330
+ the way that a worker ends, so what its `c.in_worker` blocks gave
331
+ `at_stop` runs after the block. Every `measure` in the block is that
332
+ worker's, and so is the `value` of what `c.in_worker` returned.
333
+ Inside the block, `Duel::Testing.metrics(config)` returns the
334
+ worker's metric instances. Check what a worker holds, such as a
335
+ subscription or an open connection, inside the block: after a
336
+ `measure` of its own, a worker has ended.
337
+ - `project(dir, files)`: writes a small project of the given files.
338
+ - `compare(base, candidate)`: compares two projects in workers. See the
339
+ following paragraphs.
340
+ - `sample(n: 1)`: a stand-in sample of `n` iterations, which measures
341
+ nothing, for a metric's `start_sample` or a `details` block. `name:`,
342
+ `only:`, and `peak_memory:` describe its benchmark.
343
+ - `reading(totals, context)`: a stand-in reading of running totals, by
344
+ name, with the context that was read with them. A sample's stretches
345
+ are pairs of readings.
346
+ - `details(metric, stretches, sample)`: what the `details` block of
347
+ running totals says of the stretches of a sample. `metric` is what
348
+ `c.metric` returned, and `sample` is `sample` by default.
349
+
350
+ `measure` runs the configuration's `before_sample` hooks before the
351
+ sample, and its `after_iterations` hooks after its block, measured with
352
+ it, the same way that a worker does. With an `around_iteration` hook,
353
+ it runs the `after_iterations` hooks after each iteration. The
354
+ benchmark's own hooks run in its context, the same as a benchmark's
355
+ own hooks do, while the block keeps its own `self`.
356
+
357
+ A plugin whose metrics read a server says that they're costly, so that
358
+ a benchmark with an `around_iteration` hook doesn't make a round trip to
359
+ the server for each iteration. To check that what such a hook sends the
360
+ server isn't counted, measure with one:
361
+
362
+ ```ruby
363
+ rolled_back = lambda do |iteration|
364
+ pg.exec("BEGIN")
365
+ iteration.call
366
+ pg.exec("ROLLBACK")
367
+ end
368
+ Duel::Testing.measure(config, n: 20, around_iteration: rolled_back) { pg.exec("INSERT INTO items VALUES (1)") }
369
+ # => {"postgres_queries" => 20, "postgres_rows" => 20, ...}
370
+ ```
371
+
372
+ To test or benchmark what a metric says of a sample, without a worker,
373
+ use the stand-ins:
374
+
375
+ ```ruby
376
+ metric = config.metrics.first # what c.metric returned
377
+ stretches = [[Duel::Testing.reading({"rows" => 1}, views_before), Duel::Testing.reading({"rows" => 4}, views_after)]]
378
+ Duel::Testing.details(metric, stretches, Duel::Testing.sample(n: 3)) # => {"rows" => "an iteration: 1× ..."}
379
+ ```
380
+
381
+ A plugin's tests find what the plugin added in `config.metrics`, the
382
+ metrics in order. Each metric has its `measures` and their `settings`,
383
+ with every setting filled in. For example,
384
+ `config.metrics.first.settings.fetch("redis_cpu")` is
385
+ `{kind: :ratio, unit: "µs", floor: 300, ...}`.
386
+
387
+ `compare` takes `duel`'s options as keywords: `processes:` (two unless
388
+ given), `timeout:`, `limit:`, `warm:`, and `filter:` (a Regexp). It
389
+ returns the comparison: `passed?`, its `output` (what `duel` would have
390
+ printed), and its `results`, one for each benchmark.
391
+ `comparison["name"]` finds one. A result has the following:
392
+
393
+ - Its `name`, `passed?`, and the measures that it `regressed` on. When
394
+ no one timing was slower in every measurement, but each measurement
395
+ had one that was, those are the timings that were slower in any.
396
+ - Each `timed` measure's `"verdict"`, `"median"` ratio, and
397
+ `"interval"` (over its `"pairs"` of samples), the `"limit"` that it
398
+ was judged by, whether it's judged `"across"` the board, and each
399
+ version's median (`"baseline"` and `"candidate"`), by name.
400
+ - Each `counted` measure's fewest on each version (`"baseline"` and
401
+ `"candidate"`) over a sample's `"n"` iterations, with its
402
+ `"tolerance"` and `"allowance"` (both with their `"unit"` and
403
+ `"plural"`), by name.
404
+ - What metrics said of it (`details`), by measure and by version.
405
+ - Its peak `memory`, by version, when it was asked for.
406
+ - Its iterations a sample (`n`), and its `seconds`.
407
+ - Why it was `skipped` (the baseline couldn't run it), or its `error`.
408
+ - Its `confirmations`: the measurements that confirmed it was worse.
@@ -0,0 +1,105 @@
1
+ # Troubleshooting
2
+
3
+ This page lists what Duel says when it can't compare, or when a
4
+ comparison doesn't go as you expect, and what to do about it.
5
+
6
+ ## Duel says there's nothing to compare
7
+
8
+ If nothing under `lib/` or `ext/` changed since the baseline, there's
9
+ nothing to compare, and Duel passes. To compare anyway, use `--force`.
10
+ With nothing changed, Duel compares your code with itself, which checks
11
+ that your benchmarks run. To change what a change must touch to be
12
+ compared, set `c.watch`. See [Configure Duel](configuration.md).
13
+
14
+ Against `--base last-release`, Duel also reports that there's nothing to
15
+ compare against until you tag your first release. Releases are tagged
16
+ `v*` by default. To match other tags, set `c.release_tags`.
17
+
18
+ ## Duel finds no main branch, or no commits
19
+
20
+ Duel compares with where your branch left the main branch, so it needs
21
+ both a main branch and a commit. In a repository without any commits
22
+ yet, commit your work first, or compare with a copy of your project in
23
+ another directory: `duel --base DIR`. If your main branch isn't named
24
+ `main` or `master`, set `c.main_branch`. See
25
+ [Where the baseline comes from](how-it-works.md#where-the-baseline-comes-from).
26
+
27
+ ## HEAD and the main branch have no commit in common
28
+
29
+ This happens in a shallow clone, which doesn't have the history that the
30
+ baseline comes from. To fix it, fetch the whole history. For example, on
31
+ GitHub Actions, set `fetch-depth: 0` for `actions/checkout`, and on
32
+ GitLab, set `GIT_DEPTH: 0`. See
33
+ [Where the baseline comes from](how-it-works.md#where-the-baseline-comes-from).
34
+
35
+ ## Another run is in progress
36
+
37
+ Duel runs one comparison at a time in a project, because two runs would
38
+ share the project's `tmp/duel` directory and its baseline's checkout. A
39
+ second run fails and names the process that's running the first. A run
40
+ that crashes leaves nothing behind that blocks the next run.
41
+
42
+ ## A benchmark is reported as an error after a minute
43
+
44
+ If a worker takes more than a minute to load or to answer, such as for a
45
+ benchmark that hangs or that waits on a server that died, Duel stops the
46
+ worker and reports the benchmark as an error. To change the limit, use
47
+ `--timeout`. If a benchmark's iterations are slow, split it into
48
+ smaller benchmarks.
49
+
50
+ ## Duel tells you to set c.build
51
+
52
+ Duel checks that each version's workers load only native extensions that
53
+ were built in that version's directory. A version without a build of its
54
+ own would quietly load another build, such as the other version's, or an
55
+ installed gem's, and the comparison wouldn't be of its code. If that
56
+ happens, Duel stops and tells you to set `c.build` to a command that
57
+ builds each version, such as `c.build = "make"`. See
58
+ [Prepare each version](configuration.md#prepare-each-version).
59
+
60
+ ## Duel says that the versions' paths differ in length
61
+
62
+ The baseline's path is as long and as deep as your project's, because
63
+ code that works with its own file paths takes longer on a longer or
64
+ deeper path. If Duel can't write next to your project, or something else
65
+ already has the baseline's name, it uses `tmp/duel/base` instead, and
66
+ says so. Make the directory that contains your project writable, or
67
+ move what has that name. See
68
+ [Where the baseline comes from](how-it-works.md#where-the-baseline-comes-from).
69
+
70
+ ## A version asks for a Ruby that isn't installed
71
+
72
+ With `c.ruby = :version_file`, each version runs on the Ruby that its
73
+ version file names. If that Ruby isn't installed where rbenv, chruby,
74
+ mise, or asdf finds it, the run fails and says which Ruby it needs.
75
+ Install it. On CI, see
76
+ [Run each version on its own Ruby on CI](ci.md#run-each-version-on-its-own-ruby-on-ci).
77
+
78
+ ## Two prepare blocks set the same environment variable
79
+
80
+ If two prepare blocks set the same environment variable to different
81
+ values, the run fails. For example, two plugins for two databases might
82
+ both set `DATABASE_URL`. A worker's prepare block also can't set a
83
+ variable that the version's environment sets to a different value. See [Prepare each worker](configuration.md#prepare-each-worker).
84
+
85
+ ## A worker fails on a helper's name
86
+
87
+ A helper in `duel/duel_helper.rb` can't have the name of a method that
88
+ Duel gives benchmarks, such as `duel`, `untimed`, or a hook. Duel's
89
+ method would hide the helper, so the worker fails, and names the
90
+ helper's file and line. Rename the helper. See
91
+ [Share code between benchmarks](benchmarks.md#share-code-between-benchmarks).
92
+
93
+ ## A timing is too close to the limit to settle
94
+
95
+ Duel reports a timing that's still too close to the limit to call, and
96
+ doesn't fail on it. A benchmark that's reported unsettled run after run
97
+ is noisy. Make every iteration do the same work, or, for work that's
98
+ mostly waiting, judge the benchmark on its counts alone with
99
+ `only: :count`. See
100
+ [Choose what a benchmark is judged on](benchmarks.md#choose-what-a-benchmark-is-judged-on)
101
+ and [How Duel tells a regression from noise](how-it-works.md#how-duel-tells-a-regression-from-noise).
102
+
103
+ On JRuby and TruffleRuby, timings stay noisier than on CRuby, so Duel
104
+ often leaves small timing regressions unsettled. See
105
+ [JRuby and TruffleRuby](how-it-works.md#jruby-and-truffleruby).
data/exe/duel ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "duel/cli"
5
+
6
+ exit Duel::CLI.new(ARGV).run
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stats"
4
+
5
+ module Duel
6
+ # Whether the candidate is slower across the board: a slowdown smaller
7
+ # than any one benchmark can settle (1% everywhere, say) still shows in
8
+ # all of them together. For each ratio metric, every benchmark judged on
9
+ # it is a sample (its median ratio), and the candidate is slower when a
10
+ # distribution-free confidence interval for their median lies entirely
11
+ # above a quarter of the metric's own margin (1.005, for a limit of
12
+ # 1.02). It takes 8 benchmarks at least. One that says across: false
13
+ # is left out.
14
+ # @api private
15
+ class Across
16
+ # The across-the-board limit for a metric whose own limit is +limit+.
17
+ def self.limit(limit) = 1 + ((limit - 1) / 4.0)
18
+
19
+ def initialize(results, confidence:)
20
+ @results = results
21
+ @confidence = confidence
22
+ end
23
+
24
+ # {metric => {"median", "interval", "limit", "benchmarks", "verdict"}}
25
+ # for every ratio metric any benchmark was judged on.
26
+ def verdicts
27
+ medians = Hash.new { |h, k| h[k] = [] }
28
+ # @type var limits: Hash[String, Float]
29
+ limits = {}
30
+ @results.each do |result|
31
+ next if result.error || result.skipped
32
+
33
+ result.timed.each do |metric, timed|
34
+ next unless timed.fetch("across")
35
+
36
+ medians[metric] << timed.fetch("median")
37
+ limits[metric] ||= Across.limit(timed.fetch("limit"))
38
+ end
39
+ end
40
+ medians.to_h do |metric, list|
41
+ limit = limits.fetch(metric)
42
+ interval = Stats.median_interval(list, @confidence)
43
+ [metric, {"median" => Stats.median(list), "interval" => interval, "limit" => limit, "benchmarks" => list.size,
44
+ "verdict" => Stats.decide(interval, limit).to_s}]
45
+ end
46
+ end
47
+ end
48
+ end