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/context.rb
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "native"
|
|
4
|
+
|
|
5
|
+
module Duel
|
|
6
|
+
# What a benchmark's blocks run in (its file's hooks, and its own, too):
|
|
7
|
+
# its state lives in instance variables. Its methods are the two a
|
|
8
|
+
# benchmark calls, #once and #untimed, and the helpers that files of
|
|
9
|
+
# benchmarks define: what the worker does with it is its Harness's, so
|
|
10
|
+
# a helper's name is never Duel's (see .check).
|
|
11
|
+
class Context
|
|
12
|
+
# (Its one instance variable is named so that a benchmark's, which
|
|
13
|
+
# live beside it, never clash with it.)
|
|
14
|
+
|
|
15
|
+
# @api private
|
|
16
|
+
def initialize(harness)
|
|
17
|
+
@__duel_harness = harness
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the block the first time the benchmark runs: its trial (see
|
|
21
|
+
# Harness#trial), or, in a worker that ran none, its first peak
|
|
22
|
+
# memory sample (see Harness#run_memory), as an untimed block. Never
|
|
23
|
+
# when it's measured otherwise. (Not the setup hook, which runs
|
|
24
|
+
# before the benchmark, outside its block: see HOOKS.)
|
|
25
|
+
def once(&) = @__duel_harness.once(&)
|
|
26
|
+
|
|
27
|
+
# Runs the block with measuring paused (see DSL#duel), and returns
|
|
28
|
+
# what it does.
|
|
29
|
+
def untimed(&) = @__duel_harness.untimed(&)
|
|
30
|
+
|
|
31
|
+
# Fails if a helper that files of benchmarks define (a method of
|
|
32
|
+
# Object's own: a def at the top of a file) has the name of a method
|
|
33
|
+
# Duel gives them, a Context's or the DSL's: Duel's would hide it, and
|
|
34
|
+
# its callers would call Duel's instead.
|
|
35
|
+
# @api private
|
|
36
|
+
def self.check
|
|
37
|
+
(public_instance_methods(false) + DSL.instance_methods).each do |name|
|
|
38
|
+
next unless Object.method_defined?(name, false) || Object.private_method_defined?(name, false)
|
|
39
|
+
|
|
40
|
+
raise Error, "#{Duel.where(Object.instance_method(name))}: a helper can't be named #{name}, Duel's is: rename it"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A benchmark as a worker runs it: its Context, its hooks (the
|
|
46
|
+
# configuration's, its file's and its own: see HOOKS), its trial and
|
|
47
|
+
# its samples.
|
|
48
|
+
# @api private
|
|
49
|
+
class Harness
|
|
50
|
+
# Nothing left by pausing and resuming (see #leak).
|
|
51
|
+
NO_LEAK = {} #: Hash[String, Float]
|
|
52
|
+
|
|
53
|
+
# The iterations that each of a calibration's CALIBRATION samples
|
|
54
|
+
# pauses and resumes (see #leak).
|
|
55
|
+
STRETCHES = 1000
|
|
56
|
+
|
|
57
|
+
# The iterations of each of the CALIBRATION samples that learn what
|
|
58
|
+
# around_iteration hooks add to the metrics that are costly to read
|
|
59
|
+
# (see #leak).
|
|
60
|
+
HOOKED = 10
|
|
61
|
+
|
|
62
|
+
attr_reader :definition, :context
|
|
63
|
+
|
|
64
|
+
# +hooks+: the configuration's (Config#hooks), by name.
|
|
65
|
+
def initialize(definition, hooks = NO_HOOKS)
|
|
66
|
+
@definition = definition
|
|
67
|
+
@context = Context.new(self)
|
|
68
|
+
body = definition.body
|
|
69
|
+
@body = bound(body)
|
|
70
|
+
@given_n = body.arity == 1
|
|
71
|
+
@setup = hooks(:setup, hooks).map { |hook| given(hook) }
|
|
72
|
+
@teardown = hooks(:teardown, hooks).map { |hook| given(hook) }.reverse
|
|
73
|
+
@before_sample = hooks(:before_sample, hooks).map { |hook| given(hook) }
|
|
74
|
+
@after = hooks(:after_iterations, hooks)
|
|
75
|
+
# (Each around_iteration hook's Iteration, innermost first, made once:
|
|
76
|
+
# running one allocates nothing.)
|
|
77
|
+
iteration = nil #: Iteration?
|
|
78
|
+
hooks(:around_iteration, hooks).reverse_each { |hook| iteration = Iteration.new(self, hook, iteration) }
|
|
79
|
+
@iteration = iteration
|
|
80
|
+
raise Error, "#{definition.name}: around_iteration runs around each iteration: give the benchmark's block no parameter" if @given_n && @iteration
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# (See Context#once.)
|
|
84
|
+
def once(&)
|
|
85
|
+
if @trial then yield
|
|
86
|
+
elsif @once_done == false then untimed(&)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# (See Context#untimed.)
|
|
91
|
+
def untimed(&)
|
|
92
|
+
sample = @sample || raise(Error, "#{@definition.name}: untimed outside a sample")
|
|
93
|
+
# (Inside an around_iteration hook, or another untimed block, it's
|
|
94
|
+
# paused already.)
|
|
95
|
+
Duel.measuring? ? sample.pause { Duel.measured(false, &) } : yield
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Runs its setup hooks.
|
|
99
|
+
def prepare = @setup.each(&:call)
|
|
100
|
+
|
|
101
|
+
# Runs its teardown hooks (each level's last first, the benchmark's
|
|
102
|
+
# first).
|
|
103
|
+
def finish = @teardown.each(&:call)
|
|
104
|
+
|
|
105
|
+
# Runs the benchmark once, measuring nothing, with its once blocks:
|
|
106
|
+
# one that this version can't run (it uses something new) fails here.
|
|
107
|
+
def trial
|
|
108
|
+
@trial = true
|
|
109
|
+
run(1, Trial.new)
|
|
110
|
+
ensure
|
|
111
|
+
@trial = false
|
|
112
|
+
@once_done = true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Runs one sample of +n+ iterations for its peak memory, measured by
|
|
116
|
+
# +sample+ (a MemorySample). A worker that ran no trial (one doesn't,
|
|
117
|
+
# for peak memory: see Worker#setup) runs the benchmark's once blocks
|
|
118
|
+
# in it.
|
|
119
|
+
def run_memory(n, sample)
|
|
120
|
+
@once_done ||= false # (nil, with no trial: once blocks run in this sample)
|
|
121
|
+
run(n, sample)
|
|
122
|
+
ensure
|
|
123
|
+
@once_done = true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Runs one sample of +n+ iterations, measured by +sample+ (a Sample),
|
|
127
|
+
# once its before_sample hooks have run: a block without a parameter
|
|
128
|
+
# n times, or one with once, given n, and then its after_iterations
|
|
129
|
+
# hooks.
|
|
130
|
+
def run(n, sample)
|
|
131
|
+
@sample = sample
|
|
132
|
+
@before_sample.each(&:call)
|
|
133
|
+
sample.start
|
|
134
|
+
begin
|
|
135
|
+
# (A trial measures nothing.)
|
|
136
|
+
Duel.measured(!@trial) { iterate(n, sample) }
|
|
137
|
+
ensure
|
|
138
|
+
sample.stop
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
@sample = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether it runs inside around_iteration hooks.
|
|
145
|
+
def around? = !@iteration.nil?
|
|
146
|
+
|
|
147
|
+
# A Sample of +n+ iterations of it, measured by +metrics+ (see
|
|
148
|
+
# Sample.new).
|
|
149
|
+
def sample(metrics, n:, counting: true) = Sample.new(@definition, metrics, n: n, counting: counting, around: around?)
|
|
150
|
+
|
|
151
|
+
# What a benchmark with around_iteration hooks leaves in each of
|
|
152
|
+
# +metrics+' measures, an iteration, that isn't the iteration's own,
|
|
153
|
+
# learned once in each worker: for those paused around each
|
|
154
|
+
# iteration (see Iteration), what pausing and resuming leaves (the
|
|
155
|
+
# readings' own cost, mostly), and for those costly to read
|
|
156
|
+
# (Metric.costly?), which aren't, what the hooks add (see #hooked).
|
|
157
|
+
# Taken from each sample's measures (Sample#less), it doesn't water a
|
|
158
|
+
# regression down. Nothing, for a benchmark without them.
|
|
159
|
+
def leak(metrics)
|
|
160
|
+
return NO_LEAK unless @iteration
|
|
161
|
+
|
|
162
|
+
@leak ||= begin
|
|
163
|
+
costly, cheap = metrics.partition { |metric| metric.class.costly? }
|
|
164
|
+
paused(cheap).merge(hooked(costly))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Runs one iteration, measured, then its after_iterations hooks
|
|
169
|
+
# (inside the innermost Iteration), and returns what the iteration
|
|
170
|
+
# does.
|
|
171
|
+
def iteration
|
|
172
|
+
Duel.measured(!@trial) do
|
|
173
|
+
sample = @sample #: _Sample
|
|
174
|
+
sample.unpause do
|
|
175
|
+
done = @body.call
|
|
176
|
+
@after.each(&:call)
|
|
177
|
+
done
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# The hooks +name+d: +hooks+' (the configuration's, as they are), then
|
|
185
|
+
# its file's and its own (run in its context).
|
|
186
|
+
def hooks(name, hooks)
|
|
187
|
+
file = Duel.file_hooks.fetch(@definition.file, NO_HOOKS).fetch(name)
|
|
188
|
+
hooks.fetch(name) + [*file, *@definition.hooks[name]].map { |hook| bound(hook) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# +hook+, given the benchmark, if it takes it.
|
|
192
|
+
def given(hook)
|
|
193
|
+
return hook if hook.arity.zero?
|
|
194
|
+
|
|
195
|
+
definition = @definition
|
|
196
|
+
-> { hook.call(definition) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# What pausing and resuming leaves in each of +metrics+' measures, an
|
|
200
|
+
# iteration: the least of what CALIBRATION samples of STRETCHES empty
|
|
201
|
+
# iterations left.
|
|
202
|
+
def paused(metrics)
|
|
203
|
+
runs = Array.new(Snapshot::CALIBRATION) do
|
|
204
|
+
sample = Sample.new(@definition, metrics, n: STRETCHES)
|
|
205
|
+
sample.start
|
|
206
|
+
sample.between { STRETCHES.times { sample.unpause {} } }
|
|
207
|
+
sample.stop
|
|
208
|
+
sample.timed.merge(sample.counted)
|
|
209
|
+
end
|
|
210
|
+
# (Two stretches more: from the start, and to the stop.)
|
|
211
|
+
least(runs, STRETCHES + 2)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# What the around_iteration hooks add to each of +metrics+' measures
|
|
215
|
+
# (those costly to read), an iteration: the least of what CALIBRATION
|
|
216
|
+
# samples of HOOKED iterations (each run as any sample's, hooks and
|
|
217
|
+
# all) measured with every iteration left out (see Hooked). What a
|
|
218
|
+
# hook does that depends on its iteration (rolling back what it
|
|
219
|
+
# wrote, say) is in it too. Nothing, without such metrics.
|
|
220
|
+
def hooked(metrics)
|
|
221
|
+
return NO_LEAK if metrics.empty?
|
|
222
|
+
|
|
223
|
+
runs = Array.new(Snapshot::CALIBRATION) do
|
|
224
|
+
sample = Sample.new(@definition, metrics, n: HOOKED)
|
|
225
|
+
run(HOOKED, Hooked.new(sample))
|
|
226
|
+
sample.timed.merge(sample.counted)
|
|
227
|
+
end
|
|
228
|
+
least(runs, HOOKED)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Each measure's least in +runs+, over +stretches+.
|
|
232
|
+
def least(runs, stretches) = runs.first.to_h { |name, _| [name, runs.map { |run| run.fetch(name) }.min.fdiv(stretches)] }
|
|
233
|
+
|
|
234
|
+
# Runs +n+ iterations, then its after_iterations hooks, each inside
|
|
235
|
+
# the around_iteration hooks if it has them, with +sample+ paused
|
|
236
|
+
# (Sample#between) but while each iteration runs. (Nothing allocated
|
|
237
|
+
# an iteration: a bound method's call allocates nothing, where
|
|
238
|
+
# instance_exec would.)
|
|
239
|
+
def iterate(n, sample)
|
|
240
|
+
iteration = @iteration
|
|
241
|
+
if iteration
|
|
242
|
+
sample.between do
|
|
243
|
+
Duel.measured(false) do
|
|
244
|
+
i = 0
|
|
245
|
+
while i < n
|
|
246
|
+
iteration.run
|
|
247
|
+
i += 1
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
elsif @given_n
|
|
252
|
+
@body.call(n)
|
|
253
|
+
@after.each(&:call)
|
|
254
|
+
else
|
|
255
|
+
i = 0
|
|
256
|
+
while i < n
|
|
257
|
+
@body.call
|
|
258
|
+
i += 1
|
|
259
|
+
end
|
|
260
|
+
@after.each(&:call)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# +block+ as a method of the context's (of a module of its own, not
|
|
265
|
+
# the context's class: no name of a helper's is taken).
|
|
266
|
+
def bound(block) = Module.new { define_method(:call, &block) }.instance_method(:call).bind(@context)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# An iteration of a benchmark with around_iteration hooks, which a hook
|
|
270
|
+
# is given to run, by #call: the iteration, and its after_iterations
|
|
271
|
+
# hooks, are measured, and the rest of the hook isn't. Making one is
|
|
272
|
+
# @api private.
|
|
273
|
+
class Iteration
|
|
274
|
+
# Given to +hook+, it runs the hook given +inner+ (the next
|
|
275
|
+
# around_iteration hook's Iteration), or, innermost (+inner+ nil),
|
|
276
|
+
# +harness+'s iteration.
|
|
277
|
+
# @api private
|
|
278
|
+
def initialize(harness, hook, inner)
|
|
279
|
+
@harness = harness
|
|
280
|
+
@hook = hook
|
|
281
|
+
@inner = inner
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Runs the iteration, measured (inside any hooks inner to this one),
|
|
285
|
+
# and returns what it does. Once, and only once, each time the hook
|
|
286
|
+
# is called.
|
|
287
|
+
def call
|
|
288
|
+
raise Error, "#{@harness.definition.name}: around_iteration ran its iteration twice" if @ran
|
|
289
|
+
|
|
290
|
+
@ran = true
|
|
291
|
+
inner = @inner
|
|
292
|
+
@done = inner ? inner.run : @harness.iteration
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Runs its hook, given it, and returns what its iteration did: a hook
|
|
296
|
+
# that didn't run it fails.
|
|
297
|
+
# @api private
|
|
298
|
+
def run
|
|
299
|
+
@ran = false
|
|
300
|
+
@hook.call(self)
|
|
301
|
+
raise Error, "#{@harness.definition.name}: around_iteration didn't run its iteration" unless @ran
|
|
302
|
+
|
|
303
|
+
@done
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# One measured stretch of a benchmark: every metric's measurement of it,
|
|
308
|
+
# and (CRuby) about how many bytes it allocated (#heap), which pile up
|
|
309
|
+
# while the garbage collector is held off. A metric's #start_sample gets it:
|
|
310
|
+
# its #benchmark and #n are the metric's to read, and the rest is the
|
|
311
|
+
# worker's.
|
|
312
|
+
class Sample
|
|
313
|
+
# Zero bytes allocated: what JRuby tells (see HEAP).
|
|
314
|
+
# @api private
|
|
315
|
+
NO_HEAP = -> { 0 }
|
|
316
|
+
|
|
317
|
+
# Bytes allocated so far, roughly: objects (at the least an object
|
|
318
|
+
# takes, heap 0's slot) and memory malloc'd. Zero on JRuby, whose collector isn't
|
|
319
|
+
# held off (and doesn't tell).
|
|
320
|
+
# @api private
|
|
321
|
+
HEAP = {
|
|
322
|
+
"ruby" => -> { (GC.stat_heap(0, :slot_size) * GC.stat(:total_allocated_objects)) + GC.stat(:malloc_increase_bytes) }
|
|
323
|
+
}.fetch(RUBY_ENGINE, NO_HEAP)
|
|
324
|
+
|
|
325
|
+
# Collects the garbage left so far, then holds off collecting until
|
|
326
|
+
# the sample is over: when a collection lands is up to chance, so it
|
|
327
|
+
# would only add noise. Its cost grows with what's allocated, which
|
|
328
|
+
# is counted exactly instead. (JRuby's collector can't be held off.)
|
|
329
|
+
# @api private
|
|
330
|
+
QUIET_GC = {
|
|
331
|
+
"ruby" => lambda do
|
|
332
|
+
GC.start(full_mark: false)
|
|
333
|
+
GC.disable
|
|
334
|
+
end
|
|
335
|
+
}.fetch(RUBY_ENGINE, -> {})
|
|
336
|
+
|
|
337
|
+
# What a metric learns of the sample: its benchmark (a Definition:
|
|
338
|
+
# name, only, peak_memory) and its iterations.
|
|
339
|
+
attr_reader :benchmark, :n
|
|
340
|
+
|
|
341
|
+
# @api private
|
|
342
|
+
attr_reader :started, :stopped, :timed, :counted, :heap, :details
|
|
343
|
+
|
|
344
|
+
# +metrics+: the worker's metric instances, outermost first (CPU, the
|
|
345
|
+
# innermost, last). +counting+: whether counted measures are taken
|
|
346
|
+
# too (ratio ones always are). +around+: whether its benchmark runs
|
|
347
|
+
# inside around_iteration hooks (see Harness#around?).
|
|
348
|
+
# @api private
|
|
349
|
+
def initialize(benchmark, metrics, n:, counting: true, around: false)
|
|
350
|
+
@benchmark = benchmark
|
|
351
|
+
@n = n
|
|
352
|
+
@counting = counting
|
|
353
|
+
@metrics = metrics.select { |m| m.kinds.value?(:ratio) || counting }
|
|
354
|
+
# (Those paused around each iteration, inside around_iteration hooks:
|
|
355
|
+
# not those costly to read. Without such hooks, or such a metric, the
|
|
356
|
+
# same Array: nothing more is allocated, or worked out for every
|
|
357
|
+
# sample.)
|
|
358
|
+
@paused = (around && @metrics.any? { |m| m.class.costly? }) ? @metrics.reject { |m| m.class.costly? } : @metrics
|
|
359
|
+
# (How a measurement is filed is worked out now, not while measuring.)
|
|
360
|
+
@metrics.each(&:first_measure)
|
|
361
|
+
@timed = {}
|
|
362
|
+
@counted = {}
|
|
363
|
+
@details = {}
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @api private
|
|
367
|
+
def start
|
|
368
|
+
@started = true
|
|
369
|
+
QUIET_GC.call
|
|
370
|
+
@heap = -HEAP.call
|
|
371
|
+
@metrics.each { |m| m.start_sample(self) }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# @api private
|
|
375
|
+
def stop
|
|
376
|
+
@metrics.reverse_each { |m| record(m, m.stop_sample) }
|
|
377
|
+
@heap += HEAP.call
|
|
378
|
+
GC.enable
|
|
379
|
+
@metrics.each { |m| explain(m) }
|
|
380
|
+
@stopped = true
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Runs the block with the metrics paused (what they measured so far
|
|
384
|
+
# kept), and returns what it does. The garbage collector stays off.
|
|
385
|
+
# @api private
|
|
386
|
+
def pause
|
|
387
|
+
@metrics.reverse_each { |m| record(m, m.pause_sample) }
|
|
388
|
+
yield
|
|
389
|
+
ensure
|
|
390
|
+
@metrics.each { |m| m.resume_sample(self) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Runs the block (around_iteration hooks, around each iteration: see
|
|
394
|
+
# Iteration) with the metrics paused, but those costly to read
|
|
395
|
+
# (Metric.costly?), which go on measuring (what the hooks add to them
|
|
396
|
+
# is taken away instead: see Harness#leak). Returns what it does.
|
|
397
|
+
# @api private
|
|
398
|
+
def between
|
|
399
|
+
@paused.reverse_each { |m| record(m, m.pause_sample) }
|
|
400
|
+
yield
|
|
401
|
+
ensure
|
|
402
|
+
@paused.each { |m| m.resume_sample(self) }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Runs the block measured, inside #between's (an iteration inside its
|
|
406
|
+
# around_iteration hooks), and returns what it does. Neither this nor #between
|
|
407
|
+
# allocates anything, so the allocations counted around it are the
|
|
408
|
+
# block's own.
|
|
409
|
+
# @api private
|
|
410
|
+
def unpause
|
|
411
|
+
@paused.each { |m| m.resume_sample(self) }
|
|
412
|
+
yield
|
|
413
|
+
ensure
|
|
414
|
+
@paused.reverse_each { |m| record(m, m.pause_sample) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Takes +leak+ (Harness#leak) away from each measure, once an
|
|
418
|
+
# iteration, but no further than nothing.
|
|
419
|
+
# @api private
|
|
420
|
+
def less(leak)
|
|
421
|
+
[@timed, @counted].each do |totals|
|
|
422
|
+
totals.each { |name, total| totals[name] = [total - (leak.fetch(name, 0) * @n).round, 0].max }
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private
|
|
427
|
+
|
|
428
|
+
# Keeps what a metric says of the sample (Metric#details), by measure.
|
|
429
|
+
def explain(metric)
|
|
430
|
+
case (said = metric.details)
|
|
431
|
+
when Hash then @details.merge!(said)
|
|
432
|
+
when String then @details.merge!(metric.kinds.transform_values { said })
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Files a metric's measurement (a Hash of them, by name, or one: its
|
|
437
|
+
# first measure's) by kind.
|
|
438
|
+
def record(metric, value)
|
|
439
|
+
kinds = metric.kinds
|
|
440
|
+
case value
|
|
441
|
+
when Hash then value.each { |name, measured| file(kinds, name, measured) }
|
|
442
|
+
else file(kinds, metric.first_measure, value)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Adds +measured+ (nil: nothing this time) to +name+'s total, of its
|
|
447
|
+
# kind (counted ones only when counting).
|
|
448
|
+
def file(kinds, name, measured)
|
|
449
|
+
return if measured.nil?
|
|
450
|
+
|
|
451
|
+
kind = kinds.fetch(name)
|
|
452
|
+
return unless kind == :ratio || @counting
|
|
453
|
+
|
|
454
|
+
totals = (kind == :ratio) ? @timed : @counted
|
|
455
|
+
totals[name] = totals.fetch(name, 0) + measured
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# A sample of what a benchmark's around_iteration hooks add to the
|
|
460
|
+
# metrics that are costly to read (Metric.costly?), which go on
|
|
461
|
+
# measuring around each iteration: +sample+ (a Sample of those)
|
|
462
|
+
# measures all but the iterations (see Harness#leak).
|
|
463
|
+
# @api private
|
|
464
|
+
class Hooked
|
|
465
|
+
def initialize(sample)
|
|
466
|
+
@sample = sample
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def start = @sample.start
|
|
470
|
+
|
|
471
|
+
def stop = @sample.stop
|
|
472
|
+
|
|
473
|
+
# Runs the block (an untimed one, inside an iteration): paused
|
|
474
|
+
# already.
|
|
475
|
+
def pause = yield
|
|
476
|
+
|
|
477
|
+
# Runs the block (the around_iteration hooks): measured.
|
|
478
|
+
def between = yield
|
|
479
|
+
|
|
480
|
+
# Runs the block (an iteration, inside its hooks) paused.
|
|
481
|
+
def unpause(&) = @sample.pause(&)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# A run of a benchmark that measures nothing: to see that it runs.
|
|
485
|
+
# @api private
|
|
486
|
+
class Trial
|
|
487
|
+
attr_reader :started, :stopped
|
|
488
|
+
|
|
489
|
+
def start = @started = true
|
|
490
|
+
|
|
491
|
+
def stop = @stopped = true
|
|
492
|
+
|
|
493
|
+
def pause = yield
|
|
494
|
+
|
|
495
|
+
def between = yield
|
|
496
|
+
|
|
497
|
+
def unpause = yield
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# A stretch of a benchmark run for its peak memory: with the garbage
|
|
501
|
+
# collector working as usual, the most resident memory the process
|
|
502
|
+
# reached above where it started (after a full collection), watched
|
|
503
|
+
# every millisecond.
|
|
504
|
+
# @api private
|
|
505
|
+
class MemorySample
|
|
506
|
+
attr_reader :started, :stopped, :peak
|
|
507
|
+
|
|
508
|
+
def start
|
|
509
|
+
@started = true
|
|
510
|
+
GC.start(full_mark: true, immediate_sweep: true)
|
|
511
|
+
@base = resident
|
|
512
|
+
@peak = 0
|
|
513
|
+
@watcher = Thread.new do
|
|
514
|
+
loop do
|
|
515
|
+
watch
|
|
516
|
+
sleep 0.001
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def stop
|
|
522
|
+
@watcher.kill
|
|
523
|
+
@watcher.join
|
|
524
|
+
watch
|
|
525
|
+
@stopped = true
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Runs the block (an untimed one, or a benchmark's once: see
|
|
529
|
+
# Context#once) unwatched, and what it leaves resident isn't counted:
|
|
530
|
+
# the peak is the measured work's, as every other measure is.
|
|
531
|
+
def pause
|
|
532
|
+
unwatched
|
|
533
|
+
begin
|
|
534
|
+
yield
|
|
535
|
+
ensure
|
|
536
|
+
watched
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Runs the block (around_iteration hooks, around each iteration)
|
|
541
|
+
# unwatched, as #pause does.
|
|
542
|
+
def between(&) = pause(&)
|
|
543
|
+
|
|
544
|
+
# Runs the block watched, inside #between's (an iteration inside its
|
|
545
|
+
# around_iteration hooks): what it leaves resident is counted after
|
|
546
|
+
# all.
|
|
547
|
+
def unpause
|
|
548
|
+
watched
|
|
549
|
+
begin
|
|
550
|
+
yield
|
|
551
|
+
ensure
|
|
552
|
+
unwatched
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
private
|
|
557
|
+
|
|
558
|
+
# From now on, nothing is watched, and what's left resident isn't
|
|
559
|
+
# counted.
|
|
560
|
+
def unwatched
|
|
561
|
+
@paused = true
|
|
562
|
+
@unwatched = resident
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# From now on, what's resident is watched again, above what was left
|
|
566
|
+
# while it wasn't.
|
|
567
|
+
def watched
|
|
568
|
+
@base += resident - @unwatched
|
|
569
|
+
@paused = false
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def resident
|
|
573
|
+
# (Resident memory is known: see Lane#peak_memory.)
|
|
574
|
+
Native::Memory.resident #: Integer
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def watch
|
|
578
|
+
return if @paused
|
|
579
|
+
|
|
580
|
+
above = resident - @base
|
|
581
|
+
@peak = above if above > @peak
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# A stretch of a benchmark profiled, as a Sample measures it: with the
|
|
586
|
+
# garbage collector held off until it's over (its cost shows in the
|
|
587
|
+
# allocation count). StackProf runs throughout (see Worker#profile), and
|
|
588
|
+
# what it samples outside the iterations (Harness#iterate), or in
|
|
589
|
+
# #pause but not in an iteration inside it, is left out (see
|
|
590
|
+
# Profile.of).
|
|
591
|
+
# @api private
|
|
592
|
+
class ProfileSample
|
|
593
|
+
INTERVAL = 100 # microseconds between StackProf's samples
|
|
594
|
+
|
|
595
|
+
# What StackProf samples by on +platform+: CPU time, but on macOS,
|
|
596
|
+
# elapsed time. macOS signals that CPU time is up only as the thread
|
|
597
|
+
# enters the kernel, so its samples pile up on whatever makes system
|
|
598
|
+
# calls, the most CPU-hungry code or not. A benchmark busy on the CPU
|
|
599
|
+
# spends elapsed time the same way.
|
|
600
|
+
def self.mode(platform = RUBY_PLATFORM) = platform.include?("darwin") ? :wall : :cpu
|
|
601
|
+
|
|
602
|
+
MODE = mode
|
|
603
|
+
|
|
604
|
+
attr_reader :started, :stopped
|
|
605
|
+
|
|
606
|
+
def start
|
|
607
|
+
@started = true
|
|
608
|
+
Sample::QUIET_GC.call
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def stop
|
|
612
|
+
GC.enable
|
|
613
|
+
@stopped = true
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Runs the block (an untimed one, or the around_iteration hooks of a
|
|
617
|
+
# benchmark with them: see #between): what it does is sampled in this
|
|
618
|
+
# method, so it's left out, all but an iteration inside it (see
|
|
619
|
+
# #unpause).
|
|
620
|
+
def pause = yield
|
|
621
|
+
|
|
622
|
+
# Runs the block (a benchmark's around_iteration hooks) in #pause.
|
|
623
|
+
def between(&) = pause(&)
|
|
624
|
+
|
|
625
|
+
# Runs the block (an iteration, inside its around_iteration hooks:
|
|
626
|
+
# see Iteration): what it does is sampled in Harness#iteration, inside
|
|
627
|
+
# #pause, so it's kept.
|
|
628
|
+
def unpause = yield
|
|
629
|
+
end
|
|
630
|
+
end
|