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/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# ⚔️ Duel: Performance regression guards for Ruby
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sferik/duel-ruby)
|
|
4
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/test.yml)
|
|
5
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/lint.yml)
|
|
6
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/mutant.yml)
|
|
7
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/typecheck.yml)
|
|
8
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/plugins.yml)
|
|
9
|
+
[](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
|
+
[](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) | [](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) | [](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) | [](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) | [](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) | [](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) | [](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) | [](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) | [](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.
|
data/docs/benchmarks.md
ADDED
|
@@ -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.
|