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/ci.md ADDED
@@ -0,0 +1,141 @@
1
+ # Run Duel in CI
2
+
3
+ This page describes the GitHub Actions workflow that `duel init` writes,
4
+ and how to compare a branch that upgrades Ruby on CI. For how to run
5
+ Duel as a CI step on any CI service, and for the report that it adds to
6
+ a pull request, see [Run Duel in CI](../README.md#run-duel-in-ci).
7
+
8
+ ## Catch slow creep
9
+
10
+ A limit lets through any slowdown that's smaller than the limit. For
11
+ example, a series of changes that are each 1.5% slower would each pass.
12
+ To catch that kind of slow creep, also compare against your last release
13
+ on a schedule. The slowdowns add up there until they're a regression.
14
+
15
+ The following GitHub Actions workflow does both, and reports on each
16
+ pull request. `duel init` writes it for you, as
17
+ `.github/workflows/perf.yml`:
18
+
19
+ ```yaml
20
+ # .github/workflows/perf.yml
21
+ name: Perf
22
+
23
+ on:
24
+ pull_request:
25
+ push:
26
+ branches: [main]
27
+ schedule:
28
+ - cron: "0 4 * * *"
29
+ workflow_dispatch:
30
+
31
+ permissions:
32
+ contents: read
33
+ pull-requests: write # to comment on a pull request with the report
34
+
35
+ jobs:
36
+ duel:
37
+ name: Duel
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v7
41
+ with:
42
+ fetch-depth: 0 # the baseline comes from history, tags included
43
+ # (And whatever else your tests need set up: packages, services.)
44
+ - uses: ruby/setup-ruby@v1
45
+ with:
46
+ ruby-version: "3.4" # (or none, with a .ruby-version)
47
+ bundler-cache: true
48
+ # Against the main branch for a pull request, what was there before
49
+ # for a push, and the last release on a schedule (slow creep). Duel
50
+ # adds its report to the job's summary, and writes it to duel.md for
51
+ # the comment.
52
+ - name: Compare
53
+ env:
54
+ DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }}
55
+ # (Run by hand, it compares anyway: with nothing changed, the code
56
+ # with itself, which checks the setup.)
57
+ DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
58
+ run: |
59
+ # (A branch's first push, or one that rewrote history, has no
60
+ # commit before it here to compare against.)
61
+ if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then
62
+ echo "::notice::No commit before this push to compare against"
63
+ exit 0
64
+ fi
65
+ bundle exec duel --markdown "$RUNNER_TEMP/duel.md"
66
+ # The report, as a comment on the pull request: one comment, updated
67
+ # on every push. (A pull request from a fork gets no comment, as its
68
+ # token can't write one: the job's summary has the report.)
69
+ - name: Comment on the pull request
70
+ if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
71
+ env:
72
+ GH_TOKEN: ${{ github.token }}
73
+ PR: ${{ github.event.pull_request.number }}
74
+ run: |
75
+ [ -f "$RUNNER_TEMP/duel.md" ] || exit 0
76
+ marker="<!-- duel -->"
77
+ { echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md"
78
+ ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
79
+ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id")
80
+ if [ -n "$ids" ]; then
81
+ gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
82
+ else
83
+ gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
84
+ fi
85
+ ```
86
+
87
+ The workflow compares the following:
88
+
89
+ - A pull request, against the main branch.
90
+ - A push to `main`, against the commit before the push. If that commit
91
+ isn't in the history, such as after a force push or for a branch's
92
+ first push, the workflow skips the comparison with a notice.
93
+ - On a schedule, the last release. Until you tag your first release,
94
+ Duel reports that there's nothing to compare against, and passes.
95
+ - When you run the workflow manually, your code against itself. This
96
+ checks that the benchmarks run on the CI machines.
97
+
98
+ ## Comment on a pull request
99
+
100
+ The workflow writes Duel's report
101
+ ([Report on a pull request](../README.md#report-on-a-pull-request))
102
+ to a file, and then comments on the pull request with it. It updates
103
+ the same comment on every push, rather than adding one each time. It
104
+ finds its comment by the hidden marker on the comment's first line, so
105
+ give each workflow that comments a marker of its own. Commenting takes
106
+ the `pull-requests: write` permission. A pull request from a fork gets
107
+ no comment, because its token can't write one, but the job's summary
108
+ still has the report.
109
+
110
+ ## Run each version on its own Ruby on CI
111
+
112
+ With [`c.ruby = :version_file`](configuration.md#run-each-version-on-its-own-ruby),
113
+ each version runs on the Ruby that its own version file names. On CI, a
114
+ setup step such as `ruby/setup-ruby` installs the Ruby that
115
+ runs `duel` where no version manager looks. That's enough while both
116
+ versions ask for that Ruby. A pull request that upgrades Ruby needs the
117
+ baseline's Ruby installed too, where Duel finds it. For example, for a
118
+ project with a `.ruby-version`, in the
119
+ [GitHub Actions workflow](#catch-slow-creep), install the main branch's
120
+ Ruby with a second `ruby/setup-ruby` step, and link it into
121
+ `~/.rubies`, where Duel looks for Rubies the way that chruby does.
122
+ `duel init` adds these steps for a project that has a `.ruby-version`:
123
+
124
+ ```yaml
125
+ # (Before the step that sets up the candidate's Ruby.)
126
+ - name: Find the baseline's Ruby
127
+ id: baseline
128
+ if: github.event_name == 'pull_request'
129
+ run: echo "ruby=$(git show "origin/$GITHUB_BASE_REF:.ruby-version")" >> "$GITHUB_OUTPUT"
130
+ - uses: ruby/setup-ruby@v1
131
+ id: baseline-ruby
132
+ if: github.event_name == 'pull_request'
133
+ with:
134
+ ruby-version: ${{ steps.baseline.outputs.ruby }}
135
+ - name: Link the baseline's Ruby where Duel finds it
136
+ if: github.event_name == 'pull_request'
137
+ run: |
138
+ prefix="${{ steps.baseline-ruby.outputs.ruby-prefix }}"
139
+ mkdir -p ~/.rubies
140
+ ln -sfn "$prefix" ~/.rubies/ruby-"$("$prefix/bin/ruby" -e 'print RUBY_VERSION')"
141
+ ```
@@ -0,0 +1,429 @@
1
+ # Configure Duel
2
+
3
+ Most gems don't need any configuration: Duel's conventions cover them.
4
+ Benchmarks are in `duel/**/*_duel.rb`, each version's `lib/` is first on
5
+ the load path, a change is compared when it touches `lib/` or `ext/`,
6
+ and releases are tagged `v*`. If your project differs, create a `.duel.rb` file
7
+ at its root. Both the runner and every worker load it:
8
+
9
+ ```ruby
10
+ Duel.configure do |c|
11
+ c.benchmarks = "perf/**/*_bench.rb" # default: duel/**/*_duel.rb
12
+ c.watch = %w[lib ext] # what a change must touch to be compared
13
+ c.library = "my_gem" # required from each version (default: the gemspec's)
14
+ c.build = "make" # for both versions (default: rake compile, with ext/)
15
+ c.main_branch = "origin/trunk"
16
+ c.release_tags = "release-*" # default: v*
17
+ c.ruby = :version_file # each version's own Ruby (default: the runner's)
18
+ c.use :redis # a plugin (see Use plugins)
19
+
20
+ # How a worker loads one version of your code, instead of the library.
21
+ c.load { |root| require File.join(root, "config", "boot") }
22
+ end
23
+ ```
24
+
25
+ `Duel.configure` yields the configuration, a `Duel::Config` object,
26
+ which `Duel.config` also returns. `c.root` is your project's root
27
+ directory. The configuration also takes hooks, such as
28
+ `c.before_sample`. See
29
+ [Run code around a benchmark](benchmarks.md#run-code-around-a-benchmark).
30
+
31
+ ## Prepare each worker
32
+
33
+ Each worker can get things of its own, such as a server, so that the
34
+ baseline and the candidate never share one. To prepare each worker,
35
+ call `c.prepare(:worker)` with a block. Duel runs the block in the
36
+ runner, as each worker starts. The block receives a `Duel::Resource`,
37
+ which gives the worker a directory of its own, and helps start
38
+ processes there. The block returns the environment variables that the
39
+ worker gets:
40
+
41
+ ```ruby
42
+ Duel.configure do |c|
43
+ c.prepare(:worker) do |w|
44
+ socket = w.path("memcached.sock")
45
+ w.spawn("memcached", "-s", socket) # its output goes to memcached.log, there
46
+ w.wait_until { answers?(socket) } # your check (it fails if the program exits, or takes over --timeout)
47
+ {"MEMCACHE_SERVERS" => socket}
48
+ end
49
+ end
50
+ ```
51
+
52
+ A prepare block can use the following methods:
53
+
54
+ | Method | Description |
55
+ |---|---|
56
+ | `w.spawn(*command)` | Starts a process. Its output goes to a log in the worker's directory, named after the program. For a program like `ruby` that says little about itself, give the log a name with `log:`. To stop it with a signal other than `TERM`, pass `stop_signal:`. |
57
+ | `w.wait_until { ... }` | Waits until the block returns true. Fails if a spawned process exits first, or if the wait takes longer than `--timeout`. |
58
+ | `w.run(*command)` | Runs a command to completion, for example to set something up. Fails, and shows the command's log, if the command fails. |
59
+ | `w.failed("message")` | Fails the same way, for any other reason. |
60
+ | `w.at_stop { ... }` | Runs the block when the worker stops. |
61
+ | `w.cached(key) { \|dir\| ... }` | Makes something once, for every worker. See the following example. |
62
+ | `w.name` | The worker's name, for example `"baseline-1"`. |
63
+ | `w.dir` | The worker's directory. `w.path("name")` returns the path of a file in it. |
64
+ | `w.root` | The tree of the version that the worker runs. |
65
+ | `w.ruby` | The path of the Ruby that the version runs on. See [Run each version on its own Ruby](#run-each-version-on-its-own-ruby). |
66
+ | `w.env` | The version's environment variables. See the following paragraph. |
67
+
68
+ The commands that `w.spawn` and `w.run` start get the version's
69
+ environment variables, `w.env`: what the version's own prepare blocks
70
+ return (see [Prepare each version](#prepare-each-version)), and, for a
71
+ version on another Ruby than the runner's, that Ruby's directory first
72
+ on `PATH`. A command's own environment variables take precedence, so a
73
+ command that sets its whole environment should include `w.env`.
74
+
75
+ When a worker is done, Duel runs the blocks that its prepare blocks
76
+ gave `w.at_stop`, last first. Then Duel stops what they spawned, first
77
+ with `TERM` (or the `stop_signal:` that the process was spawned with),
78
+ and then with `KILL`.
79
+
80
+ Some things take long to make, and are the same for every worker, such
81
+ as a database's initial files. `w.cached` makes them once. The first
82
+ worker that asks builds the directory, and the other workers, in this
83
+ run and in later ones, get its path, to copy or to start from. The key
84
+ names what's cached, so put a version in it. `duel --clean` removes
85
+ every cached directory.
86
+
87
+ ```ruby
88
+ template = w.cached("postgres-#{version}") { |dir| w.run("initdb", "-D", dir) }
89
+ FileUtils.cp_r(template, w.path("data"))
90
+ ```
91
+
92
+ A run's workers start at the same time, each prepared, and stop at the
93
+ same time.
94
+
95
+ > [!WARNING]
96
+ > If two prepare blocks set the same environment variable to different
97
+ > values, the run fails. For example, two plugins for two databases
98
+ > might both set `DATABASE_URL`.
99
+
100
+ ## Prepare each version
101
+
102
+ Some setup is for a version as a whole, not for each worker, such as
103
+ installing the version's bundle. To prepare each version once, call
104
+ `c.prepare(:version)` with a block. Duel runs the block in the runner,
105
+ for the baseline and then the candidate, before any of that version's
106
+ workers start. The block receives a `Duel::Resource` for the version,
107
+ with the same methods as a worker's, and returns the environment
108
+ variables that the version's workers and process benchmarks get:
109
+
110
+ ```ruby
111
+ require "bundler"
112
+ require "digest"
113
+
114
+ Duel.configure do |c|
115
+ c.prepare(:version) do |v|
116
+ gemfile = File.join(v.root, "Gemfile")
117
+ lock = Digest::SHA256.file("#{gemfile}.lock").hexdigest[0, 16]
118
+ v.cached("bundle-#{lock}") do |_|
119
+ # (Outside the runner's own bundle.)
120
+ env = Bundler.unbundled_env.merge(v.env, "BUNDLE_GEMFILE" => gemfile)
121
+ v.run(env, "bundle", "install", log: "bundle", unsetenv_others: true)
122
+ end
123
+ {"BUNDLE_GEMFILE" => gemfile}
124
+ end
125
+ end
126
+ ```
127
+
128
+ `v.name` is `"baseline"` or `"candidate"`, and `v.root` is the version's
129
+ directory. What the block spawns stops when the run ends. A worker's
130
+ prepare blocks get the version's environment too, as `w.env`. If a
131
+ worker's prepare block sets a variable that the version's environment
132
+ sets to a different value, the run fails.
133
+
134
+ To build each version, such as its C extension, set `c.build` to a
135
+ shell command instead, such as `c.build = "make"`. Duel runs it in the
136
+ baseline's tree when it checks the baseline out, and in the candidate's
137
+ before every run.
138
+
139
+ ## Run code in each worker
140
+
141
+ The runner loads `.duel.rb` too, so code at its top level runs in the
142
+ runner as well as in every worker. For what only a worker does, such as
143
+ loading data that its benchmarks share, give `c.in_worker` a block.
144
+ Duel runs it in each worker, once the version and the benchmarks have
145
+ loaded, and before the worker makes its metrics. The block receives the
146
+ worker (`|w|`), and what you pass to `w.at_stop` runs when the worker
147
+ ends.
148
+
149
+ `c.in_worker` returns what its block returns, in each worker, as
150
+ `value`, such as a connection of the worker's own. Outside a worker,
151
+ `value` fails:
152
+
153
+ ```ruby
154
+ Duel.configure do |c|
155
+ redis = c.in_worker do |w|
156
+ Redis.new(url: ENV.fetch("REDIS_URL")).tap { |r| w.at_stop { r.close } } # when the worker ends
157
+ end
158
+ c.metric("redis_commands") { redis.value.info("stats").fetch("total_commands_processed").to_i }.costly
159
+ end
160
+ ```
161
+
162
+ ## Run each version on its own Ruby
163
+
164
+ By default, both versions run on the Ruby that runs `duel`. To compare a
165
+ branch that upgrades Ruby, set `c.ruby = :version_file`, or run
166
+ `duel --ruby version-file`. Duel then runs each version on the Ruby that
167
+ its own version file names: `.ruby-version`, `.tool-versions`, or
168
+ `mise.toml`, in the version's directory or the nearest directory above
169
+ it. If that's the Ruby that runs `duel`, Duel uses it. As the version
170
+ managers do, Duel takes a version with or without a `ruby-` prefix,
171
+ another Ruby by its name (`jruby-10.1.2.0`), and a partial version, such
172
+ as `3.4`, as any version that starts with it. Otherwise, Duel finds that
173
+ Ruby with whichever of rbenv, chruby, mise, and asdf has it installed. A
174
+ version without a version file runs on the runner's Ruby. If a version
175
+ asks for a Ruby that isn't installed, the run fails and says which Ruby
176
+ it needs.
177
+
178
+ `c.ruby` also takes the path of a Ruby for both versions, or a block
179
+ that receives the version's directory and returns the path of its Ruby:
180
+
181
+ ```ruby
182
+ c.ruby = ->(root) { File.join(Dir.home, ".rubies", File.read(File.join(root, ".ruby-version")).strip, "bin", "ruby") }
183
+ ```
184
+
185
+ When the two versions run on different Rubies, the report shows each
186
+ version's `RUBY_DESCRIPTION`. A version on another Ruby needs its bundle
187
+ installed for that Ruby. For example, install it with
188
+ [`c.prepare(:version)`](#prepare-each-version), whose `bundle install`
189
+ runs on the version's Ruby. To install the baseline's Ruby on CI, see
190
+ [Run each version on its own Ruby on CI](ci.md#run-each-version-on-its-own-ruby-on-ci).
191
+
192
+ ## Add your own metrics
193
+
194
+ Anything that you can measure around a sample can be a metric. A metric
195
+ is one of the following kinds:
196
+
197
+ - A *counted* metric fails on any increase.
198
+ - A *ratio* metric is judged the same way as CPU time.
199
+
200
+ ### Measure running totals
201
+
202
+ Most metrics are running totals. Give `c.metric` a name and a block that
203
+ reads the total, and Duel measures how much the total grows during each
204
+ sample. For example, the following counts the Active Record queries of a
205
+ gem that adds to models:
206
+
207
+ ```ruby
208
+ # .duel.rb
209
+ Duel.configure do |c|
210
+ queries = 0
211
+ subscriber = nil
212
+ c.metric("sql_queries") { queries }
213
+ .open do # (in workers only: .duel.rb is loaded by the runner too)
214
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
215
+ queries += 1 unless payload[:name] == "SCHEMA" || payload[:cached]
216
+ end
217
+ end
218
+ .close { ActiveSupport::Notifications.unsubscribe(subscriber) }
219
+ end
220
+ ```
221
+
222
+ The runner loads `.duel.rb` too, so set up what the metric reads in each
223
+ worker with `open`, and let go of it with `close`. In each worker, Duel
224
+ runs the `open` block when it opens the metric, before the metric reads
225
+ anything, and the `close` block when the worker ends. A metric whose
226
+ measures are all left out (see [Metric settings](#metric-settings))
227
+ isn't made, so its blocks don't run. `open`, `close`, `details`, and
228
+ `costly` each return the metric, so you can chain them. A
229
+ [metric class](#write-a-metric-class) has the same four, as methods.
230
+
231
+ For other setup that only a worker needs, see
232
+ [Run code in each worker](#run-code-in-each-worker).
233
+
234
+ To read several totals at once, pass `c.metric` a Hash of names and
235
+ their settings. The block returns the totals by name, or `nil` for a
236
+ total that it can't read this time.
237
+
238
+ The following example measures what Redis did for each benchmark, from
239
+ its command statistics. It reads them over a connection of the metric's
240
+ own, not the one that's being measured. `c.in_worker` makes that
241
+ connection in each worker, and its `value` fails anywhere else:
242
+
243
+ ```ruby
244
+ Duel.configure do |c|
245
+ redis = c.in_worker do |w|
246
+ Redis.new(url: ENV.fetch("REDIS_URL")).tap { |r| w.at_stop { r.close } } # when the worker ends
247
+ end
248
+ c.metric(
249
+ "redis_cpu" => {
250
+ kind: :ratio,
251
+ unit: "µs", # shown as time (and sizing samples as CPU time does)
252
+ floor: 300, # µs: below this on both versions, a sample isn't judged on it
253
+ limit: 1.05 # its own limit (default: the run's, 1.02)
254
+ },
255
+ "redis_commands" => {kind: :counted}
256
+ ) do
257
+ stats = redis.value.info("commandstats").values
258
+ {"redis_cpu" => stats.sum { |s| Integer(s["usec"]) }, "redis_commands" => stats.sum { |s| Integer(s["calls"]) }}
259
+ end
260
+ end
261
+ ```
262
+
263
+ ### Explain a regression
264
+
265
+ To explain a regression, call `details` with a block on what `c.metric`
266
+ returns. Duel passes the block the sample's *stretches* and the sample
267
+ itself. `sample.n` is the sample's number of iterations.
268
+
269
+ A stretch is the pair of readings at its start and its end. Each reading
270
+ has its `totals`, and `total("name")` returns one of them, or fails if
271
+ it couldn't be read. A sample is one stretch, or several if it has
272
+ `untimed` blocks, or if its benchmark runs inside an `around_iteration`
273
+ hook and the metric isn't costly.
274
+
275
+ A reading can also carry what its totals came from. If the metric's
276
+ block returns `[totals, context]`, each reading has that context as its
277
+ `context`. [Steep](https://github.com/soutaro/steep) infers both types
278
+ from the block. For example, a block that reads Integers has Integer
279
+ totals.
280
+
281
+ ```ruby
282
+ c.metric("sql_queries") { [queries, Statements.counts] }.details do |stretches, sample|
283
+ Statements.top(3, stretches, per: sample.n) # each stretch's Statements.counts, at its start and its end
284
+ end
285
+ ```
286
+
287
+ Duel calls the `details` block after every sample, not only after a
288
+ regression, so keep it cheap. Return nothing, or something cheap, unless
289
+ something looks worse. For everything that the block receives, and when
290
+ Duel calls it, see [Explain a sample](plugins.md#explain-a-sample).
291
+
292
+ ### Account for the cost of reading
293
+
294
+ Reading a total can add to it. In the Redis example, each `INFO` is one
295
+ more command. Duel corrects for this in counted totals: each worker
296
+ first reads them twice in a row a few times, and then subtracts the
297
+ smallest growth from every sample.
298
+
299
+ If reading a total costs nothing, such as a count that your own code
300
+ keeps, Duel finds that it costs nothing, and subtracts nothing, so you
301
+ don't need to say so. If your block subtracts a varying cost of its own
302
+ reading, set `calibrate: false`. Duel doesn't correct ratio totals,
303
+ because a reading's cost isn't steady enough to subtract.
304
+
305
+ If reading the totals takes time, such as a round trip to a server,
306
+ call `costly` on what `c.metric` returns:
307
+
308
+ ```ruby
309
+ c.metric("redis_commands") { redis.value.info["total_commands_processed"] }.costly
310
+ ```
311
+
312
+ Duel then reads the totals only when a sample starts and stops, even
313
+ for a benchmark with an `around_iteration` hook, and subtracts what the
314
+ hook adds to them. For more information, see
315
+ [Undo each iteration](benchmarks.md#undo-each-iteration). Don't call `costly` on a
316
+ metric that's cheap to read, such as a count that your own code keeps,
317
+ because learning what the hook adds takes a few samples of each
318
+ benchmark with one.
319
+
320
+ ### Metric settings
321
+
322
+ Every setting is optional:
323
+
324
+ | Setting | Applies to | Description |
325
+ |---|---|---|
326
+ | `kind:` | All | `:counted` (the default), which is exact and fails on any increase, or `:ratio`, which is judged by paired ratios with a confidence interval, the same way as CPU time. |
327
+ | `unit:` | All | What the metric is in, as reports show it. `"ns"`, `"µs"`, `"ms"`, and `"s"` show it as time, and set `unit_ns`. Any other unit is either a word with its plural, such as `%w[row rows]` (1 row, 2 rows), or a symbol that's the same for any number, such as `"B"` or `"KB"`. A word without its plural fails. |
328
+ | `floor:` | Ratio | The least that a sample must measure, on either version, to be judged. |
329
+ | `cpu_floor:` | Ratio | The least share of the sample's CPU time that the metric must be (in time, by `unit_ns`), on either version, to be judged. For example, `0.05` for 5%. |
330
+ | `limit:` | Ratio | The ratio above which the metric is slower. The default is the run's limit. |
331
+ | `unit_ns:` | Ratio | Nanoseconds per unit, to size samples by the metric. A time `unit:` sets it. |
332
+ | `sizes_samples:` | Ratio | `false` for a metric that's too noisy to size samples by. |
333
+ | `across:` | Ratio | `false` for a metric that's too noisy to judge across the board, where the margin is a quarter of its limit. |
334
+ | `tolerance:` | Counted | How much more per iteration still doesn't count as more. |
335
+ | `allowance:` | Counted | How much more per sample still doesn't count as more, for noise that doesn't grow with the iterations. |
336
+ | `calibrate:` | Counted | `false` to keep what a reading costs in the total, for a block that subtracts it itself. A reading that costs nothing needs no setting. |
337
+
338
+ A setting that doesn't exist fails, such as `toleranse:`, and so does a
339
+ kind that doesn't exist.
340
+
341
+ You can change the settings of any measure, including a plugin's and
342
+ Duel's own, or leave a measure out, with `c.measure`:
343
+
344
+ ```ruby
345
+ c.measure("redis_cpu", limit: 1.1)
346
+ c.measure("cpu", limit: 1.05)
347
+ c.measure("instructions", skip: true)
348
+ ```
349
+
350
+ To set up nothing for a measure that's left out, a metric's `open` can
351
+ call `c.skip?("name")`.
352
+
353
+ Duel reads metrics in the order that you add them before a sample, and
354
+ in reverse order after it, outside the built-in metrics. That way, the
355
+ metrics' own work isn't counted in allocations, instructions, or CPU
356
+ time. Duel also pauses metrics around `untimed` blocks, and around what
357
+ an `around_iteration` hook does outside each iteration, so that work
358
+ isn't counted. A costly metric isn't paused around the hook's work,
359
+ which Duel subtracts instead.
360
+
361
+ ### Write a metric class
362
+
363
+ For what running totals can't express, write a subclass of
364
+ `Duel::Metric`. It uses the same names as running totals: `open`,
365
+ `close`, `details`, and `costly?`. Its per-sample methods end in
366
+ `_sample`, so they're never mistaken for those that run once in each
367
+ worker. A subclass defines the following methods:
368
+
369
+ | Method | Description |
370
+ |---|---|
371
+ | `self.measures` | Names what the metric measures, with the settings in [Metric settings](#metric-settings), for example `{"wait" => {kind: :ratio, unit: "ns"}}`. By default, it's one counted measure named `self.metric_name`, the class's name in snake case: `QueryCount` is `query_count`. |
372
+ | `self.available?` | Returns `false` where the metric can't measure, to leave it out. |
373
+ | `self.costly?` | Returns `true` for a metric that's costly to read, such as one that queries a server, so that Duel doesn't pause it around each iteration inside an `around_iteration` hook. For more information, see [Account for the cost of reading](#account-for-the-cost-of-reading). |
374
+ | `open` | Called once in each worker, after its `c.in_worker` blocks, to set up what the metric reads, such as a subscription. |
375
+ | `start_sample(sample)` | Called before every sample. `sample.n` is the sample's number of iterations, and `sample.benchmark` is the benchmark, with its `name`, `only`, and `peak_memory`. |
376
+ | `stop_sample` | Called after the sample. Returns what was measured, or a Hash of measurements by name. |
377
+ | `pause_sample` and `resume_sample(sample)` | Called around an `untimed` block, and around what an `around_iteration` hook does outside each iteration (unless the metric is costly). By default, they call `stop_sample` and `start_sample`. |
378
+ | `details` | Explains a regression. See the following paragraphs. |
379
+ | `close` | Called when the worker ends, to let go of what `open` set up. Duel closes metrics last first. |
380
+
381
+ A metric also has the following methods to call:
382
+
383
+ - `settings`: its settings by measure name, as the configuration has
384
+ them after `c.measure`'s changes, with every setting filled in. A
385
+ metric that measures one thing gets one setting with
386
+ `setting(:allowance)`. Otherwise, name the measure:
387
+ `setting(:allowance, "rows")`.
388
+ - `within?(more, n)`: whether a count that grew by `more` over `n`
389
+ iterations is still within its tolerance and allowance, as Duel judges
390
+ it. `Duel::Metric.within?(more, n, tolerance:, allowance:)` does the
391
+ same for any count.
392
+
393
+ To explain a regression, define `details`. It returns text, or text by
394
+ measure name, about the sample that the metric last stopped. Duel shows
395
+ it under the regression for each version, for example
396
+ `retained_objects (candidate): 100 String, 50 Hash`. For a count, Duel
397
+ shows what the metric said of the sample that the count came from.
398
+
399
+ For when Duel calls `details`, see [Explain a sample](plugins.md#explain-a-sample).
400
+ Duel calls `details` after every sample that the metric measured, so
401
+ keep it cheap. Return nothing, or something cheap, unless something
402
+ looks worse, for example `return if within?(@retained, @n)`.
403
+
404
+ To add the metric, pass its class to `c.metric`:
405
+ `c.metric(MyMetric)`.
406
+
407
+ ## Use plugins
408
+
409
+ A plugin packages what a server or a library needs, such as a server for
410
+ each worker, hooks, and metrics. For the plugins that are available, and
411
+ how to add one, see [Plugins](../README.md#plugins).
412
+
413
+ `c.use :redis` requires `duel/redis`, from the duel-redis gem, and
414
+ configures Duel for the plugin, once. A plugin that needs another uses
415
+ it too, such as duel-rails, which uses duel-activerecord. Using a plugin
416
+ again does nothing more, and returns what the first use returned, such
417
+ as the plugin's own connection to its server. To give a plugin options,
418
+ use it yourself, before a plugin that uses it: a later use can repeat
419
+ the options that the first use gave, but another option fails, because
420
+ the plugin is configured already.
421
+
422
+ Duel's report lists the plugins that the configuration used.
423
+
424
+ Each worker loads Duel, and every plugin that the runner loaded, from
425
+ where the runner loaded them, ahead of the version's own bundle. Both
426
+ versions are measured with the same Duel and plugins, even a version
427
+ whose bundle doesn't have them, or has older versions of them.
428
+
429
+ To write your own plugin, see [Write a Duel plugin](plugins.md).