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