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/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # ⚔️ Duel: Performance regression guards for Ruby
2
+
3
+ [![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)
4
+ [![Test](https://github.com/sferik/duel-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/test.yml)
5
+ [![Lint](https://github.com/sferik/duel-ruby/actions/workflows/lint.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/lint.yml)
6
+ [![Mutant](https://github.com/sferik/duel-ruby/actions/workflows/mutant.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/mutant.yml)
7
+ [![Typecheck](https://github.com/sferik/duel-ruby/actions/workflows/typecheck.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/typecheck.yml)
8
+ [![Plugins](https://github.com/sferik/duel-ruby/actions/workflows/plugins.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugins.yml)
9
+ [![Gem Version](https://badge.fury.io/rb/duel.svg)](https://rubygems.org/gems/duel)
10
+
11
+ Duel runs your benchmarks on two versions of your code side by side and fails
12
+ if the new code is slower. Add it to your app's CI and it will prevent your
13
+ code from becoming slower.
14
+
15
+ ```sh
16
+ $ duel
17
+ Comparing 12 benchmarks, 2 at a time: baseline /src/.3f9c2a1b against candidate /src/my_parser (slower: a 99.0% interval above 1.02)
18
+ parse: a small document 0.6s n=412 cpu 0.998 [0.991, 1.006] same allocations 20
19
+ ! parse: a large document 1.1s n=9 cpu 1.081 [1.062, 1.097] slower allocations 5790
20
+ ...
21
+ REGRESSION parse: a large document: worse cpu in every measurement
22
+ cpu: 112.4µs → 121.5µs an iteration (1.08x)
23
+ 12 benchmarks in 14s: 1 regressed, 0 errors, 0 with a timing too close to the limit to settle
24
+ ```
25
+
26
+ ## Never slower
27
+
28
+ Duel exists to enforce a policy that the WebKit project sets out on its
29
+ [Performance](https://webkit.org/performance/) page:
30
+
31
+ > The way to make a program faster is to never let it get slower.
32
+ >
33
+ > We have a zero-tolerance policy for performance regressions. If a patch
34
+ > lands that regresses performance according to our benchmarks, then the
35
+ > person responsible must either back the patch out of the tree or drop
36
+ > everything immediately and fix the regression.
37
+ >
38
+ > Common excuses people give when they regress performance are, "But the
39
+ > new way is cleaner!" or "The new way is more correct." We don't care.
40
+ > No performance regressions are allowed, regardless of the reason. There
41
+ > is no justification for regressing performance. None.
42
+
43
+ WebKit backs those words with its benchmarks, and with the discipline to
44
+ treat a slowdown as seriously as a broken build. Duel brings that idea
45
+ to Ruby. If you adopt the policy, Duel tells a pull request that it made
46
+ your code slower before the pull request is merged. All credit for the
47
+ idea belongs to the WebKit project, and any shortcomings in carrying it
48
+ out are Duel's.
49
+
50
+ ## Install Duel
51
+
52
+ 1. Add Duel to the `development` group of your `Gemfile`:
53
+
54
+ ```ruby
55
+ # Gemfile
56
+ group :development do
57
+ gem "duel"
58
+ end
59
+ ```
60
+
61
+ 2. Run `bundle install`.
62
+
63
+ 3. Set Duel up in your project:
64
+
65
+ ```sh
66
+ bundle exec duel init
67
+ ```
68
+
69
+ It adds StackProf to your `Gemfile`, for profiles of regressions, so
70
+ run `bundle install` again.
71
+
72
+ ### Set up a project
73
+
74
+ `duel init` sets Duel up in a gem or an app, and writes the following:
75
+
76
+ | File | What it's for |
77
+ |---|---|
78
+ | `.duel.rb` | Duel's configuration. It builds a project that has an `ext/` directory with `rake compile`, and runs each version on its own Ruby when the project has a `.ruby-version`. Without a gemspec to name the library, it names the only file in `lib/`, or leaves the library for you to set. See [Configure Duel](docs/configuration.md). |
79
+ | `duel/<library>_duel.rb` | An example benchmark to replace with your own, with advice on writing benchmarks that tell a regression from noise. See [Write a benchmark](#write-a-benchmark). |
80
+ | `.github/workflows/perf.yml` | The GitHub Actions workflow in [Run Duel in CI](docs/ci.md#catch-slow-creep). For a project in a subdirectory of its repository, the workflow is at the repository's root, named after the subdirectory, and runs in it. |
81
+ | `Rakefile` | If there is one, `duel init` adds Duel's Rake task to it, as `rake duel`. See [Run Duel in CI](#run-duel-in-ci). |
82
+ | `Gemfile` | Unless it mentions StackProf already, `duel init` adds `gem "stackprof", platforms: :mri` to its `development` group (or, without one, to its end), so Duel can show where a regression spent its extra time. StackProf runs on CRuby only, so the gem isn't installed on JRuby or TruffleRuby. |
83
+
84
+ `duel init` doesn't overwrite a file that exists. To overwrite it, use
85
+ `--force`. To see what `duel init` would do without writing anything,
86
+ use `--dry-run`. When it's done, `duel init` lists the next steps.
87
+
88
+ For a Rails app, use
89
+ [duel-rails](https://github.com/sferik/duel-ruby/tree/main/plugins/rails)
90
+ instead: add it to your `Gemfile`, and run
91
+ `bin/rails generate duel:install`.
92
+
93
+ ### Adopt Duel with an agent
94
+
95
+ [AGENT_PROMPT.md](AGENT_PROMPT.md) is a prompt that tells a coding agent
96
+ how to add Duel to your project. The agent does the following:
97
+
98
+ * Picks the code that's worth guarding.
99
+ * Writes benchmarks that don't vary from run to run.
100
+ * Checks that the benchmarks pass against the unchanged code, and that
101
+ they catch a deliberate slowdown.
102
+ * Adds the CI workflow.
103
+
104
+ To use the prompt, paste it into the agent, or point the agent at the
105
+ file. The prompt also ships with the gem. After you install Duel, it's
106
+ in the directory that `bundle info duel --path` prints.
107
+
108
+ ## Write a benchmark
109
+
110
+ By convention, add benchmarks in files named `*_duel.rb` in the `duel/`
111
+ directory.
112
+
113
+ ```ruby
114
+ # duel/parser_duel.rb
115
+ duel "parse: a small document" do
116
+ MyParser.parse("[1, 2, 3]")
117
+ end
118
+ ```
119
+
120
+ Duel times the block that you pass to `duel`, and runs it as many times
121
+ as a sample needs. To do work that you don't want timed, put it in a
122
+ `once` block. Duel runs it once, the first time that the benchmark runs,
123
+ before it measures anything:
124
+
125
+ ```ruby
126
+ duel "parse: a large document" do
127
+ once { @input = File.read("test/fixtures/large.json") } # not timed
128
+ MyParser.parse(@input) # timed
129
+ end
130
+ ```
131
+
132
+ If your code handles many items at once, give the block a parameter.
133
+ Duel then runs the block once per sample, and passes it the number of
134
+ iterations. To prepare each sample's work without timing it, wrap the
135
+ preparation in `untimed`, which pauses measuring:
136
+
137
+ ```ruby
138
+ duel "queue: drain the queue" do |n|
139
+ queue = untimed { MyQueue.new(Array.new(n) { |i| i }) } # not timed
140
+ queue.drain # timed
141
+ end
142
+ ```
143
+
144
+ Benchmarks can do much more, such as run code around their samples or
145
+ iterations, undo what each iteration changes, and time a whole process,
146
+ such as your app's boot. For more information, see
147
+ [Write benchmarks](docs/benchmarks.md).
148
+
149
+ ## Run Duel
150
+
151
+ Run `duel` from your project's root. By default, it compares your
152
+ working tree with the commit where `HEAD` left the main branch:
153
+
154
+ ```sh
155
+ duel # against where HEAD left the main branch
156
+ duel --base v1.4.0 # against any git ref
157
+ duel --base last-release # against the newest release tag before HEAD
158
+ duel --filter "^parse:" # only benchmarks whose names match (a regular expression)
159
+ duel --base ../old # against a tree on disk, already built
160
+ ```
161
+
162
+ To see every option, such as `--processes`, `--timeout`, and `--limit`,
163
+ run `duel --help`. To write the report in Markdown too, use
164
+ `--markdown`. For more information, see
165
+ [Report on a pull request](#report-on-a-pull-request). To write it as
166
+ JSON, for a program to read, use `--json`. For more information, see
167
+ [Read Duel's report as JSON](docs/json.md).
168
+
169
+ Duel runs one comparison at a time in a project, because two runs would
170
+ share the project's `tmp/duel` directory and its baseline's checkout. A
171
+ second run fails and names the process that's running the first. A run
172
+ that crashes leaves nothing behind that blocks the next run.
173
+
174
+ Where it's hard to pass options, such as behind a Rake task or in a CI
175
+ expression, set these environment variables instead:
176
+
177
+ | Variable | Option |
178
+ |---|---|
179
+ | `DUEL_BASE` | `--base` |
180
+ | `DUEL_FILTER` | `--filter` |
181
+ | `DUEL_FORCE=1` | `--force` |
182
+
183
+ An option on the command line takes precedence over its variable, and
184
+ an empty variable counts as unset.
185
+
186
+ For where the baseline comes from, how Duel checks it out and builds it,
187
+ and how it compares a project in a subdirectory of its repository, see
188
+ [Where the baseline comes from](docs/how-it-works.md#where-the-baseline-comes-from).
189
+
190
+ ### Timeouts and exit status
191
+
192
+ A benchmark that hangs, or that waits on a server that died, doesn't
193
+ hang the run. If a worker takes more than a minute to load or to answer,
194
+ Duel stops the worker and reports the benchmark as an error. To change
195
+ the limit, use `--timeout`.
196
+
197
+ `duel` exits with one of the following statuses:
198
+
199
+ | Status | Meaning |
200
+ |---|---|
201
+ | `0` | Nothing got slower, or there was nothing to compare. |
202
+ | `1` | Something got slower, or a benchmark failed. |
203
+ | `2` | Duel couldn't compare. |
204
+
205
+ ## Run Duel in CI
206
+
207
+ Run `bundle exec duel` as a step in your CI job. Its exit status passes
208
+ or fails the job, and its output shows what got slower, by how much, and
209
+ where the extra time went. For example, on GitLab:
210
+
211
+ ```yaml
212
+ duel:
213
+ script: bundle exec duel
214
+ variables:
215
+ GIT_DEPTH: 0 # the baseline comes from history
216
+ ```
217
+
218
+ To run Duel from Rake, define a task with `Duel::RakeTask`. Any
219
+ arguments after the task's name are passed to `duel`:
220
+
221
+ ```ruby
222
+ require "duel/rake_task"
223
+ Duel::RakeTask.new(:perf)
224
+ Duel::RakeTask.new(:perf_since_release, "--base", "last-release")
225
+ ```
226
+
227
+ `duel init` writes a GitHub Actions workflow,
228
+ `.github/workflows/perf.yml`. It compares each pull request with the
229
+ main branch, each push to `main` with the commit before it, and, on a
230
+ schedule, your last release, to catch slow creep. It also comments on
231
+ each pull request with Duel's report. For the workflow, and for a branch
232
+ that upgrades Ruby, see [Run Duel in CI](docs/ci.md).
233
+
234
+ ### Report on a pull request
235
+
236
+ On GitHub Actions, Duel adds its report to the job's summary, in
237
+ Markdown, whatever the outcome: a comparison, nothing to compare, or an
238
+ error. To write the same Markdown to a file, for example to post it as a
239
+ comment, use `--markdown`:
240
+
241
+ ```sh
242
+ duel --markdown tmp/duel.md
243
+ ```
244
+
245
+ The report starts with the verdict and the two versions that Duel
246
+ compared, with each version's Ruby when they differ. A table lists the
247
+ benchmarks that changed, regressions first, with each timing's ratio and
248
+ its interval, and the counts that changed. Each regression is explained
249
+ as it is in the terminal, with what its metrics said and where the extra
250
+ CPU time went. Then come the benchmarks whose timings were too close to
251
+ call, and the benchmarks that failed. The benchmarks that didn't change
252
+ are collapsed, so the report stays short with hundreds of benchmarks:
253
+
254
+ ````markdown
255
+ ### Duel failed: 1 regression
256
+
257
+ - **Baseline:** `3f9c2a1`, where HEAD left `origin/main`
258
+ - **Candidate:** `8e4d0b7`
259
+
260
+ 12 benchmarks in 14s. A timing is slower when a 99.0% confidence interval for its ratio, the candidate's to the baseline's, is above 1.02.
261
+
262
+ | Benchmark | Verdict | Timings (ratio [interval]) | Counts an iteration |
263
+ |---|---|---|---|
264
+ | `parse: a large document` | **regressed** | instructions 1.071 [1.069, 1.073] slower<br>cpu 1.081 [1.062, 1.097] slower | allocations 5790 → 5791 |
265
+
266
+ #### Regressions
267
+
268
+ **`parse: a large document`**: worse instructions, cpu, allocations in every measurement
269
+
270
+ ```text
271
+ instructions: 1.521e+06 → 1.629e+06 an iteration (1.07x)
272
+ cpu: 112.4µs → 121.5µs an iteration (1.08x)
273
+ allocations: 5790 → 5791 an iteration
274
+ where the extra CPU went (as a share of the baseline's: in each frame itself, and with what it called):
275
+ +6.8% +7.1% MyParser::Lexer#next_token
276
+ +0.4% +0.4% String#byteslice
277
+ ```
278
+
279
+ <details>
280
+ <summary>11 benchmarks unchanged</summary>
281
+
282
+ ...
283
+
284
+ </details>
285
+ ````
286
+
287
+ The workflow that `duel init` writes posts this report as a comment on
288
+ the pull request, and updates the same comment on every push. For more
289
+ information, see
290
+ [Comment on a pull request](docs/ci.md#comment-on-a-pull-request).
291
+
292
+ ### Show that a project is guarded
293
+
294
+ To show that Duel guards your project, add its badge to your README,
295
+ next to your other badges:
296
+
297
+ ```markdown
298
+ [![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)
299
+ ```
300
+
301
+ The badge links to Duel. It doesn't show whether a comparison passed:
302
+ your CI's status does that.
303
+
304
+ ## Plugins
305
+
306
+ A plugin packages what a server or a library needs, such as a server for
307
+ each worker, hooks, and metrics, for any project to use. The following
308
+ plugins are available:
309
+
310
+ | Plugin | Build | What it adds |
311
+ |---|---|---|
312
+ | [Rails](https://github.com/sferik/duel-ruby/tree/main/plugins/rails) | [![duel-rails](https://github.com/sferik/duel-ruby/actions/workflows/plugin-rails.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-rails.yml) | For a Rails app: each version booted like production, with its own bundle and database, and benchmarks of its requests, judged on its queries, views, and cache use too. |
313
+ | [ActiveRecord](https://github.com/sferik/duel-ruby/tree/main/plugins/activerecord) | [![duel-activerecord](https://github.com/sferik/duel-ruby/actions/workflows/plugin-activerecord.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-activerecord.yml) | The queries, transactions, and records that each benchmark took, from Active Record's notifications (for example, an N+1), with any database. |
314
+ | [PostgreSQL](https://github.com/sferik/duel-ruby/tree/main/plugins/postgresql) | [![duel-postgresql](https://github.com/sferik/duel-ruby/actions/workflows/plugin-postgresql.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-postgresql.yml) | A PostgreSQL server for each worker, and the queries, rows, blocks, and time that each benchmark took of it, by statement. |
315
+ | [MySQL](https://github.com/sferik/duel-ruby/tree/main/plugins/mysql) | [![duel-mysql](https://github.com/sferik/duel-ruby/actions/workflows/plugin-mysql.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-mysql.yml) | A MySQL server for each worker, and the queries, rows (returned and examined), and time that each benchmark took of it, by statement. |
316
+ | [Redis](https://github.com/sferik/duel-ruby/tree/main/plugins/redis) | [![duel-redis](https://github.com/sferik/duel-ruby/actions/workflows/plugin-redis.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-redis.yml) | A Redis server for each worker, and the Redis CPU time, commands, and round trips that each benchmark took. |
317
+ | [Memcached](https://github.com/sferik/duel-ruby/tree/main/plugins/memcached) | [![duel-memcached](https://github.com/sferik/duel-ruby/actions/workflows/plugin-memcached.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-memcached.yml) | A memcached server for each worker, and the operations, misses, and bytes that each benchmark took of it. |
318
+ | [GVL](https://github.com/sferik/duel-ruby/tree/main/plugins/gvl) | [![duel-gvl](https://github.com/sferik/duel-ruby/actions/workflows/plugin-gvl.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-gvl.yml) | How long the threads of a benchmark with threads of its own waited for the GVL, on CRuby. |
319
+ | [Retained](https://github.com/sferik/duel-ruby/tree/main/plugins/retained) | [![duel-retained](https://github.com/sferik/duel-ruby/actions/workflows/plugin-retained.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-retained.yml) | The objects that each benchmark left alive (for example, a leak, or a cache that grows with every call), on CRuby. |
320
+
321
+ To use a plugin, add its gem to your `Gemfile`, and call `c.use` in
322
+ `.duel.rb` with the plugin's name and any of its options:
323
+
324
+ ```ruby
325
+ # .duel.rb
326
+ Duel.configure do |c|
327
+ c.use :redis
328
+ c.use :activerecord, query_time: true
329
+ end
330
+ ```
331
+
332
+ For what `c.use` does, and how a plugin's options work, see
333
+ [Use plugins](docs/configuration.md#use-plugins). To write your own
334
+ plugin, see [Write a Duel plugin](docs/plugins.md).
335
+
336
+ ## Learn more
337
+
338
+ The [Duel guide](docs/README.md) covers the rest:
339
+
340
+ - [Write benchmarks](docs/benchmarks.md): hooks, undoing each
341
+ iteration, sharing code, choosing what a benchmark is judged on, and
342
+ benchmarking a whole process.
343
+ - [Configure Duel](docs/configuration.md): `.duel.rb`, preparing each
344
+ worker and each version, each version's own Ruby, your own metrics,
345
+ and plugins.
346
+ - [Run Duel in CI](docs/ci.md): the GitHub Actions workflow, slow creep,
347
+ and comments on pull requests.
348
+ - [How Duel works](docs/how-it-works.md): how it tells a regression from
349
+ noise, what it measures, profiles, where the baseline comes from, and
350
+ JIT compilers.
351
+ - [Troubleshooting](docs/troubleshooting.md): what to do when Duel
352
+ can't compare, or reports what you don't expect.
353
+ - [Write a Duel plugin](docs/plugins.md): plugin conventions, hooks,
354
+ metrics, and tests.
data/docs/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # Duel guide
2
+
3
+ The [README](../README.md) shows how to install Duel, write a first
4
+ benchmark, run it, and run it in CI. This guide covers the rest.
5
+
6
+ | Page | What it covers |
7
+ |---|---|
8
+ | [Write benchmarks](benchmarks.md) | Hooks that run code around a benchmark, starting every sample alike, undoing each iteration, measuring work that a benchmark leaves for later, sharing code between benchmarks, choosing what a benchmark is judged on, and benchmarking a whole process. |
9
+ | [Configure Duel](configuration.md) | `.duel.rb` and its settings, preparing each worker and each version, running code in each worker, running each version on its own Ruby, adding your own metrics, and using plugins. |
10
+ | [Run Duel in CI](ci.md) | The GitHub Actions workflow that `duel init` writes, catching slow creep, commenting on a pull request, and installing the baseline's Ruby on CI. |
11
+ | [How Duel works](how-it-works.md) | How Duel tells a regression from noise, what it measures, where the extra time went, where the baseline comes from, and JIT compilers, including JRuby and TruffleRuby. |
12
+ | [Read Duel's report as JSON](json.md) | Writing the report as JSON with `--json`, its schema and how it's versioned, and an example. |
13
+ | [Troubleshooting](troubleshooting.md) | What Duel says when it can't compare, or reports what you don't expect, and what to do about it. |
14
+ | [Write a Duel plugin](plugins.md) | Plugin conventions, adding to the DSL, hooks and metrics in a plugin, explaining a sample, and testing a plugin. |
15
+
16
+ ## Public API
17
+
18
+ What the README and this guide document is Duel's public API, which
19
+ follows [Semantic Versioning](https://semver.org).
20
+
21
+ > [!IMPORTANT]
22
+ > Anything marked `@api private` in the code or in its signatures
23
+ > (`sig/`) is Duel's own, and can change in any release.
@@ -0,0 +1,289 @@
1
+ # Write benchmarks
2
+
3
+ This page covers what a benchmark can do beyond timing a block: run
4
+ code around its samples and iterations, share code with other
5
+ benchmarks, choose what it's judged on, and benchmark a whole process.
6
+ For a first benchmark, with `once`, `untimed`, and a block that takes
7
+ the number of iterations, see
8
+ [Write a benchmark](../README.md#write-a-benchmark).
9
+
10
+ ## Run code around a benchmark
11
+
12
+ A *hook* runs code around a benchmark's samples or iterations, such as
13
+ setting up a client once, or emptying a queue before every sample. Duel
14
+ has the following hooks:
15
+
16
+ | Hook | When it runs, in each worker | Measured |
17
+ |---|---|---|
18
+ | `setup` | Once, before the benchmark's samples | No |
19
+ | `teardown` | Once, after the benchmark's samples | No |
20
+ | `before_sample` | Before every sample, warm-up samples included | No |
21
+ | `around_iteration` | Around each iteration, which it's given to run | Only the iteration |
22
+ | `after_iterations` | After each sample's iterations, or after each iteration with `around_iteration` | Yes |
23
+
24
+ You can give a hook at three levels, with the same name at each:
25
+
26
+ - In `.duel.rb`, for every benchmark: `c.before_sample { ... }`.
27
+ - At the top of a file of benchmarks, for every benchmark in the file:
28
+ `before_sample { ... }`.
29
+ - As an option of one benchmark, with a lambda:
30
+ `duel "name", before_sample: -> { ... } do ... end`.
31
+
32
+ ```ruby
33
+ # duel/client_duel.rb
34
+ setup { @client = MyClient.new(timeout: 1) }
35
+ teardown { @client.close }
36
+
37
+ duel("client: one request") { @client.get("/") }
38
+ duel("client: ten requests", setup: -> { @client.warm }) { 10.times { @client.get("/") } }
39
+ ```
40
+
41
+ When several levels give the same hook, Duel runs `.duel.rb`'s first,
42
+ then the file's, then the benchmark's own, each in the order that it was
43
+ given. `teardown` runs in the reverse order, so what's set up last is
44
+ torn down first. `around_iteration` hooks nest, outermost first: the
45
+ hook from `.duel.rb` runs around the file's, which runs around the
46
+ benchmark's.
47
+
48
+ A file's hooks and a benchmark's own run in the benchmark's context, so
49
+ they share its instance variables. Hooks in `.duel.rb` run where they're
50
+ written, which suits a plugin's hooks. `setup`, `teardown`, and
51
+ `before_sample` receive the benchmark, with its `name`, `only`, and
52
+ `peak_memory`, if they take a parameter.
53
+
54
+ Inside a benchmark's own block, `once { ... }` runs its block the first
55
+ time that the benchmark runs, and isn't timed, as in
56
+ [Write a benchmark](../README.md#write-a-benchmark). It's for what one
57
+ benchmark needs, in its own block, where the `setup` hook is for what
58
+ it needs before its block runs, or for what every benchmark in a file
59
+ needs.
60
+
61
+ A process benchmark (`duel_process`) doesn't run in a worker, so it
62
+ runs no hooks. See [Benchmark a whole process](#benchmark-a-whole-process).
63
+
64
+ ## Start every sample alike
65
+
66
+ A worker measures each benchmark over many samples. If a benchmark
67
+ changes something that later samples see, such as rows that it inserts
68
+ or items that it pushes onto a queue, later samples measure different
69
+ work. A `before_sample` hook puts back what earlier samples changed, so
70
+ that every sample starts alike. Duel doesn't measure it:
71
+
72
+ ```ruby
73
+ # duel/queue_duel.rb
74
+ setup { @queue = MyQueue.new }
75
+ before_sample { @queue.clear } # not measured
76
+
77
+ duel("queue: push") { @queue.push(1) }
78
+ ```
79
+
80
+ To start every sample of every benchmark alike, for example by emptying
81
+ a database's tables, give the hook in `.duel.rb`:
82
+
83
+ ```ruby
84
+ Duel.configure do |c|
85
+ c.before_sample { |benchmark| MyDatabase.truncate_all } # not measured
86
+ end
87
+ ```
88
+
89
+ ## Undo each iteration
90
+
91
+ Some work changes what the next iteration sees, such as a request that
92
+ creates a record. To undo each iteration without measuring the undo,
93
+ use an `around_iteration` hook. Duel runs the hook around each
94
+ iteration, and the hook calls `iteration.call` exactly once. Duel
95
+ measures only the iteration: nothing else that the hook does is timed
96
+ or counted.
97
+
98
+ ```ruby
99
+ rolled_back = lambda do |iteration|
100
+ Post.transaction do
101
+ iteration.call
102
+ raise ActiveRecord::Rollback
103
+ end
104
+ end
105
+
106
+ duel "posts: create", around_iteration: rolled_back do
107
+ Post.create!(title: "Hello")
108
+ end
109
+ ```
110
+
111
+ `iteration.call` returns what the iteration returns. A benchmark that
112
+ runs inside an `around_iteration` hook, from any level, can't take a
113
+ block parameter, because the hook runs once for each iteration.
114
+
115
+ To leave out what the hook does, Duel pauses every metric after each
116
+ iteration and resumes it before the next one. That reads each metric
117
+ twice per iteration, and part of that reading lands inside the
118
+ iteration. Duel learns how much in each worker, before it measures the
119
+ benchmark, and subtracts it from every iteration. Pausing allocates
120
+ nothing, so counts stay exact.
121
+
122
+ A metric that's costly to read, such as one that queries a database
123
+ server's statistics, would take a round trip to the server twice per
124
+ iteration. So Duel doesn't pause a metric that says it's costly (see
125
+ [Account for the cost of reading](configuration.md#account-for-the-cost-of-reading)).
126
+ It reads that metric only when a sample starts and stops, and subtracts
127
+ what the hook adds to it instead. Duel learns that in each worker,
128
+ before it measures the benchmark, from a few samples of their own, in
129
+ which it pauses the metric around each iteration. The hook's own work,
130
+ such as a transaction's `BEGIN` and `ROLLBACK`, isn't counted, and
131
+ neither is work of the hook's that depends on the iteration, such as
132
+ deleting what the iteration wrote. The plugins for servers, such as
133
+ [PostgreSQL](https://github.com/sferik/duel-ruby/tree/main/plugins/postgresql),
134
+ say that their metrics are costly.
135
+
136
+ The pausing still adds a little to each iteration, the same on both
137
+ versions, and it takes time that Duel doesn't measure. On an Apple M1
138
+ Max, with macOS and Ruby 3.4, an empty iteration with an
139
+ `around_iteration` hook measures less than 100 ns of CPU time and a few
140
+ hundred instructions more than an empty iteration without one, and no
141
+ more allocations. It also takes from 2 µs to 50 µs longer to run,
142
+ depending on how busy the machine is, mostly to read the CPU's
143
+ instruction counter, which takes a system call on macOS. Use
144
+ `around_iteration` for iterations that take tens of microseconds or
145
+ more, such as a database write. For cheaper work, give the block a
146
+ parameter, and prepare each sample's work in `untimed`.
147
+
148
+ ## Measure the work that a benchmark leaves for later
149
+
150
+ Some of the work that a benchmark starts runs later, such as jobs that
151
+ it enqueues, or messages that it broadcasts. To measure that work as
152
+ part of the benchmark, give an `after_iterations` hook that does it
153
+ now:
154
+
155
+ ```ruby
156
+ Duel.configure do |c|
157
+ c.after_iterations { MyJobs.run_pending } # measured: the jobs are the benchmark's work
158
+ end
159
+ ```
160
+
161
+ `after_iterations` is the one hook that Duel measures. It runs inside
162
+ the timed part of each sample, after the sample's iterations, and Duel
163
+ measures it with them. For a benchmark with an `around_iteration` hook,
164
+ it runs inside each iteration, after the iteration's own work, so that
165
+ what the hook undoes after the iteration is still there for the pending
166
+ work. Its work counts as the benchmark's, in every metric and in a
167
+ regression's profile.
168
+
169
+ ## Share code between benchmarks
170
+
171
+ Put code that several files share in `duel/duel_helper.rb`, and require
172
+ it from each file, the same way that tests require `test_helper.rb`.
173
+ Every benchmark can call the methods that the helper defines at its top
174
+ level. Instance variables that those methods set belong to the
175
+ benchmark that calls them:
176
+
177
+ ```ruby
178
+ # duel/duel_helper.rb
179
+ def document(size) = "[" + (["1"] * size).join(",") + "]"
180
+
181
+ def build_parser = @parser = MyParser.new(strict: true)
182
+
183
+ # duel/parser_duel.rb
184
+ require_relative "duel_helper"
185
+
186
+ duel("parse: a thousand numbers") { MyParser.parse(document(1000)) }
187
+
188
+ duel "parse: strictly" do
189
+ once { build_parser }
190
+ @parser.parse("[1]")
191
+ end
192
+ ```
193
+
194
+ A helper can have any name except those of the methods that Duel gives
195
+ benchmarks: `duel`, `duel_process`, `once`, `untimed`, the hooks
196
+ (`setup`, `teardown`, `before_sample`, `around_iteration`, and
197
+ `after_iterations`), and any that a plugin used in `.duel.rb` adds.
198
+ Duel's method would hide a helper of the same name, so the worker
199
+ fails, and names the helper's file and line.
200
+
201
+ ## Choose what a benchmark is judged on
202
+
203
+ By default, Duel judges each benchmark on its timings and its counts:
204
+
205
+ - Timings: CPU time, instructions, and the timings of any metric of your
206
+ own.
207
+ - Counts: allocations, and any other exact counts.
208
+
209
+ To change that, pass these options to `duel`:
210
+
211
+ - `only: :count`: for work that's mostly waiting, too slow and light to
212
+ time. Duel judges the benchmark on its counts alone, over a few
213
+ iterations.
214
+ - `only: :timing`: for work done by threads that the benchmark starts,
215
+ which make its counts vary from run to run. Duel judges the benchmark
216
+ on its timings alone, and takes the process's CPU time instead of the
217
+ benchmark thread's.
218
+ - `peak_memory: true`: also judges the benchmark's peak memory, which is
219
+ the most resident memory that its measured work reached, above where
220
+ it started. Duel takes each version's lowest peak from three fresh
221
+ workers. Like every other measure, peak memory leaves out what
222
+ `once` and `untimed` blocks leave behind.
223
+
224
+ ```ruby
225
+ duel "server: 10 workers", only: :timing, peak_memory: true do |n|
226
+ # ...
227
+ end
228
+ ```
229
+
230
+ ## Benchmark a whole process
231
+
232
+ To guard how long your app takes to boot, or your library to load, use
233
+ `duel_process`. Each sample starts fresh processes for each version,
234
+ in that version's directory and with its environment, and the two
235
+ versions take turns, as they do for every other benchmark. Give
236
+ `duel_process` a command, or Ruby code to run with `ruby:`:
237
+
238
+ ```ruby
239
+ # duel/boot_duel.rb
240
+ duel_process "boot: the app", "bin/rails", "runner", "1"
241
+ duel_process "boot: require the library", ruby: %(require "my_gem")
242
+ ```
243
+
244
+ Duel runs a command the way that `Process.spawn` does, so a single
245
+ string with spaces runs in a shell. It runs Ruby code on the version's
246
+ Ruby, with the version's `lib/` first on the load path.
247
+
248
+ Duel judges a process benchmark on the following:
249
+
250
+ - `cpu`: the CPU time that the process took, including the CPU time of
251
+ the processes that it started and waited for.
252
+ - `peak_memory`: the most resident memory that the process reached,
253
+ when Duel's runner is CRuby. Duel takes each version's lowest peak.
254
+
255
+ Each sample is the fastest of a few processes for each version, with
256
+ the two versions' processes taking turns: the lowest CPU time, and the
257
+ lowest peak memory. Interference from the rest of the machine only ever
258
+ adds time, so the fastest of a few is steadier than any one, and fewer
259
+ pairs settle the verdict. A sample takes as many processes as take
260
+ about a second of CPU time, up to 3, judging by a first pair of
261
+ processes that Duel doesn't measure. A short process, whose time varies
262
+ the most, gets 3, and an app that takes a second or more to boot gets
263
+ 1, since its pairs take long enough as they are. To set the number
264
+ yourself, pass `runs:`:
265
+
266
+ ```ruby
267
+ duel_process "boot: require the library", ruby: %(require "my_gem"), runs: 5
268
+ ```
269
+
270
+ Duel starts and measures the processes from a small Ruby process of its
271
+ own, one for each worker, that lasts as long as the worker. It does
272
+ nothing else while a process runs, so the CPU time that it reads is the
273
+ process's alone, and its own start-up is paid once, not for every
274
+ process.
275
+
276
+ A process that exits with a status other than 0 is an error, and Duel
277
+ shows the end of its output. A process benchmark has no profile.
278
+
279
+ Each process takes a while, so Duel judges a process benchmark after
280
+ every pair of samples, and stops as soon as it finds that the
281
+ candidate isn't slower. It finds a process benchmark slower only after
282
+ a round of 10 pairs, as it does any benchmark, so stopping early never
283
+ makes the same code look slower.
284
+
285
+ A process benchmark doesn't run in a worker, so nothing that runs there
286
+ applies to it: hooks, from `.duel.rb` or its file, and the metrics that
287
+ plugins add. `duel_process` doesn't take the options of `duel`
288
+ (`only:`, `peak_memory:`, and the hooks, such as `around_iteration:`),
289
+ and raises an `ArgumentError` if it's given one.