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/lib/duel.rb
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "duel/version"
|
|
4
|
+
require_relative "duel/config"
|
|
5
|
+
require_relative "duel/metric"
|
|
6
|
+
|
|
7
|
+
# Performance regression guards: every benchmark runs on two versions of
|
|
8
|
+
# a codebase side by side, the baseline and the candidate, and the
|
|
9
|
+
# candidate fails if it's slower at any of them. See the README.
|
|
10
|
+
module Duel
|
|
11
|
+
# What Duel raises when something is used wrong, or fails: a setting
|
|
12
|
+
# there isn't, a helper called where it would be counted, a resource
|
|
13
|
+
# that didn't start (Failed).
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# The hooks, which run code around a benchmark's samples: in .duel.rb
|
|
17
|
+
# (Config#setup, say), at the top of a file of benchmarks (DSL#setup),
|
|
18
|
+
# or as an option of one benchmark (DSL#duel's setup:). Where several
|
|
19
|
+
# give one, the configuration's run first, then the file's, then the
|
|
20
|
+
# benchmark's (teardown's in reverse, and around_iteration's outermost
|
|
21
|
+
# first):
|
|
22
|
+
#
|
|
23
|
+
# - setup: once in each worker, before the benchmark's samples
|
|
24
|
+
# - teardown: once in each worker, after them
|
|
25
|
+
# - before_sample: before every sample (the warm-up's too), not
|
|
26
|
+
# measured
|
|
27
|
+
# - around_iteration: around each iteration, given it (an Iteration) to
|
|
28
|
+
# run: only the iteration is measured
|
|
29
|
+
# - after_iterations: after each sample's iterations (inside each
|
|
30
|
+
# iteration, with around_iteration), measured with them
|
|
31
|
+
HOOKS = %i[setup teardown before_sample around_iteration after_iterations].freeze
|
|
32
|
+
|
|
33
|
+
# No hooks, by name.
|
|
34
|
+
# @api private
|
|
35
|
+
NO_HOOKS = HOOKS.to_h { |hook| [hook, Array.new(0).freeze] }.freeze #: Hash[Symbol, Array[hook]]
|
|
36
|
+
|
|
37
|
+
# A benchmark (see DSL#duel), defined in +file+, whose hooks (those of
|
|
38
|
+
# its file, and its own, +hooks+, by name) it gets. Or a benchmark of a
|
|
39
|
+
# whole +process+ (see DSL#duel_process), with no +body+. What a
|
|
40
|
+
# metric sees of it (Sample#benchmark), and its hooks are given, is
|
|
41
|
+
# its +name+, +only+ and +peak_memory+. Its +file+, +body+, +hooks+
|
|
42
|
+
# and +process+, and its writers, are @api private.
|
|
43
|
+
Definition = Struct.new(:name, :only, :peak_memory, :file, :body, :hooks, :process)
|
|
44
|
+
|
|
45
|
+
class Definition
|
|
46
|
+
# Whose CPU time it takes: the benchmark thread's, or (judged on its
|
|
47
|
+
# timings alone, as work on threads of its own is) the process's.
|
|
48
|
+
# @api private
|
|
49
|
+
def cpu = (only == :timing) ? :process : :thread
|
|
50
|
+
|
|
51
|
+
# What the runner learns of it.
|
|
52
|
+
# @api private
|
|
53
|
+
def to_h = {"name" => name, "only" => only&.to_s, "peak_memory" => peak_memory, "process" => process}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The iterations of a benchmark judged on its counts alone (see
|
|
57
|
+
# DSL#duel).
|
|
58
|
+
# @api private
|
|
59
|
+
COUNTS_ONLY = 4
|
|
60
|
+
|
|
61
|
+
# What a benchmark may be judged on (see DSL#duel): nil for both.
|
|
62
|
+
# @api private
|
|
63
|
+
ONLY = [nil, :count, :timing] #: Array[only?]
|
|
64
|
+
|
|
65
|
+
# What a worker's blocks (Config#in_worker) are given: blocks to run
|
|
66
|
+
# when the worker ends, last first.
|
|
67
|
+
class Stops
|
|
68
|
+
# @api private
|
|
69
|
+
def initialize
|
|
70
|
+
@blocks = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Runs the block when the worker ends.
|
|
74
|
+
def at_stop(&block) = @blocks << block
|
|
75
|
+
|
|
76
|
+
# Runs them, last first.
|
|
77
|
+
# @api private
|
|
78
|
+
def run = @blocks.reverse_each(&:call)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# What Config#in_worker returns: what its block returned in the worker
|
|
82
|
+
# (a connection, say, that metrics and benchmarks use).
|
|
83
|
+
class InWorker
|
|
84
|
+
# The worker's. Outside a worker (the runner's process, or a test not
|
|
85
|
+
# in Duel::Testing.in_worker), it fails.
|
|
86
|
+
def value
|
|
87
|
+
made = @made
|
|
88
|
+
raise Error, "made in each worker only (c.in_worker): in tests, inside Duel::Testing.in_worker" unless made
|
|
89
|
+
|
|
90
|
+
made.first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @api private
|
|
94
|
+
def made(value) = @made = [value]
|
|
95
|
+
|
|
96
|
+
# @api private
|
|
97
|
+
def forget = @made = nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
# The configuration: the project's .duel.rb, or the conventions.
|
|
102
|
+
def config = @config ||= Config.new
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
attr_writer :config
|
|
106
|
+
|
|
107
|
+
# Yields the configuration (see Config), as .duel.rb does.
|
|
108
|
+
def configure = yield(config)
|
|
109
|
+
|
|
110
|
+
# Whether a benchmark is being measured right now: in a worker, inside
|
|
111
|
+
# a sample, but not in its +untimed+ blocks (or in setup). What a
|
|
112
|
+
# plugin's helper does then would be counted (a query it runs, say),
|
|
113
|
+
# so a helper meant for setting up can fail instead.
|
|
114
|
+
def measuring? = @measuring == true
|
|
115
|
+
|
|
116
|
+
# Fails while a benchmark is being measured (see #measuring?): a
|
|
117
|
+
# plugin's helper meant for setting up, +name+d, calls it first, since
|
|
118
|
+
# what it does then would be counted.
|
|
119
|
+
#
|
|
120
|
+
# def self.flush
|
|
121
|
+
# Duel.unmeasured!("Duel::Redis.flush")
|
|
122
|
+
# ...
|
|
123
|
+
def unmeasured!(name)
|
|
124
|
+
raise Error, "#{name}: in setup or untimed only (it would be counted)" if measuring?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Runs the block with #measuring? as +measuring+, and returns what it
|
|
128
|
+
# does.
|
|
129
|
+
# @api private
|
|
130
|
+
def measured(measuring)
|
|
131
|
+
was = @measuring
|
|
132
|
+
begin
|
|
133
|
+
@measuring = measuring
|
|
134
|
+
yield
|
|
135
|
+
ensure
|
|
136
|
+
@measuring = was
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Loads a config file (.duel.rb): a fresh configuration, rooted where
|
|
141
|
+
# the file is.
|
|
142
|
+
# @api private
|
|
143
|
+
def load_config(path)
|
|
144
|
+
path = File.expand_path(path)
|
|
145
|
+
self.config = Config.new(File.dirname(path))
|
|
146
|
+
load path
|
|
147
|
+
config
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Where +method+ is defined ("file:line"), or, for one with no file,
|
|
151
|
+
# whose it is (Kernel, say): for an error about it.
|
|
152
|
+
# @api private
|
|
153
|
+
def where(method) = method.source_location&.join(":") || method.owner.to_s
|
|
154
|
+
|
|
155
|
+
# Every benchmark defined, by name.
|
|
156
|
+
# @api private
|
|
157
|
+
def benchmarks = @benchmarks ||= {}
|
|
158
|
+
|
|
159
|
+
# Each file's hooks (see DSL#setup, say), by its path, then by name.
|
|
160
|
+
# @api private
|
|
161
|
+
def file_hooks = @file_hooks ||= Hash.new { |all, file| all[file] = HOOKS.to_h { |hook| [hook, []] } }
|
|
162
|
+
|
|
163
|
+
# Adds +block+ as the hook +name+ (one of HOOKS) of the file of
|
|
164
|
+
# benchmarks running (see #defining_file), or else of +file+, the one
|
|
165
|
+
# that gave it (not where the block is: a lambda of a plugin's, say).
|
|
166
|
+
# @api private
|
|
167
|
+
def file_hook(name, block, file)
|
|
168
|
+
hook = hook(name, block)
|
|
169
|
+
file_hooks[defining_file || file].fetch(name) << hook
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# +block+, a hook +name+d (one of HOOKS), or a failure if it isn't
|
|
173
|
+
# one: a Proc (a block, or a lambda), and for around_iteration, one
|
|
174
|
+
# that takes the iteration.
|
|
175
|
+
# @api private
|
|
176
|
+
def hook(name, block)
|
|
177
|
+
raise ArgumentError, "#{name}: give a block (or a lambda), not #{block.inspect}" unless block.is_a?(Proc)
|
|
178
|
+
return block unless name == :around_iteration
|
|
179
|
+
return block if block.arity == 1
|
|
180
|
+
|
|
181
|
+
raise ArgumentError, "around_iteration: take the iteration, and run it: { |iteration| ... iteration.call ... }"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Registers a plugin, +name+d as its file is required (duel/+name+,
|
|
185
|
+
# which Config#use requires): +plugin+, a module whose +configure+
|
|
186
|
+
# Config#use calls, given the configuration and the options it's used
|
|
187
|
+
# with. Returns its name.
|
|
188
|
+
#
|
|
189
|
+
# module Duel
|
|
190
|
+
# module Redis
|
|
191
|
+
# Duel.plugin(:redis, self)
|
|
192
|
+
#
|
|
193
|
+
# def self.configure(config, server: "redis-server")
|
|
194
|
+
# config.prepare(:worker) { |w| ... }
|
|
195
|
+
# end
|
|
196
|
+
# end
|
|
197
|
+
# end
|
|
198
|
+
#
|
|
199
|
+
# Its own kinds of benchmark, if it has any, are the methods of its
|
|
200
|
+
# module's DSL module, which files of benchmarks get as .duel.rb uses
|
|
201
|
+
# it (see DSL.add):
|
|
202
|
+
#
|
|
203
|
+
# module Duel
|
|
204
|
+
# module MyApp
|
|
205
|
+
# Duel.plugin(:my_app, self)
|
|
206
|
+
#
|
|
207
|
+
# module DSL
|
|
208
|
+
# def duel_request(name, path, **options) = duel(name, **options) { ::MyApp.get(path) }
|
|
209
|
+
# end
|
|
210
|
+
# end
|
|
211
|
+
# end
|
|
212
|
+
def plugin(name, plugin)
|
|
213
|
+
plugins[name] = plugin
|
|
214
|
+
name
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Every plugin registered, by name.
|
|
218
|
+
# @api private
|
|
219
|
+
def plugins = @plugins ||= {} #: Hash[Symbol, Module & _Plugin]
|
|
220
|
+
|
|
221
|
+
# The file of benchmarks that called the DSL method running (see
|
|
222
|
+
# DSL.add), if one is: the file that what it defines is filed
|
|
223
|
+
# under, so it gets that file's hooks, not those of the file the
|
|
224
|
+
# method is in.
|
|
225
|
+
# @api private
|
|
226
|
+
attr_reader :defining_file
|
|
227
|
+
|
|
228
|
+
# Runs the block with +file+ as #defining_file, unless a DSL method
|
|
229
|
+
# that called this one gave one already (the outermost, a file of
|
|
230
|
+
# benchmarks, is the one), and returns what it does.
|
|
231
|
+
# @api private
|
|
232
|
+
def defining(file)
|
|
233
|
+
outer = @defining_file
|
|
234
|
+
@defining_file = outer || file
|
|
235
|
+
begin
|
|
236
|
+
yield
|
|
237
|
+
ensure
|
|
238
|
+
@defining_file = outer
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# What files of benchmarks call (the worker gives it to its main
|
|
244
|
+
# object).
|
|
245
|
+
module DSL
|
|
246
|
+
# Adds the public methods of +methods+, a plugin's DSL module (see
|
|
247
|
+
# Duel.plugin), to the DSL, as .duel.rb uses the plugin (see
|
|
248
|
+
# Config#use): files of benchmarks call them on their main object,
|
|
249
|
+
# so they can call #duel. Adding a module's again does nothing.
|
|
250
|
+
#
|
|
251
|
+
# A name the DSL has already fails, and so does one that every object
|
|
252
|
+
# has (Kernel's +test+, say), which it would hide. What a method
|
|
253
|
+
# defines (with #duel or #duel_process, or a hook: #setup, say) is
|
|
254
|
+
# filed under the file of benchmarks that called it, not the
|
|
255
|
+
# plugin's, so it gets that file's hooks, as a benchmark defined
|
|
256
|
+
# there with #duel does.
|
|
257
|
+
# @api private
|
|
258
|
+
def self.add(methods) = methods.public_instance_methods(false).each { |name| define(name, methods) }
|
|
259
|
+
|
|
260
|
+
# Adds +methods+' method +name+ to the DSL (see .add), unless it's
|
|
261
|
+
# there already.
|
|
262
|
+
# @api private
|
|
263
|
+
def self.define(name, methods)
|
|
264
|
+
return if DEFINED[name].equal?(methods)
|
|
265
|
+
|
|
266
|
+
taken = [self, Object].find { |owner| owner.method_defined?(name) || owner.private_method_defined?(name) }
|
|
267
|
+
raise Error, "#{methods}##{name}: the DSL has #{name} already (#{Duel.where((DEFINED[name] || taken).instance_method(name))})" if taken
|
|
268
|
+
|
|
269
|
+
DEFINED[name] = methods
|
|
270
|
+
define_method(name) do |*args, **options, &block|
|
|
271
|
+
# @type var options: Hash[Symbol, untyped]
|
|
272
|
+
# @type var block: (^(*untyped, **untyped) -> untyped)?
|
|
273
|
+
# @type self: Object
|
|
274
|
+
# (Where it's called from: the one frame there always is.)
|
|
275
|
+
# @type var from: Thread::Backtrace::Location
|
|
276
|
+
caller_locations(1, 1) => [from]
|
|
277
|
+
# (The plugin's method as it is now, looked up each call.)
|
|
278
|
+
Duel.defining(from.path) { methods.instance_method(name).bind_call(self, *args, **options, &block) }
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The plugin's DSL module each method DSL.add added is from (for
|
|
283
|
+
# where it's defined).
|
|
284
|
+
# @api private
|
|
285
|
+
DEFINED = {} #: Hash[Symbol, Module]
|
|
286
|
+
|
|
287
|
+
# Defines a benchmark. Its block is timed, run as many times as a
|
|
288
|
+
# sample needs. With a parameter, it's run once a sample and given
|
|
289
|
+
# how many iterations to do:
|
|
290
|
+
#
|
|
291
|
+
# duel "parse: a document" do
|
|
292
|
+
# once { @input = File.read("doc.json") } # not timed: once, first
|
|
293
|
+
# MyLib.parse(@input) # timed
|
|
294
|
+
# end
|
|
295
|
+
#
|
|
296
|
+
# duel "queue: drain the queue" do |n|
|
|
297
|
+
# queue = untimed { MyQueue.new(Array.new(n)) } # not timed: every sample
|
|
298
|
+
# queue.drain
|
|
299
|
+
# end
|
|
300
|
+
#
|
|
301
|
+
# Options: only: what it's judged on, if not both its timings and its
|
|
302
|
+
# counts: :count for work too slow and light to time (waiting, say),
|
|
303
|
+
# run a few iterations at a time, or :timing for work done by
|
|
304
|
+
# threads of its own, which make its counts vary (and whose CPU time
|
|
305
|
+
# is the process's, not the benchmark thread's). peak_memory: true to
|
|
306
|
+
# judge its peak memory too. And its own hooks (see HOOKS), lambdas
|
|
307
|
+
# run in its context, after its configuration's and its file's: an
|
|
308
|
+
# around_iteration one takes the iteration (an Iteration) and runs
|
|
309
|
+
# it, and only the iteration is measured, so what it does before and
|
|
310
|
+
# after (rolling back what the iteration wrote, say) isn't.
|
|
311
|
+
#
|
|
312
|
+
# duel "posts: create", around_iteration: ->(iteration) { rolled_back { iteration.call } } do
|
|
313
|
+
# Post.create!(title: "Hi")
|
|
314
|
+
# end
|
|
315
|
+
def duel(name, only: nil, peak_memory: false, **hooks, &body)
|
|
316
|
+
raise ArgumentError, "duplicate benchmark #{name.inspect}" if Duel.benchmarks.key?(name)
|
|
317
|
+
raise ArgumentError, "only: must be :count or :timing, not #{only.inspect}" unless ONLY.include?(only)
|
|
318
|
+
|
|
319
|
+
Duel.benchmarks[name] = Definition.new(name, only, peak_memory, Duel.defining_file || body.source_location.first, body,
|
|
320
|
+
DSL.hooks(name, hooks, body))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# A benchmark's own hooks (+hooks+, by name), each one a hook there
|
|
324
|
+
# is (see Duel.hook): an around_iteration one runs around each
|
|
325
|
+
# iteration, so +body+ (the benchmark's block) can't take them.
|
|
326
|
+
# @api private
|
|
327
|
+
def self.hooks(name, hooks, body)
|
|
328
|
+
unknown = hooks.keys - HOOKS
|
|
329
|
+
raise ArgumentError, "duel #{name.inspect}: no option #{unknown.join(", ")} (only, peak_memory, #{HOOKS.join(", ")})" unless unknown.empty?
|
|
330
|
+
|
|
331
|
+
hooks.each { |hook, block| Duel.hook(hook, block) }
|
|
332
|
+
if hooks.key?(:around_iteration) && body.arity == 1
|
|
333
|
+
raise ArgumentError, "duel #{name.inspect}: around_iteration runs around each iteration: give the benchmark's block no parameter"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
hooks
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Defines a benchmark of a whole process, booting an app or requiring
|
|
340
|
+
# a library, say: each of its samples is a fresh process for each
|
|
341
|
+
# version, in turn, started in the version's tree with its
|
|
342
|
+
# environment (and on its Ruby: see Config#ruby), and it's judged on
|
|
343
|
+
# the process's CPU time (its children's too) and, where Duel can tell
|
|
344
|
+
# it, its peak memory.
|
|
345
|
+
#
|
|
346
|
+
# duel_process "boot: the app", "bin/rails", "runner", "1"
|
|
347
|
+
# duel_process "boot: require the library", ruby: %(require "my_gem")
|
|
348
|
+
#
|
|
349
|
+
# A +command+ is run as Process.spawn takes it (one String with
|
|
350
|
+
# spaces is a shell command). Code given as +ruby+ is run by the
|
|
351
|
+
# version's Ruby, with the version's lib/ first on the load path. A
|
|
352
|
+
# process that fails (a status other than 0) is an error. Each sample
|
|
353
|
+
# is the fastest of +runs+ processes: by default, as many as take about
|
|
354
|
+
# a second of CPU time, up to 3.
|
|
355
|
+
#
|
|
356
|
+
# It isn't run in a worker, so what runs there doesn't apply to it:
|
|
357
|
+
# hooks (the configuration's, and its file's), and plugins' metrics.
|
|
358
|
+
# Nor do #duel's options: given one (around_iteration:, say), it
|
|
359
|
+
# fails.
|
|
360
|
+
def duel_process(name, *command, ruby: nil, runs: nil, **options)
|
|
361
|
+
raise ArgumentError, "duplicate benchmark #{name.inspect}" if Duel.benchmarks.key?(name)
|
|
362
|
+
raise ArgumentError, "duel_process #{name.inspect}: give a command, or ruby: code, not both" if command.empty? == ruby.nil?
|
|
363
|
+
counted = runs.nil? || (runs.instance_of?(Integer) && runs.positive?)
|
|
364
|
+
raise ArgumentError, "duel_process #{name.inspect}: runs: is a number of processes a sample, 1 or more, not #{runs.inspect}" unless counted
|
|
365
|
+
unless options.empty?
|
|
366
|
+
raise ArgumentError, "duel_process #{name.inspect}: #{options.keys.join(", ")} #{(options.size == 1) ? "is" : "are"} duel's, " \
|
|
367
|
+
"for a benchmark run in a worker: a process benchmark runs no hooks, and is judged on its CPU time and peak memory alone"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
process = {"command" => (command unless command.empty?), "ruby" => ruby, "runs" => runs} #: Definition::process
|
|
371
|
+
Duel.benchmarks[name] = Definition.new(name, nil, false, Duel.defining_file || caller_locations.fetch(0).path, nil, {}, process)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# En garde.
|
|
375
|
+
# @api private
|
|
376
|
+
alias_method :⚔️, :duel
|
|
377
|
+
# @api private
|
|
378
|
+
alias_method :⚔, :duel
|
|
379
|
+
|
|
380
|
+
# The file's hooks (see HOOKS), for every benchmark in it: run in each
|
|
381
|
+
# benchmark's context, after the configuration's and before the
|
|
382
|
+
# benchmark's own. setup runs once in each worker before a
|
|
383
|
+
# benchmark's samples, and teardown after them (last first).
|
|
384
|
+
# before_sample runs before every sample (the warm-up's too), not
|
|
385
|
+
# measured: to put back what earlier samples changed (data they
|
|
386
|
+
# wrote, say), so that every sample starts alike. The first three
|
|
387
|
+
# are given the benchmark, if they take it.
|
|
388
|
+
def setup(&block) = Duel.file_hook(:setup, block, DSL.caller_file)
|
|
389
|
+
|
|
390
|
+
def teardown(&block) = Duel.file_hook(:teardown, block, DSL.caller_file)
|
|
391
|
+
|
|
392
|
+
def before_sample(&block) = Duel.file_hook(:before_sample, block, DSL.caller_file)
|
|
393
|
+
|
|
394
|
+
# Runs the block around each iteration of every benchmark in the
|
|
395
|
+
# file, given the iteration (an Iteration) to run, once: only the
|
|
396
|
+
# iteration is measured.
|
|
397
|
+
def around_iteration(&block) = Duel.file_hook(:around_iteration, block, DSL.caller_file)
|
|
398
|
+
|
|
399
|
+
# Runs the block after each sample's iterations of every benchmark in
|
|
400
|
+
# the file (inside each iteration, with around_iteration), measured
|
|
401
|
+
# with them: to do the work they left to be done later.
|
|
402
|
+
def after_iterations(&block) = Duel.file_hook(:after_iterations, block, DSL.caller_file)
|
|
403
|
+
|
|
404
|
+
# The file that called the DSL method calling this (the one frame
|
|
405
|
+
# there always is, past it).
|
|
406
|
+
# @api private
|
|
407
|
+
def self.caller_file
|
|
408
|
+
# @type var from: Thread::Backtrace::Location
|
|
409
|
+
caller_locations(2, 1) => [from]
|
|
410
|
+
from.path
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
data/sig/duel/across.rbs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Duel
|
|
2
|
+
# Whether the candidate is slower across the board.
|
|
3
|
+
# @api private
|
|
4
|
+
class Across
|
|
5
|
+
# A timed metric's verdict across the board ("confirmed": slower in a
|
|
6
|
+
# second measurement too, see Runner#across).
|
|
7
|
+
type verdict = { "median" => Float, "interval" => Array[Float]?, "limit" => Float, "benchmarks" => Integer,
|
|
8
|
+
"verdict" => String, ?"confirmed" => bool }
|
|
9
|
+
|
|
10
|
+
@results: Array[Result]
|
|
11
|
+
@confidence: Float
|
|
12
|
+
|
|
13
|
+
# The across-the-board limit for a metric whose own limit is +limit+.
|
|
14
|
+
def self.limit: (Float limit) -> Float
|
|
15
|
+
|
|
16
|
+
def initialize: (Array[Result] results, confidence: Float) -> void
|
|
17
|
+
|
|
18
|
+
# Every timed metric any benchmark was judged on, by name.
|
|
19
|
+
def verdicts: () -> Hash[String, verdict]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Duel
|
|
2
|
+
# The version to compare against: a directory, or a git commit checked
|
|
3
|
+
# out (and built) where #checkout says.
|
|
4
|
+
# @api private
|
|
5
|
+
class Baseline
|
|
6
|
+
LAST_RELEASE: String
|
|
7
|
+
BRANCHES: Array[String]
|
|
8
|
+
|
|
9
|
+
@config: Config
|
|
10
|
+
@settings: Settings
|
|
11
|
+
@ref: String?
|
|
12
|
+
@prefix: String?
|
|
13
|
+
|
|
14
|
+
def initialize: (Config config, Settings settings) -> void
|
|
15
|
+
|
|
16
|
+
# The commit, or nil for "last-release" with no release yet.
|
|
17
|
+
def ref: () -> String?
|
|
18
|
+
|
|
19
|
+
# Whether nothing Config#watch names changed since the baseline.
|
|
20
|
+
def unchanged?: () -> bool
|
|
21
|
+
|
|
22
|
+
# The baseline's tree, checked out and built if it's a commit.
|
|
23
|
+
def root: () -> String
|
|
24
|
+
|
|
25
|
+
# Where the project is in a checkout of its repository at +dir+.
|
|
26
|
+
def within: (String dir) -> String
|
|
27
|
+
|
|
28
|
+
# The project's path from its repository's top level ("" at the top).
|
|
29
|
+
def prefix: () -> String
|
|
30
|
+
|
|
31
|
+
# Where a git baseline is checked out: next to the repository, under a
|
|
32
|
+
# hidden name as long as its own, or else tmp/duel/base.
|
|
33
|
+
def checkout: () -> String
|
|
34
|
+
|
|
35
|
+
# Removes what Duel keeps: tmp/duel, and the baseline's checkout.
|
|
36
|
+
def clean: () -> void
|
|
37
|
+
|
|
38
|
+
# The main branch.
|
|
39
|
+
def main_branch: () -> String
|
|
40
|
+
|
|
41
|
+
# Builds the tree in +dir+ (Config#build), if there's a build.
|
|
42
|
+
def build: (String dir, String what) -> void
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Where HEAD left the main branch: their merge base, or a failure that
|
|
47
|
+
# says why there's none.
|
|
48
|
+
def merge_base: () -> String
|
|
49
|
+
|
|
50
|
+
# Whether +dir+ is a checkout of this repository.
|
|
51
|
+
def ours?: (String dir) -> bool
|
|
52
|
+
|
|
53
|
+
# The repository a directory is a checkout of, or nil for none.
|
|
54
|
+
def common_dir: (String dir) -> String?
|
|
55
|
+
|
|
56
|
+
# Git's output. It raises Error when it fails, unless +allow_failure+
|
|
57
|
+
# (then nil).
|
|
58
|
+
def git: (*String args) -> String
|
|
59
|
+
| (*String args, allow_failure: bool) -> String?
|
|
60
|
+
end
|
|
61
|
+
end
|
data/sig/duel/cli.rbs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Duel
|
|
2
|
+
# The `duel` command.
|
|
3
|
+
# @api private
|
|
4
|
+
class CLI
|
|
5
|
+
# The environment (ENV, or a Hash in its place).
|
|
6
|
+
interface _Env
|
|
7
|
+
def reject: () { (String name, String value) -> boolish } -> _Variables
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# What's left of it.
|
|
11
|
+
interface _Variables
|
|
12
|
+
def key?: (String name) -> bool
|
|
13
|
+
|
|
14
|
+
def fetch: (String name) -> String
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
USAGE: String
|
|
18
|
+
|
|
19
|
+
# Where GitHub Actions has a step add to its job's summary.
|
|
20
|
+
STEP_SUMMARY: String
|
|
21
|
+
|
|
22
|
+
# The variables that set options, to their options.
|
|
23
|
+
ENV_OPTIONS: Hash[String, String]
|
|
24
|
+
|
|
25
|
+
# The variables that set a flag by being set at all.
|
|
26
|
+
ENV_FLAGS: Hash[String, String]
|
|
27
|
+
|
|
28
|
+
@argv: Array[String]
|
|
29
|
+
@env: _Env
|
|
30
|
+
@out: _Output
|
|
31
|
+
@err: _Output
|
|
32
|
+
@clean: bool?
|
|
33
|
+
@markdown: String?
|
|
34
|
+
@json: String?
|
|
35
|
+
@settings: Settings?
|
|
36
|
+
|
|
37
|
+
def initialize: (Array[String] argv, ?env: _Env, ?out: _Output, ?err: _Output) -> void
|
|
38
|
+
|
|
39
|
+
# Runs, and returns the exit status: 0 not slower (or nothing to compare),
|
|
40
|
+
# 1 slower (or a benchmark failed), 2 couldn't compare.
|
|
41
|
+
def run: () -> Integer
|
|
42
|
+
|
|
43
|
+
# The settings, from the environment and then the options.
|
|
44
|
+
# +source+ as a Regexp, or an option's invalid argument.
|
|
45
|
+
def self.regexp: (String source) -> Regexp
|
|
46
|
+
|
|
47
|
+
def parse: () -> Settings
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# What #run does, holding the project's lock.
|
|
52
|
+
def compare: (Config config, Settings settings) -> Integer
|
|
53
|
+
|
|
54
|
+
# Says there's nothing to compare, and why, and passes.
|
|
55
|
+
def nothing: (String why) -> Integer
|
|
56
|
+
|
|
57
|
+
# Reports a run that didn't compare, and returns +exit+.
|
|
58
|
+
def unfinished: (String title, String status, String message, Integer exit) -> Integer
|
|
59
|
+
|
|
60
|
+
# Writes the reports where they're asked for, each made only if it's
|
|
61
|
+
# written.
|
|
62
|
+
def publish: (^() -> String markdown, ^() -> JSONReport::report json) -> void
|
|
63
|
+
|
|
64
|
+
# Writes +text+ to +path+, in a directory made for it.
|
|
65
|
+
def write: (String path, String text) -> void
|
|
66
|
+
|
|
67
|
+
# The baseline, in Markdown.
|
|
68
|
+
def described_baseline: (Config config, Settings settings, Baseline baseline) -> String
|
|
69
|
+
|
|
70
|
+
# The candidate, in Markdown.
|
|
71
|
+
def described_candidate: (Config config, Settings settings) -> String
|
|
72
|
+
|
|
73
|
+
# The baseline, for JSON: the ref that named it, its commit, and its
|
|
74
|
+
# tree.
|
|
75
|
+
def baseline_json: (Config config, Settings settings, Baseline baseline, String root) -> Hash[String, String?]
|
|
76
|
+
|
|
77
|
+
# The candidate, for JSON: its commit, whether it has changes not
|
|
78
|
+
# committed, and its tree.
|
|
79
|
+
def candidate_json: (Config config, Settings settings) -> Hash[String, String | bool | nil]
|
|
80
|
+
|
|
81
|
+
# Git's output, and its status, in +dir+.
|
|
82
|
+
def git: (String dir, *String args) -> [String, Process::Status]
|
|
83
|
+
|
|
84
|
+
# Says so if the two versions' paths differ in length.
|
|
85
|
+
def uneven: (String base_root, String candidate) -> void
|
|
86
|
+
|
|
87
|
+
# The DUEL_* variables set, as options.
|
|
88
|
+
def env_options: () -> Array[String]
|
|
89
|
+
|
|
90
|
+
# The environment's variables that are set.
|
|
91
|
+
def variables: () -> _Variables
|
|
92
|
+
|
|
93
|
+
def parser: (Settings settings) -> OptionParser
|
|
94
|
+
end
|
|
95
|
+
end
|