duel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/AGENT_PROMPT.md +211 -0
  3. data/CHANGELOG.md +281 -0
  4. data/LICENSE +21 -0
  5. data/README.md +354 -0
  6. data/docs/README.md +23 -0
  7. data/docs/benchmarks.md +289 -0
  8. data/docs/ci.md +141 -0
  9. data/docs/configuration.md +429 -0
  10. data/docs/how-it-works.md +207 -0
  11. data/docs/json.md +306 -0
  12. data/docs/plugins.md +408 -0
  13. data/docs/troubleshooting.md +105 -0
  14. data/exe/duel +6 -0
  15. data/lib/duel/across.rb +48 -0
  16. data/lib/duel/baseline.rb +166 -0
  17. data/lib/duel/cli.rb +264 -0
  18. data/lib/duel/config.rb +343 -0
  19. data/lib/duel/context.rb +630 -0
  20. data/lib/duel/init.rb +211 -0
  21. data/lib/duel/json_report.rb +107 -0
  22. data/lib/duel/lock.rb +38 -0
  23. data/lib/duel/metric.rb +496 -0
  24. data/lib/duel/native.rb +162 -0
  25. data/lib/duel/process_sample.rb +144 -0
  26. data/lib/duel/profile.rb +116 -0
  27. data/lib/duel/rake_task.rb +25 -0
  28. data/lib/duel/resource.rb +209 -0
  29. data/lib/duel/rubies.rb +149 -0
  30. data/lib/duel/runner.rb +1114 -0
  31. data/lib/duel/stats.rb +73 -0
  32. data/lib/duel/summary.rb +237 -0
  33. data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
  34. data/lib/duel/templates/duel.rb.erb +35 -0
  35. data/lib/duel/templates/perf.yml.erb +97 -0
  36. data/lib/duel/testing.rb +241 -0
  37. data/lib/duel/version.rb +5 -0
  38. data/lib/duel/worker.rb +211 -0
  39. data/lib/duel.rb +413 -0
  40. data/sig/duel/across.rbs +21 -0
  41. data/sig/duel/baseline.rbs +61 -0
  42. data/sig/duel/cli.rbs +95 -0
  43. data/sig/duel/config.rbs +181 -0
  44. data/sig/duel/context.rbs +387 -0
  45. data/sig/duel/init.rbs +97 -0
  46. data/sig/duel/json_report.rbs +47 -0
  47. data/sig/duel/lock.rbs +12 -0
  48. data/sig/duel/metric.rbs +351 -0
  49. data/sig/duel/native.rbs +98 -0
  50. data/sig/duel/process_sample.rbs +54 -0
  51. data/sig/duel/profile.rbs +64 -0
  52. data/sig/duel/rake_task.rbs +9 -0
  53. data/sig/duel/resource.rbs +106 -0
  54. data/sig/duel/rubies.rbs +45 -0
  55. data/sig/duel/runner.rbs +530 -0
  56. data/sig/duel/stats.rbs +26 -0
  57. data/sig/duel/summary.rbs +87 -0
  58. data/sig/duel/testing.rbs +91 -0
  59. data/sig/duel/version.rbs +3 -0
  60. data/sig/duel/worker.rbs +74 -0
  61. data/sig/duel.rbs +276 -0
  62. data/sig/manifest.yaml +4 -0
  63. metadata +124 -0
@@ -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