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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# How Duel works
|
|
2
|
+
|
|
3
|
+
This page explains how Duel decides that a change made your code slower,
|
|
4
|
+
what it measures, how it explains a regression, where the baseline comes
|
|
5
|
+
from, and how it handles JIT compilers.
|
|
6
|
+
|
|
7
|
+
## How Duel tells a regression from noise
|
|
8
|
+
|
|
9
|
+
A policy of zero tolerance only works if the guard never cries wolf.
|
|
10
|
+
After one false alarm, people learn to rerun until the build is green.
|
|
11
|
+
So Duel is built to fail only when your code really got slower, and
|
|
12
|
+
never on noise, so that it can gate every pull request:
|
|
13
|
+
|
|
14
|
+
- **CPU time, not elapsed time.** Duel times each benchmark by its
|
|
15
|
+
thread's CPU time, which other processes on the machine don't
|
|
16
|
+
inflate. A sample is the fastest of several runs, because interference
|
|
17
|
+
only ever adds time. Garbage collection is kept out of samples, and
|
|
18
|
+
its cost shows in the allocation count instead, which is exact.
|
|
19
|
+
- **Side by side, many times over.** The two versions run in worker
|
|
20
|
+
processes of their own, three of each by default, at the same time on
|
|
21
|
+
the same machine. They take turns: one sample from each, in
|
|
22
|
+
alternating order, and each pair from a different pair of processes.
|
|
23
|
+
That way, a busy moment weighs on both versions alike, and no one
|
|
24
|
+
process's luck decides anything. (Where things land in a process's
|
|
25
|
+
memory can make the very same code a few percent faster or slower for
|
|
26
|
+
the process's whole life.) There are no stored numbers and no
|
|
27
|
+
thresholds to maintain, and any machine will do.
|
|
28
|
+
- **A confidence interval, not an average.** A timing is slower only
|
|
29
|
+
when a 99% distribution-free confidence interval for the median of
|
|
30
|
+
the paired ratios (candidate / baseline) lies entirely above the
|
|
31
|
+
limit, which is 1.02 by default. No one outlier sample can decide it.
|
|
32
|
+
Duel takes samples in rounds of 10 pairs, up to 60, until the interval
|
|
33
|
+
settles the question. A process benchmark's sample is the fastest of
|
|
34
|
+
a few fresh processes, and it can also stop after any pair once the
|
|
35
|
+
interval shows that it isn't slower, which only ever ends a
|
|
36
|
+
measurement before it could find a slowdown.
|
|
37
|
+
Duel reports a timing that's still too close to the limit to call, and
|
|
38
|
+
doesn't fail on it.
|
|
39
|
+
- **Measured again before failing.** Duel measures whatever looks
|
|
40
|
+
slower again from scratch, up to three times, in fresh processes. It
|
|
41
|
+
fails only if every measurement finds the same metric worse. A
|
|
42
|
+
measurement whose timings are all still too close to call neither
|
|
43
|
+
confirms a slowdown nor clears it, so Duel measures once more in its
|
|
44
|
+
place, up to three more times. Every measurement that counts still has
|
|
45
|
+
to find it worse, so identical code, which only rarely looks slower in
|
|
46
|
+
any one measurement, still has to look slower in four or more.
|
|
47
|
+
- **Slower in time, whichever timing shows it.** A slowdown near the
|
|
48
|
+
limits can be slower in CPU time in one measurement and in
|
|
49
|
+
instructions in the next, with the other timing too close to call
|
|
50
|
+
each time. Duel fails on that too, if in every measurement a timing
|
|
51
|
+
is slower and none is the same or faster. Any timing can be the
|
|
52
|
+
slower one, not only one in particular, so Duel measures once more
|
|
53
|
+
before it fails, which keeps identical code from failing more often
|
|
54
|
+
than it otherwise would.
|
|
55
|
+
- **Exact counts.** Allocations (on CRuby), and any counted metric of
|
|
56
|
+
your own, fail on any increase: one more allocation per two
|
|
57
|
+
iterations.
|
|
58
|
+
- **Across the board, too.** A slowdown that's too small for any one
|
|
59
|
+
benchmark to settle, such as 1% everywhere, still shows in all of the
|
|
60
|
+
benchmarks together. For each metric that's judged by ratio, every
|
|
61
|
+
benchmark's median ratio is a sample. The candidate fails when a
|
|
62
|
+
confidence interval for their median lies above a quarter of the
|
|
63
|
+
metric's margin: 1.005, for CPU time's 1.02. This takes 8 benchmarks,
|
|
64
|
+
so with fewer, Duel doesn't report it. Duel measures again before it
|
|
65
|
+
fails, as it does for the rest.
|
|
66
|
+
|
|
67
|
+
## What Duel measures
|
|
68
|
+
|
|
69
|
+
Duel judges every benchmark on the following metrics:
|
|
70
|
+
|
|
71
|
+
| Metric | What it is | A regression |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `cpu` | The benchmark thread's CPU time (or the process's) | More than 2% slower, with 99% confidence |
|
|
74
|
+
| `instructions` | Instructions retired, from the CPU's own counters | More than 1% more, with 99% confidence |
|
|
75
|
+
| `allocations` | Ruby objects allocated (on CRuby) | One more per two iterations |
|
|
76
|
+
| `peak_memory` | The most resident memory, above the start, that measured work reached (not `once` or `untimed`), with garbage collection running as usual (when asked for) | More than 10% and 1 MB more |
|
|
77
|
+
| Yours | See [Add your own metrics](configuration.md#add-your-own-metrics) | |
|
|
78
|
+
|
|
79
|
+
Instruction counts barely vary from run to run, so they catch slowdowns
|
|
80
|
+
that are too small for CPU time to settle. CPU time still catches what
|
|
81
|
+
instructions miss, such as cache misses.
|
|
82
|
+
|
|
83
|
+
Instruction counts come from the CPU's counters, where the operating
|
|
84
|
+
system lets Duel read them:
|
|
85
|
+
|
|
86
|
+
- On Linux, they're the benchmark thread's, through `perf_event_open`,
|
|
87
|
+
which many virtual machines, including CI runners, don't offer. So
|
|
88
|
+
Linux doesn't count instructions for benchmarks with threads of their
|
|
89
|
+
own.
|
|
90
|
+
- On macOS, they're the whole process's.
|
|
91
|
+
- Elsewhere, and on JRuby and TruffleRuby, there's no `instructions`
|
|
92
|
+
metric, and CPU time judges alone.
|
|
93
|
+
|
|
94
|
+
## Where the extra time went
|
|
95
|
+
|
|
96
|
+
Duel shows what regressed per iteration, on each version, with what a
|
|
97
|
+
metric said of it if it had something to say. For more information, see
|
|
98
|
+
[Add your own metrics](configuration.md#add-your-own-metrics). When something is slower
|
|
99
|
+
in time, Duel profiles both versions over the same iterations with
|
|
100
|
+
[StackProf](https://github.com/tmm1/stackprof), if it's in your bundle
|
|
101
|
+
(`duel init` and duel-rails's generator add it to your `Gemfile`), and
|
|
102
|
+
shows where the extra CPU time went:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
REGRESSION parse: a large document: worse instructions, cpu in every measurement
|
|
106
|
+
instructions: 1.521e+06 → 1.629e+06 an iteration (1.07x)
|
|
107
|
+
cpu: 112.4µs → 121.5µs an iteration (1.08x)
|
|
108
|
+
where the extra CPU went (as a share of the baseline's: in each frame itself, and with what it called):
|
|
109
|
+
+6.8% +7.1% MyParser::Lexer#next_token
|
|
110
|
+
+0.4% +0.4% String#byteslice
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A profile covers only the work that Duel measures. Work inside
|
|
114
|
+
`untimed`, and what an `around_iteration` hook does before and after
|
|
115
|
+
each iteration, is left out. What an `after_iterations` hook does is in
|
|
116
|
+
it, because Duel measures that with the iterations. The garbage collector is held off
|
|
117
|
+
during each sample, as it is when Duel measures. Collecting what each
|
|
118
|
+
sample left behind isn't the benchmark's code, so it isn't ranked with
|
|
119
|
+
the frames. If it changed, Duel shows it on a line of its own, `in
|
|
120
|
+
garbage collection, apart from the code`. On macOS, the signal that a CPU-time interval
|
|
121
|
+
has passed arrives only when the process calls into the kernel, so a
|
|
122
|
+
CPU-time profile would blame whatever code makes system calls. There,
|
|
123
|
+
Duel profiles elapsed time instead, which a benchmark that's busy on the
|
|
124
|
+
CPU spends the same way.
|
|
125
|
+
|
|
126
|
+
When no one timing is slower in every measurement, but every
|
|
127
|
+
measurement has one that's slower (see
|
|
128
|
+
[How Duel tells a regression from noise](#how-duel-tells-a-regression-from-noise)),
|
|
129
|
+
the report names the timings that were:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
REGRESSION parse: a large document: slower in every measurement, by one of cpu, instructions
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
On each benchmark's line, counts such as `allocations` are per
|
|
136
|
+
iteration, as they are in the explanation of a regression.
|
|
137
|
+
|
|
138
|
+
## Where the baseline comes from
|
|
139
|
+
|
|
140
|
+
The main branch is the one that `origin/HEAD` names. Without
|
|
141
|
+
`origin/HEAD`, Duel uses `origin/main`, and then `origin/master`. In a
|
|
142
|
+
repository without a remote, it uses `main`, and then `master`. To name
|
|
143
|
+
another branch, set `c.main_branch`.
|
|
144
|
+
|
|
145
|
+
Duel checks out a git baseline as a git worktree next to your project,
|
|
146
|
+
under a hidden name that's as long as your project's directory name, for
|
|
147
|
+
example `.3f9c2a1b` next to `my_parser`. Duel keeps it there for the
|
|
148
|
+
next run. If your project has an `ext/` directory, Duel builds the
|
|
149
|
+
baseline with `bundle exec rake compile`. To remove the baseline, git's
|
|
150
|
+
record of it, and the rest of `tmp/duel`, run `duel --clean`.
|
|
151
|
+
|
|
152
|
+
If your project is in a subdirectory of its repository, as in a
|
|
153
|
+
monorepo, run `duel` in that subdirectory. Duel checks out the whole
|
|
154
|
+
repository next to the repository's directory, and the baseline is the
|
|
155
|
+
same subdirectory in that checkout. The paths in `c.watch` are from your
|
|
156
|
+
project's directory.
|
|
157
|
+
|
|
158
|
+
If `HEAD` and the main branch have no commit in common, Duel says so.
|
|
159
|
+
This happens in a shallow clone, which doesn't have the history that the
|
|
160
|
+
baseline comes from. To fix it, fetch the whole history. For example, on
|
|
161
|
+
GitHub Actions, set `fetch-depth: 0` for `actions/checkout`.
|
|
162
|
+
|
|
163
|
+
The baseline's path is as long and as deep as your project's, in a
|
|
164
|
+
subdirectory too, because what code does with its own file paths, such
|
|
165
|
+
as looking for a file that a request might be for, takes longer on a
|
|
166
|
+
longer or deeper path. A
|
|
167
|
+
baseline at a longer path would measure slower, and hide a regression
|
|
168
|
+
that small. If Duel can't write next to your project, or something else
|
|
169
|
+
already has that name, it uses `tmp/duel/base` instead. Whenever the two
|
|
170
|
+
versions' paths differ in length, Duel says so.
|
|
171
|
+
|
|
172
|
+
If nothing under `lib/` or `ext/` changed since the baseline, there's
|
|
173
|
+
nothing to compare, and Duel says so. To compare anyway, use `--force`.
|
|
174
|
+
|
|
175
|
+
Duel also builds the candidate before every run. Builds are incremental,
|
|
176
|
+
so a build that's already done costs little. A stale or missing build,
|
|
177
|
+
or a C extension that your code quietly does without, never decides a
|
|
178
|
+
comparison.
|
|
179
|
+
|
|
180
|
+
Duel also checks that each version's workers load only native extensions
|
|
181
|
+
that were built in that version's directory. A version without a build
|
|
182
|
+
of its own would quietly load another build, such as the other
|
|
183
|
+
version's, or an installed gem's, and the comparison wouldn't be of its
|
|
184
|
+
code. If that happens, Duel stops and tells you to set `c.build`. An
|
|
185
|
+
installed gem's extension is never taken for a version's build, even
|
|
186
|
+
when the gems are installed in the project's directory, as
|
|
187
|
+
`vendor/bundle` (where setup-ruby's `bundler-cache` puts them).
|
|
188
|
+
|
|
189
|
+
## JIT compilers
|
|
190
|
+
|
|
191
|
+
If YJIT is on in a worker, Duel runs each benchmark for two seconds
|
|
192
|
+
before it measures it. A small library's methods are compiled within
|
|
193
|
+
Duel's first samples, but in a big app, YJIT compiles a method only after
|
|
194
|
+
120 calls. YJIT can be on because you turned it on (`RUBY_YJIT_ENABLE=1`,
|
|
195
|
+
for example), or because your code did as it loaded, as Rails 7.2 and
|
|
196
|
+
later do. To warm up for a different time, use `--warm`.
|
|
197
|
+
|
|
198
|
+
### JRuby and TruffleRuby
|
|
199
|
+
|
|
200
|
+
JRuby's and TruffleRuby's JIT compilers keep compiling for seconds, so
|
|
201
|
+
Duel runs each benchmark for five seconds before it measures it
|
|
202
|
+
(`--warm`). Their timings stay noisier than CRuby's, so Duel often
|
|
203
|
+
leaves small timing regressions on them unsettled.
|
|
204
|
+
|
|
205
|
+
Neither has an allocation count, an instruction count, or profiles, and
|
|
206
|
+
each has peak memory only on Linux. StackProf runs on CRuby only: Duel
|
|
207
|
+
doesn't use it on JRuby or TruffleRuby, even where it's installed.
|
data/docs/json.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Read Duel's report as JSON
|
|
2
|
+
|
|
3
|
+
To read Duel's results from a program, such as a dashboard, a bot, or a
|
|
4
|
+
script that tracks benchmarks over time, write its report as JSON with
|
|
5
|
+
`--json`:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
duel --json tmp/duel.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The JSON report has the same results as the
|
|
12
|
+
[Markdown report](../README.md#report-on-a-pull-request), and more of
|
|
13
|
+
each: every timing's ratio, interval and verdict, each version's medians
|
|
14
|
+
an iteration, and every count. A relative path is relative to where
|
|
15
|
+
`duel` runs, and Duel makes the file's directory if it needs to. Each
|
|
16
|
+
run replaces the file.
|
|
17
|
+
|
|
18
|
+
Duel writes the report whatever the outcome: a comparison that passed or
|
|
19
|
+
failed, nothing to compare, or a comparison that couldn't run. It writes
|
|
20
|
+
nothing when the command line isn't valid, such as an unknown option,
|
|
21
|
+
and nothing for `--clean`, `--version`, or `duel init`, which aren't
|
|
22
|
+
comparisons. So a missing report means that `duel` didn't get as far as
|
|
23
|
+
comparing, or crashed.
|
|
24
|
+
|
|
25
|
+
## Versions of the schema
|
|
26
|
+
|
|
27
|
+
The report's `schema` key is the version of its schema, now `1`. A new
|
|
28
|
+
key can appear in any release without a new version, so ignore the keys
|
|
29
|
+
that you don't know. Any other change, such as a key removed or renamed,
|
|
30
|
+
or a value that means something else, comes with a new version. Check
|
|
31
|
+
`schema` before you read the rest, and stop if it isn't one that you
|
|
32
|
+
know.
|
|
33
|
+
|
|
34
|
+
The report uses only plain JSON types: objects, arrays, strings, numbers,
|
|
35
|
+
`true`, `false`, and `null`. Numbers are numbers, never strings. A key
|
|
36
|
+
that doesn't apply, such as a skipped benchmark's `iterations`, is
|
|
37
|
+
`null`, not missing, so every object of a kind has the same keys.
|
|
38
|
+
|
|
39
|
+
## An example
|
|
40
|
+
|
|
41
|
+
A run that found one regression, abridged to two benchmarks:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"schema": 1,
|
|
46
|
+
"duel_version": "0.1.0",
|
|
47
|
+
"status": "failed",
|
|
48
|
+
"exit": 1,
|
|
49
|
+
"message": null,
|
|
50
|
+
"settings": {
|
|
51
|
+
"confidence": 0.99,
|
|
52
|
+
"limit": 1.02,
|
|
53
|
+
"filter": "",
|
|
54
|
+
"jobs": 4,
|
|
55
|
+
"processes": 3,
|
|
56
|
+
"timeout": 60.0,
|
|
57
|
+
"warm": null,
|
|
58
|
+
"force": false
|
|
59
|
+
},
|
|
60
|
+
"seconds": 14.2,
|
|
61
|
+
"baseline": {
|
|
62
|
+
"ref": "origin/main",
|
|
63
|
+
"commit": "3f9c2a1d5e8b7c6a4f3e2d1c0b9a8f7e6d5c4b3a",
|
|
64
|
+
"root": "/home/runner/work/my_parser/.3f9c2a1b",
|
|
65
|
+
"ruby": "ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [x86_64-linux]"
|
|
66
|
+
},
|
|
67
|
+
"candidate": {
|
|
68
|
+
"commit": "8e4d0b7f1a2c3e4d5b6a7f8e9d0c1b2a3f4e5d6c",
|
|
69
|
+
"uncommitted_changes": false,
|
|
70
|
+
"root": "/home/runner/work/my_parser/my_parser",
|
|
71
|
+
"ruby": "ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [x86_64-linux]"
|
|
72
|
+
},
|
|
73
|
+
"plugins": [],
|
|
74
|
+
"benchmarks": [
|
|
75
|
+
{
|
|
76
|
+
"name": "parse: a large document",
|
|
77
|
+
"verdict": "regressed",
|
|
78
|
+
"passed": false,
|
|
79
|
+
"regressed": ["instructions", "cpu", "allocations"],
|
|
80
|
+
"unsettled": false,
|
|
81
|
+
"iterations": 9,
|
|
82
|
+
"seconds": 3.1,
|
|
83
|
+
"confirmations": 3,
|
|
84
|
+
"timed": {
|
|
85
|
+
"instructions": {
|
|
86
|
+
"ratio": 1.071,
|
|
87
|
+
"interval": [1.069, 1.073],
|
|
88
|
+
"limit": 1.01,
|
|
89
|
+
"pairs": 10,
|
|
90
|
+
"verdict": "slower",
|
|
91
|
+
"baseline": 1521000.0,
|
|
92
|
+
"candidate": 1629000.0,
|
|
93
|
+
"unit": null,
|
|
94
|
+
"plural": null,
|
|
95
|
+
"across": true
|
|
96
|
+
},
|
|
97
|
+
"cpu": {
|
|
98
|
+
"ratio": 1.081,
|
|
99
|
+
"interval": [1.062, 1.097],
|
|
100
|
+
"limit": 1.02,
|
|
101
|
+
"pairs": 10,
|
|
102
|
+
"verdict": "slower",
|
|
103
|
+
"baseline": 112400.0,
|
|
104
|
+
"candidate": 121500.0,
|
|
105
|
+
"unit": "ns",
|
|
106
|
+
"plural": "ns",
|
|
107
|
+
"across": true
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"counted": {
|
|
111
|
+
"allocations": {
|
|
112
|
+
"baseline": 5790.0,
|
|
113
|
+
"candidate": 5791.0,
|
|
114
|
+
"unit": null,
|
|
115
|
+
"plural": null,
|
|
116
|
+
"worse": true
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"peak_memory": null,
|
|
120
|
+
"details": {},
|
|
121
|
+
"explanation": [
|
|
122
|
+
"instructions: 1.521e+06 → 1.629e+06 an iteration (1.07x)",
|
|
123
|
+
"cpu: 112.4µs → 121.5µs an iteration (1.08x)",
|
|
124
|
+
"allocations: 5790 → 5791 an iteration",
|
|
125
|
+
"where the extra CPU went (as a share of the baseline's: in each frame itself, and with what it called):",
|
|
126
|
+
" +6.8% +7.1% MyParser::Lexer#next_token"
|
|
127
|
+
],
|
|
128
|
+
"error": null,
|
|
129
|
+
"skipped": null
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "parse: an empty document",
|
|
133
|
+
"verdict": "same",
|
|
134
|
+
"passed": true,
|
|
135
|
+
"regressed": [],
|
|
136
|
+
"unsettled": false,
|
|
137
|
+
"iterations": 2400,
|
|
138
|
+
"seconds": 0.8,
|
|
139
|
+
"confirmations": 0,
|
|
140
|
+
"timed": {
|
|
141
|
+
"cpu": {
|
|
142
|
+
"ratio": 1.002,
|
|
143
|
+
"interval": [0.994, 1.009],
|
|
144
|
+
"limit": 1.02,
|
|
145
|
+
"pairs": 10,
|
|
146
|
+
"verdict": "same",
|
|
147
|
+
"baseline": 410.5,
|
|
148
|
+
"candidate": 411.2,
|
|
149
|
+
"unit": "ns",
|
|
150
|
+
"plural": "ns",
|
|
151
|
+
"across": true
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"counted": {
|
|
155
|
+
"allocations": {
|
|
156
|
+
"baseline": 3.0,
|
|
157
|
+
"candidate": 3.0,
|
|
158
|
+
"unit": null,
|
|
159
|
+
"plural": null,
|
|
160
|
+
"worse": false
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"peak_memory": null,
|
|
164
|
+
"details": {},
|
|
165
|
+
"explanation": [],
|
|
166
|
+
"error": null,
|
|
167
|
+
"skipped": null
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
"across": {
|
|
171
|
+
"cpu": {
|
|
172
|
+
"ratio": 1.004,
|
|
173
|
+
"interval": [0.998, 1.012],
|
|
174
|
+
"limit": 1.005,
|
|
175
|
+
"benchmarks": 12,
|
|
176
|
+
"verdict": "undecided",
|
|
177
|
+
"confirmed": false
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"errors": []
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A run with nothing to compare has the same keys, with nothing compared:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"schema": 1,
|
|
189
|
+
"duel_version": "0.1.0",
|
|
190
|
+
"status": "nothing_to_compare",
|
|
191
|
+
"exit": 0,
|
|
192
|
+
"message": "duel: lib, ext unchanged since the baseline, so there's nothing to compare (--force to anyway)",
|
|
193
|
+
"settings": {"confidence": 0.99, "limit": 1.02, "filter": "", "jobs": 4, "processes": 3, "timeout": 60.0, "warm": null, "force": false},
|
|
194
|
+
"seconds": null,
|
|
195
|
+
"baseline": null,
|
|
196
|
+
"candidate": null,
|
|
197
|
+
"plugins": [],
|
|
198
|
+
"benchmarks": [],
|
|
199
|
+
"across": {},
|
|
200
|
+
"errors": []
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
For example, to list the benchmarks that regressed, with
|
|
205
|
+
[jq](https://jqlang.org):
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
jq -r '.benchmarks[] | select(.verdict == "regressed") | .name' tmp/duel.json
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## The report
|
|
212
|
+
|
|
213
|
+
| Key | What it is |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `schema` | The schema's version: `1`. |
|
|
216
|
+
| `duel_version` | The version of Duel that wrote the report. |
|
|
217
|
+
| `status` | `"passed"`, `"failed"`, `"nothing_to_compare"`, or `"error"` (Duel couldn't compare). |
|
|
218
|
+
| `exit` | The exit status of `duel`: `0` for passed or nothing to compare, `1` for failed, `2` for an error. |
|
|
219
|
+
| `message` | What Duel said, for nothing to compare or an error, such as `"duel: no release tag (v*) before HEAD to compare against"`. Otherwise `null`. |
|
|
220
|
+
| `settings` | The settings that the run used (see below). |
|
|
221
|
+
| `seconds` | How long the comparison took. `null` without a comparison. |
|
|
222
|
+
| `baseline`, `candidate` | The two versions (see below). `null` without a comparison. |
|
|
223
|
+
| `plugins` | The names of the plugins in use, such as `["rails", "activerecord"]`. |
|
|
224
|
+
| `benchmarks` | Every benchmark compared, in the order Duel found them (see below). |
|
|
225
|
+
| `across` | The verdicts across the board, by metric (see below). |
|
|
226
|
+
| `errors` | The benchmarks that failed, each as `{"benchmark": name, "message": error}`. |
|
|
227
|
+
|
|
228
|
+
A run fails when a benchmark regressed, a benchmark failed, or a metric
|
|
229
|
+
was slower across the board and confirmed (`"confirmed": true`).
|
|
230
|
+
|
|
231
|
+
### Settings
|
|
232
|
+
|
|
233
|
+
| Key | What it is |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `confidence` | The confidence of each interval, such as `0.99`. |
|
|
236
|
+
| `limit` | The ratio above which a timing is slower (`--limit`). A metric can have its own limit, which its timings show. |
|
|
237
|
+
| `filter` | The regular expression that picked the benchmarks (`--filter`), as source. `""` matches every one. |
|
|
238
|
+
| `jobs` | Benchmarks measured at once (`--jobs`). |
|
|
239
|
+
| `processes` | Worker processes for each version (`--processes`). |
|
|
240
|
+
| `timeout` | The seconds that a worker may take to load or to answer (`--timeout`). |
|
|
241
|
+
| `warm` | The seconds of warm-up for each benchmark (`--warm`), or `null` for the default, which depends on the Ruby. |
|
|
242
|
+
| `force` | Whether `--force` was given. |
|
|
243
|
+
|
|
244
|
+
### The versions
|
|
245
|
+
|
|
246
|
+
| Key | What it is |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `ref` | The baseline's git ref: the one `--base` named, the release tag for `--base last-release`, or the main branch, such as `"origin/main"`, by default, where the baseline is the commit where `HEAD` left that branch. `null` for a baseline directory. |
|
|
249
|
+
| `commit` | The full hash of the commit compared. `null` for a baseline directory, or for a candidate outside a git repository. |
|
|
250
|
+
| `uncommitted_changes` | The candidate only: whether what `c.watch` names has changes that aren't committed. `null` outside a git repository. |
|
|
251
|
+
| `root` | The tree that was compared, as an absolute path. |
|
|
252
|
+
| `ruby` | The version's Ruby, as its `RUBY_DESCRIPTION`. |
|
|
253
|
+
|
|
254
|
+
### Benchmarks
|
|
255
|
+
|
|
256
|
+
| Key | What it is |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `name` | The benchmark's name. |
|
|
259
|
+
| `verdict` | `"regressed"`, `"error"`, `"unsettled"` (a timing too close to its limit to settle), `"faster"`, `"changed"` (a count changed), `"skipped"`, or `"same"`, as in the Markdown report's table. |
|
|
260
|
+
| `passed` | Whether it regressed on nothing and didn't fail. |
|
|
261
|
+
| `regressed` | The metrics that it regressed on, in every measurement, such as `["cpu", "allocations"]`. Peak memory is `"peak_memory"`. |
|
|
262
|
+
| `unsettled` | Whether a timing was too close to its limit to settle. |
|
|
263
|
+
| `iterations` | The iterations in each sample. `null` for a benchmark that failed or was skipped. |
|
|
264
|
+
| `seconds` | How long its first measurement took. `null` for a benchmark that failed or was skipped. |
|
|
265
|
+
| `confirmations` | How many times it was measured again, from scratch, because it looked worse. |
|
|
266
|
+
| `timed` | Its timings, by metric (see below). |
|
|
267
|
+
| `counted` | Its counts, by metric (see below). |
|
|
268
|
+
| `peak_memory` | Each version's peak memory, as `{"baseline": bytes, "candidate": bytes, "unit": "B", "worse": true or false}`, for a benchmark that measures it. Otherwise `null`. |
|
|
269
|
+
| `details` | What metrics said of the last samples, by metric and then by version, such as `{"allocations": {"baseline": "2 String", "candidate": "3 String"}}`. |
|
|
270
|
+
| `explanation` | For a regression, the lines that explain it, as the terminal report and the Markdown report show them. Otherwise `[]`. |
|
|
271
|
+
| `error` | Why it failed, with any backtrace. Otherwise `null`. |
|
|
272
|
+
| `skipped` | Why it was skipped, such as a benchmark that isn't on the baseline. Otherwise `null`. |
|
|
273
|
+
|
|
274
|
+
Each timing in `timed`, such as `cpu`:
|
|
275
|
+
|
|
276
|
+
| Key | What it is |
|
|
277
|
+
|---|---|
|
|
278
|
+
| `ratio` | The median of the paired ratios, the candidate's to the baseline's. Above 1 is the candidate slower. |
|
|
279
|
+
| `interval` | The confidence interval for that median, as `[low, high]`. |
|
|
280
|
+
| `limit` | The ratio above which it's slower. |
|
|
281
|
+
| `pairs` | The pairs of samples measured. |
|
|
282
|
+
| `verdict` | `"slower"`, `"faster"`, `"same"`, or `"undecided"` (too close to the limit to settle). |
|
|
283
|
+
| `baseline`, `candidate` | Each version's median for one iteration, in `unit`. |
|
|
284
|
+
| `unit`, `plural` | The metric's unit, singular and plural, such as `"ns"`, or `null` for a plain number. |
|
|
285
|
+
| `across` | Whether it counts across the board. |
|
|
286
|
+
|
|
287
|
+
Each count in `counted`, such as `allocations`:
|
|
288
|
+
|
|
289
|
+
| Key | What it is |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `baseline`, `candidate` | Each version's fewest for one iteration, in `unit`. |
|
|
292
|
+
| `unit`, `plural` | The metric's unit, singular and plural, such as `"query"` and `"queries"`, or `null` for a plain number. |
|
|
293
|
+
| `worse` | Whether the candidate's count is worse, by more than the metric tolerates. |
|
|
294
|
+
|
|
295
|
+
### Across the board
|
|
296
|
+
|
|
297
|
+
Each metric in `across`, such as `cpu`:
|
|
298
|
+
|
|
299
|
+
| Key | What it is |
|
|
300
|
+
|---|---|
|
|
301
|
+
| `ratio` | The median of every benchmark's ratio. |
|
|
302
|
+
| `interval` | The confidence interval for that median, as `[low, high]`, or `null` with too few benchmarks to tell. |
|
|
303
|
+
| `limit` | The ratio above which it's slower across the board. |
|
|
304
|
+
| `benchmarks` | The benchmarks that it's from. |
|
|
305
|
+
| `verdict` | `"slower"`, `"faster"`, `"same"`, or `"undecided"`. |
|
|
306
|
+
| `confirmed` | Whether it was slower again when every benchmark was measured a second time, which fails the run. |
|