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,1114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "rbconfig"
6
+ require "socket"
7
+ require "tmpdir"
8
+ require_relative "../duel"
9
+ require_relative "stats"
10
+ require_relative "profile"
11
+ require_relative "across"
12
+ require_relative "resource"
13
+ require_relative "rubies"
14
+ require_relative "process_sample"
15
+
16
+ module Duel
17
+ # Seconds each benchmark runs before it's measured, unless --warm
18
+ # says: JRuby's and TruffleRuby's JITs keep compiling for seconds.
19
+ # @api private
20
+ JIT_WARM = {"jruby" => 5.0, "truffleruby" => 5.0}.fetch(RUBY_ENGINE, 0.0)
21
+
22
+ # Seconds each benchmark runs before it's measured where a worker has
23
+ # YJIT on (an app may turn it on as it boots, as Rails does), unless
24
+ # --warm says. A small library's methods are compiled within the
25
+ # warm-up samples, but a big app's only after 120 calls each, more than
26
+ # a slow benchmark's warm-up samples make.
27
+ # @api private
28
+ YJIT_WARM = 2.0
29
+
30
+ # How a run is set up (see Duel::CLI for where each comes from).
31
+ # @api private
32
+ Settings = Struct.new(:base, :base_dir, :candidate_dir, :filter, :jobs, :processes, :limit, :confidence, :warm, :force,
33
+ :config_path, :timeout, :ruby) do
34
+ # @type self: singleton(Settings)
35
+ def self.defaults = new(filter: //, jobs: 1, processes: 3, limit: 1.02, confidence: 0.99, warm: nil, force: false,
36
+ timeout: 60.0)
37
+ end
38
+
39
+ # One version as a run has it (see Runner#prepare), +name+d "baseline"
40
+ # or "candidate": its tree (+root+), the Ruby its processes run on
41
+ # (Config#ruby), and what they get in their environment (that Ruby's,
42
+ # and what its Config#prepare(:version) blocks gave it).
43
+ # @api private
44
+ Side = Struct.new(:name, :root, :ruby, :env) do
45
+ # @type self: singleton(Side)
46
+
47
+ # Each version with nothing prepared: its tree, on the runner's Ruby.
48
+ def self.unprepared(config, settings, base_root)
49
+ {"baseline" => new("baseline", File.expand_path(base_root), RbConfig.ruby, {}),
50
+ "candidate" => new("candidate", File.expand_path(settings.candidate_dir || config.root), RbConfig.ruby, {})}
51
+ end
52
+ end
53
+
54
+ # A worker process (Duel::Worker) running one version of the code.
55
+ # @api private
56
+ class WorkerProcess
57
+ LIB = File.expand_path("..", __dir__)
58
+ # Seconds between looks at a worker that's loading, or ending.
59
+ POLL = 0.01
60
+ # Seconds a worker may take to end, once its connection is closed,
61
+ # before it's killed.
62
+ STOP_WAIT = 10
63
+ # The gems that are Duel's: Duel, and its plugins (duel-redis, say).
64
+ DUEL_GEM = /\Aduel(-|\z)/
65
+
66
+ attr_reader :label, :benchmarks, :metrics, :memory, :profiler, :yjit
67
+
68
+ # The Ruby it runs on (RUBY_DESCRIPTION), the native extensions it
69
+ # loaded (see Worker.extensions), and the directories gems are
70
+ # installed in (see Worker.gem_dirs).
71
+ attr_reader :description, :extensions, :gem_dirs
72
+
73
+ # Where a worker on +ruby+ loads Duel from, ahead of anything else
74
+ # (a version's own bundle, which may not have Duel, or an older one):
75
+ # the runner's Duel, and every Duel gem (+specs+) the runner loaded
76
+ # (its plugins), so both versions are measured with the same. On the
77
+ # runner's own Ruby, Duel's dependencies (Fiddle) too: on another,
78
+ # their builds wouldn't load.
79
+ def self.load_path(ruby, specs: Gem.loaded_specs)
80
+ duel = specs.select { |name, _| name.match?(DUEL_GEM) }.values
81
+ none = [] #: Array[Gem::BasicSpecification]
82
+ dependencies = Rubies.runner?(ruby) ? specs["duel"]&.runtime_dependencies.to_a.filter_map { |d| specs[d.name] } : none
83
+ [LIB, *(duel + dependencies).flat_map(&:full_require_paths)].uniq
84
+ end
85
+
86
+ # +timeout+: the seconds it may take to load, or to answer a request,
87
+ # before it's taken for hung (and killed). It runs on +ruby+, with
88
+ # +env+.
89
+ def initialize(label, root, candidate_root, dir, config_path:, env:, timeout:, ruby:)
90
+ @label = label
91
+ @timeout = timeout
92
+ @root = root
93
+ @dir = dir
94
+ @env = env
95
+ @ruby = ruby
96
+ control = File.join(dir, "#{label}.sock")
97
+ server = UNIXServer.new(control)
98
+ # (A nil config_path is left out. And the worker gets no other
99
+ # worker's connection: TruffleRuby's sockets aren't close-on-exec,
100
+ # and one left open in another worker would keep it from ending.)
101
+ @pid = Process.spawn(env, ruby, *WorkerProcess.load_path(ruby).flat_map { |lib| ["-I", lib] }, "-r", "duel/worker",
102
+ "-e", "Duel::Worker.main(*ARGV)", root, candidate_root, control, *[config_path].compact, in: File::NULL, close_others: true)
103
+ @socket = accept(server, control)
104
+ hello = read
105
+ @benchmarks = hello.fetch("benchmarks").to_h { |b| [b.fetch("name"), b] }
106
+ @metrics = hello.fetch("metrics")
107
+ @memory = hello.fetch("memory")
108
+ @profiler = hello.fetch("profiler")
109
+ @yjit = hello.fetch("yjit")
110
+ @description = hello.fetch("ruby")
111
+ @extensions = hello.fetch("extensions")
112
+ @gem_dirs = hello.fetch("gem_dirs")
113
+ end
114
+
115
+ # Sends a request, and returns the reply, or raises Failed with its
116
+ # error.
117
+ def request(op, name, **args)
118
+ @socket.puts JSON.generate({"op" => op, "name" => name, **args})
119
+ reply = read
120
+ raise Failed, "#{@label}: #{reply.fetch("error")}\n #{reply.fetch("backtrace").join("\n ")}" if reply.key?("error")
121
+
122
+ reply
123
+ end
124
+
125
+ def sample(name, n, runs: 1, count: false) = request("sample", name, n: n, runs: runs, count: count)
126
+
127
+ # A run of a process benchmark (see DSL#duel_process): +process+, a
128
+ # fresh process, started as this worker was (on its Ruby, in its
129
+ # version's tree, with its environment), and measured (see
130
+ # ProcessSample#run).
131
+ def run(process)
132
+ # (Code, without a command.)
133
+ code = process.fetch("ruby") #: String
134
+ command = process.fetch("command") || [@ruby, "-I", File.join(@root, "lib"), "-e", code]
135
+ runs = @runs ||= ProcessSample.new(@label, env: @env, chdir: @root, log: File.join(@dir, "#{@label}.log"), timeout: @timeout)
136
+ runs.run(command)
137
+ end
138
+
139
+ # Closing the connection ends the worker. One still running +wait+
140
+ # seconds later is killed.
141
+ def stop(wait: STOP_WAIT)
142
+ @runs&.stop
143
+ @socket.close
144
+ deadline = monotonic + wait
145
+ sleep POLL until (gone = Process.wait(@pid, Process::WNOHANG)) || monotonic > deadline
146
+ kill unless gone
147
+ rescue Errno::ECHILD # gone already
148
+ nil
149
+ end
150
+
151
+ private
152
+
153
+ # The worker's connection, once it has loaded (or Failed if it died,
154
+ # or took too long, first).
155
+ def accept(server, path)
156
+ deadline = monotonic + @timeout
157
+ until server.wait_readable(POLL)
158
+ raise Failed, "#{@label} worker exited while loading" if Process.wait(@pid, Process::WNOHANG)
159
+ hung("load") if monotonic > deadline
160
+ end
161
+ server.accept
162
+ ensure
163
+ server.close
164
+ File.delete(path)
165
+ end
166
+
167
+ def read
168
+ hung("answer") unless @socket.wait_readable(@timeout)
169
+ line = @socket.gets or raise Failed, "#{@label} worker exited"
170
+ JSON.parse(line)
171
+ end
172
+
173
+ # Kills a worker that didn't +what+ within the timeout, and fails.
174
+ def hung(what)
175
+ kill
176
+ raise Failed, "#{@label} worker didn't #{what} within #{@timeout}s (killed: a benchmark that hangs?)"
177
+ end
178
+
179
+ def kill
180
+ Process.kill("KILL", @pid)
181
+ Process.wait(@pid)
182
+ end
183
+
184
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
185
+ end
186
+
187
+ # What Duel raises when something a run needs fails: a resource that
188
+ # didn't start (Resource#failed), two that set a variable differently,
189
+ # a worker that died or hung.
190
+ class Failed < Error; end
191
+
192
+ # Workers of both versions, taking turns: PROCESSES of each (Settings),
193
+ # so that where things landed in one process's memory (which can make
194
+ # the very same code a few percent faster or slower for that process's
195
+ # whole life) is spread over many pairs as noise the confidence
196
+ # interval accounts for, not a bias every pair shares. Each worker has
197
+ # its own resources (Config#prepare(:worker)).
198
+ # @api private
199
+ class Lane
200
+ # CPU time (or a plugin's ratio metric's equivalent) a run aims for:
201
+ # long enough that a clock's resolution is lost in it. A sample is the
202
+ # fastest of RUNS runs.
203
+ TARGET_NS = 1_000_000
204
+ RUNS = 5
205
+ WARMUP = 5
206
+ # A process benchmark's pairs before any are measured (of one run
207
+ # each), and the most runs a sample of it takes (unless it says: see
208
+ # DSL#duel_process): as many as take PROCESS_SAMPLE_NS of CPU time,
209
+ # each sample the fastest of them.
210
+ PROCESS_WARMUP = 1
211
+ PROCESS_RUNS = 3
212
+ PROCESS_SAMPLE_NS = 1_000_000_000
213
+ # The most a run may allocate, in bytes (see Sample#heap): it all stays
214
+ # until the run is over, as the garbage collector is held off.
215
+ HEAP_CAP = 64 << 20
216
+ # Pairs are taken in rounds of STEP until the verdict is clear (a
217
+ # process benchmark's: see #judge).
218
+ STEP = 10
219
+ MAX_PAIRS = 60
220
+ # Counts are each version's fewest in COUNTS samples.
221
+ COUNTS = 3
222
+ # Peak memory is each version's least in MEMORY_SAMPLES fresh workers.
223
+ MEMORY_SAMPLES = 3
224
+ # A profile: PROFILE_SAMPLES samples of the benchmark (some 5,000 of
225
+ # StackProf's a version: enough to tell a few percent apart).
226
+ PROFILE_SAMPLES = 500
227
+
228
+ attr_reader :profiler
229
+
230
+ # Its workers start at once, each with its resources (a database
231
+ # server takes seconds), and stop at once. +sides+ are the versions as
232
+ # the run prepared them (Runner#prepare), by name.
233
+ def initialize(config, settings, sides, processes: settings.processes)
234
+ @dir = Lane.tmpdir
235
+ @resources = []
236
+ @workers = []
237
+ @lock = Mutex.new
238
+ @config = config
239
+ @settings = settings
240
+ @sides = sides
241
+ workers = %w[baseline candidate].product(Array.new(processes) { |i| i }) #: Array[[String, Integer]]
242
+ started = in_parallel(workers) { |side, index| spawn(side, index) }
243
+ @bases = started.first(processes)
244
+ @candidates = started.drop(processes)
245
+ @profiler = @candidates.first.profiler
246
+ @warm = settings.warm || (started.any?(&:yjit) ? YJIT_WARM : JIT_WARM)
247
+ check_builds
248
+ rescue Exception # rubocop:disable Lint/RescueException
249
+ stop
250
+ raise
251
+ end
252
+
253
+ # A directory for a lane's sockets, short as a Unix socket's path must
254
+ # be (see .short_tmp).
255
+ def self.tmpdir = Dir.mktmpdir("duel", short_tmp)
256
+
257
+ # The longest +tmpdir+ a lane's directory goes in: a resource's socket
258
+ # there (Resource#path) must stay within a Unix socket path's 104 bytes.
259
+ SHORT = 40
260
+
261
+ # Where lanes' directories go: +tmpdir+ (TMPDIR) if it's that short,
262
+ # else /tmp (macOS's TMPDIR is long).
263
+ def self.short_tmp(tmpdir = Dir.tmpdir) = (tmpdir.size <= SHORT) ? tmpdir : "/tmp"
264
+
265
+ # Opens a lane, yields it and always closes it.
266
+ def self.open(config, settings, sides, processes: settings.processes)
267
+ # @type var lane: Lane
268
+ lane = new(config, settings, sides, processes:)
269
+ yield lane
270
+ ensure
271
+ lane&.stop
272
+ end
273
+
274
+ def benchmarks = @candidates.first.benchmarks
275
+
276
+ # The Ruby each version runs on (RUBY_DESCRIPTION), by version.
277
+ def rubies = {"baseline" => @bases.first.description, "candidate" => @candidates.first.description}
278
+
279
+ # Stops its workers, then their resources: each worker's last first
280
+ # (one may need another), the workers' at once.
281
+ def stop
282
+ in_parallel(@workers) { |worker| worker.stop }
283
+ in_parallel(@resources.group_by(&:name).values) { |resources| resources.reverse_each(&:stop) }
284
+ FileUtils.rm_rf(@dir)
285
+ end
286
+
287
+ # Measures one benchmark on both versions, and returns its Result. (A
288
+ # process benchmark's workers have nothing to set up: its samples are
289
+ # processes of their own.)
290
+ def compare(name)
291
+ result = Result.new(name)
292
+ started = monotonic
293
+ if benchmarks.fetch(name).fetch("process")
294
+ measure_process(name, result)
295
+ else
296
+ set_up(name, result)
297
+ return result if result.skipped
298
+
299
+ begin
300
+ measure(name, result)
301
+ ensure
302
+ each_worker { |worker| worker.request("teardown", name) }
303
+ end
304
+ end
305
+ result.seconds = monotonic - started
306
+ result
307
+ end
308
+
309
+ # Profiles of the benchmark on each version (+n+ iterations a
310
+ # sample): {"baseline" =>, "candidate" =>}, or nil without StackProf.
311
+ def profile(name, n)
312
+ return unless @profiler
313
+
314
+ # @type var sides: Hash[String, WorkerProcess]?
315
+ sides = {"baseline" => @bases.first, "candidate" => @candidates.first}
316
+ sides.each_value { |worker| worker.request("setup", name) }
317
+ sides.transform_values { |worker| worker.request("profile", name, n: n, runs: PROFILE_SAMPLES) }
318
+ ensure
319
+ sides&.each_value { |worker| worker.request("teardown", name) }
320
+ end
321
+
322
+ private
323
+
324
+ # A worker of +side+, with its own resources, and its version's Ruby
325
+ # and environment.
326
+ def spawn(side, index)
327
+ label = "#{side}-#{index + 1}"
328
+ version = @sides.fetch(side)
329
+ env = version.env
330
+ @config.prepares.fetch(:worker).each_with_index do |block, i|
331
+ resource = Resource.new(File.join(@dir, "#{label}-#{i + 1}"), label, version.root, timeout: @settings.timeout,
332
+ cache: File.join(@config.root, "tmp", "duel", "cache"), ruby: version.ruby, env: version.env)
333
+ keep(@resources, resource)
334
+ env = Resource.environment(label, env, block.call(resource))
335
+ end
336
+ worker = WorkerProcess.new(label, version.root, @sides.fetch("candidate").root, @dir,
337
+ config_path: @settings.config_path, env: env, timeout: @settings.timeout, ruby: version.ruby)
338
+ keep(@workers, worker)
339
+ worker
340
+ end
341
+
342
+ # Fails if a version's workers loaded a native extension that isn't a
343
+ # build of its own: one in the other version's tree, or one where the
344
+ # other version loaded the same from its own tree (an installed gem's,
345
+ # say). A checkout without a build of its own (see Config#build)
346
+ # quietly loads whichever build it finds, and the comparison wouldn't
347
+ # be of its code. An installed gem's extension (in the directories
348
+ # gems are installed in, by the worker that loaded it) is in no
349
+ # version's tree, even in a bundle there (vendor/bundle, say, which
350
+ # both versions may load from).
351
+ def check_builds
352
+ workers = {"baseline" => @bases.first, "candidate" => @candidates.first}
353
+ workers.each do |side, worker|
354
+ other = (side == "baseline") ? "candidate" : "baseline"
355
+ theirs = workers.fetch(other).then { |w| w.extensions.select { |_, path| inside?(path, other, w) } }
356
+ worker.extensions.each do |feature, path|
357
+ next if inside?(path, side, worker) || !(inside?(path, other, worker) || theirs.key?(feature))
358
+
359
+ raise Failed, "the #{side} loaded #{path}, not a build of its own: #{feature} isn't built in " \
360
+ "#{@sides.fetch(side).root}. Set c.build in .duel.rb to build each version (by default, it's " \
361
+ "`bundle exec rake compile` for a project with ext/ and a Rakefile)"
362
+ end
363
+ end
364
+ end
365
+
366
+ # Whether +path+ (a real path, that +worker+ loaded) is in +side+'s
367
+ # tree, and not in an installed gem (in +worker+'s gem directories).
368
+ def inside?(path, side, worker)
369
+ root = @sides.fetch(side).root
370
+ path.start_with?(File.join(File.exist?(root) ? File.realpath(root) : root, "")) &&
371
+ worker.gem_dirs.none? { |dir| path.start_with?(dir) }
372
+ end
373
+
374
+ # Adds +item+ to +list+, from a thread of its own (see #in_parallel).
375
+ # mutant:disable -- CRuby's Array#<< is atomic anyway, and JRuby's isn't
376
+ def keep(list, item) = @lock.synchronize { list << item }
377
+
378
+ def each_worker(&) = (@bases + @candidates).each(&)
379
+
380
+ # Sets the benchmark up in every worker, the first candidate first (a
381
+ # benchmark that fails there is broken) and the first baseline next
382
+ # (one that fails there is new: skipped), then the rest at once.
383
+ def set_up(name, result)
384
+ @candidates.first.request("setup", name)
385
+ begin
386
+ @bases.first.request("setup", name)
387
+ rescue Failed => e
388
+ @candidates.first.request("teardown", name)
389
+ result.skipped = "not on the baseline (#{e.message.lines.first.chomp})"
390
+ return
391
+ end
392
+ in_parallel(@bases.drop(1) + @candidates.drop(1)) { |worker| worker.request("setup", name) }
393
+ end
394
+
395
+ # What's measured: ratio metrics' measures are in a sample's "timed",
396
+ # counted ones' in its "counted".
397
+ def metrics = @candidates.first.metrics
398
+
399
+ # Whether workers start (and stop) at once: not on TruffleRuby, whose
400
+ # Process.spawn, closing other descriptors (see WorkerProcess), fails
401
+ # when another thread closes one meanwhile.
402
+ PARALLEL = RUBY_ENGINE != "truffleruby"
403
+
404
+ # What the block returns for each item, each on a thread of its own
405
+ # (one after another, unless PARALLEL). Every one finishes before the
406
+ # first that failed, if any, raises.
407
+ def in_parallel(items, &)
408
+ return items.map(&) unless PARALLEL
409
+
410
+ threads = items.map do |item|
411
+ Thread.new do
412
+ Thread.current.report_on_exception = false
413
+ yield item
414
+ end
415
+ end
416
+ threads.each do |thread|
417
+ thread.join
418
+ rescue
419
+ nil # (raised by #value, once they're all done)
420
+ end
421
+ threads.map(&:value)
422
+ end
423
+
424
+ def measure(name, result)
425
+ benchmark = benchmarks.fetch(name)
426
+ only = benchmark.fetch("only")
427
+ if only == "count"
428
+ count(name, result.n = COUNTS_ONLY, result)
429
+ return
430
+ end
431
+
432
+ timings_only = !only.nil? # (the only other choice)
433
+ target = timings_only ? TARGET_NS * 10 : TARGET_NS
434
+ n = calibrate(name, target, 1)
435
+ warm(name, n)
436
+ n = calibrate(name, target, n) if @warm.positive? # it may have got faster
437
+ floors = Array.new(WARMUP) { |i| pair(name, n, i) }
438
+ # (It got faster still, a JIT got to it, say: sized again, unless
439
+ # what it allocates holds it there.)
440
+ if below_cpu_floor?(floors) && (sized = calibrate(name, target, n)) > n
441
+ n = sized
442
+ floors = Array.new(WARMUP) { |i| pair(name, n, i) }
443
+ end
444
+ result.n = n
445
+ count(name, n, result) unless timings_only
446
+ peak_memory(name, n, result) if benchmark.fetch("peak_memory")
447
+ judge(name, n, floors, result)
448
+ end
449
+
450
+ # A process benchmark's samples (see DSL#duel_process): each the
451
+ # fastest of a few fresh processes (see #pair and #process_runs), as
452
+ # its one iteration, paired as any other's, after PROCESS_WARMUP pairs
453
+ # (the first runs read files the disk's cache hasn't yet), and each
454
+ # version's least peak memory, where it can be told.
455
+ def measure_process(name, result)
456
+ @peaks = {"baseline" => [], "candidate" => []}
457
+ result.n = 1
458
+ floors = Array.new(PROCESS_WARMUP) { |i| pair(name, 1, i) }
459
+ # (A process benchmark's.)
460
+ process = benchmarks.fetch(name).fetch("process") #: Definition::process
461
+ judge(name, process.fetch("runs") || process_runs(floors), floors, result)
462
+ return if @peaks.values.flatten.include?(nil)
463
+
464
+ # (Each told, so each least is.)
465
+ least = @peaks.transform_values(&:min) #: Hash[String, Integer]
466
+ result.memory = least
467
+ end
468
+
469
+ # The runs a process benchmark's sample takes, by its warm-up's
470
+ # samples (+floors+, of a run each): as many as take
471
+ # PROCESS_SAMPLE_NS of CPU time on the slower version, up to
472
+ # PROCESS_RUNS, and one at least. A short process varies the most, and
473
+ # the fastest of a few is steadier, but a long one's pairs take long
474
+ # enough as they are.
475
+ def process_runs(floors)
476
+ longest = floors.flatten.map { |sample| sample.fetch("timed").fetch("cpu") }.max #: Integer
477
+ # (Any number, for one that took none at all.)
478
+ PROCESS_SAMPLE_NS.fdiv(longest).clamp(1, PROCESS_RUNS).floor
479
+ end
480
+
481
+ # Every worker runs the benchmark a few times, and for the warm-up's
482
+ # seconds (Settings#warm, or JIT_WARM, or YJIT_WARM), all at once:
483
+ # none of it is measured, so they needn't take turns.
484
+ def warm(name, n)
485
+ warm_until = monotonic + @warm
486
+ in_parallel(@bases + @candidates) do |worker|
487
+ 2.times { worker.sample(name, n) }
488
+ worker.sample(name, n) until monotonic > warm_until
489
+ end
490
+ end
491
+
492
+ # Takes pairs until every ratio metric worth judging (measured on
493
+ # both versions in the warm-up, and above its floors on either: one
494
+ # that rises from next to nothing is judged too) is decided, or
495
+ # MAX_PAIRS run out: each decided at the end of a round of STEP pairs.
496
+ # A process benchmark's pairs take a process each, so it's decided not
497
+ # slower (Result::CLEARED) after any pair too. That can only stop it
498
+ # before it would have been found slower, never make it slower: it's
499
+ # found slower only at the end of a round, as any benchmark is.
500
+ def judge(name, n, floors, result)
501
+ judged = metrics.select do |spec|
502
+ floors.flatten.all? { |sample| sample.fetch("timed").key?(spec.fetch("name")) } &&
503
+ floors.transpose.any? { |side| above_floors?(side.map { |sample| sample.fetch("timed") }, spec) }
504
+ end
505
+ ratios = judged.to_h { |spec| [spec.fetch("name"), []] }
506
+ # @type var raw: Hash[String, Array[[Metric::value, Metric::value]]]
507
+ raw = judged.to_h { |spec| [spec.fetch("name"), []] }
508
+ pending = judged.dup
509
+ every = benchmarks.fetch(name).fetch("process")
510
+ (0...MAX_PAIRS).each do |i|
511
+ break if pending.empty?
512
+
513
+ base, candidate = pair(name, n, i)
514
+ result.note(base, candidate)
515
+ ratios.each do |metric, list|
516
+ values = [base.fetch("timed").fetch(metric), candidate.fetch("timed").fetch(metric)] #: [Metric::value, Metric::value]
517
+ raw.fetch(metric) << values
518
+ list << values.last.fdiv([values.first, 1].max)
519
+ end
520
+ round = ((i + 1) % STEP).zero?
521
+ next unless round || every
522
+
523
+ pending.reject! { |spec| decided?(verdict(ratios.fetch(spec.fetch("name")), spec), round) }
524
+ end
525
+ judged.each do |spec|
526
+ list = ratios.fetch(spec.fetch("name"))
527
+ medians = raw.fetch(spec.fetch("name")).transpose.map { |values| Stats.median(values) }
528
+ result.timed[spec.fetch("name")] = {"median" => Stats.median(list), "limit" => limit(spec), "across" => spec.fetch("across"),
529
+ "unit" => spec.fetch("unit"), "plural" => spec.fetch("plural"),
530
+ "interval" => Stats.median_interval(list, @settings.confidence),
531
+ "pairs" => list.size, "verdict" => verdict(list, spec).to_s,
532
+ "baseline" => medians.fetch(0), "candidate" => medians.fetch(1)}
533
+ end
534
+ end
535
+
536
+ # Whether both versions' CPU time in +floors+ is below its floor: then
537
+ # it wouldn't be judged at all.
538
+ def below_cpu_floor?(floors)
539
+ cpu = metrics.find { |spec| spec.fetch("name") == "cpu" } #: Metric::spec
540
+ floors.transpose.none? { |side| above_floors?(side.map { |sample| sample.fetch("timed") }, cpu) }
541
+ end
542
+
543
+ # Whether +samples+ (one version's timed measures) are above +spec+'s
544
+ # floors (the median): its own, and its share of CPU time.
545
+ def above_floors?(samples, spec)
546
+ values = samples.map { |sample| sample.fetch(spec.fetch("name")) }
547
+ cpu = samples.map { |sample| sample.fetch("cpu") }
548
+ median = Stats.median(values)
549
+ median >= spec.fetch("floor") && median * spec.fetch("unit_ns") >= spec.fetch("cpu_floor") * Stats.median(cpu)
550
+ end
551
+
552
+ def limit(spec) = spec.fetch("limit") || @settings.limit
553
+
554
+ # The verdicts that decide a measure at the end of a round (see
555
+ # #judge), and after any other pair: only those that clear it.
556
+ DECISIVE = {true => [:slower, :faster, :same], false => [:faster, :same]}.freeze #: Hash[bool, Array[Stats::verdict]]
557
+
558
+ # Whether +verdict+ decides a measure, at the end of a +round+ or not.
559
+ def decided?(verdict, round) = DECISIVE.fetch(round).include?(verdict)
560
+
561
+ def verdict(ratios, spec) = Stats.verdict(ratios, limit: limit(spec), confidence: @settings.confidence)
562
+
563
+ # The iterations per sample (from +n+ up) whose fastest run takes
564
+ # about +target+ on the first candidate, by its largest timed metric
565
+ # that sizes samples (CPU time does), found while warming up (twice in
566
+ # a row: a cold run may look like enough), and that allocate no more
567
+ # than HEAP_CAP a run.
568
+ def calibrate(name, target, n)
569
+ units = metrics.select { |spec| spec.fetch("sizes_samples") }.to_h { |spec| [spec.fetch("name"), spec.fetch("unit_ns")] }
570
+ reached = 0
571
+ until reached == 2
572
+ sample = pair(name, n, 0, runs: 3).last
573
+ # (CPU time sizes samples, and is always measured.)
574
+ timed = sample.fetch("timed")
575
+ work = units.filter_map { |metric, unit| timed[metric]&.*(unit) }.max #: Metric::value
576
+ most = most_iterations(n, sample.fetch("heap"))
577
+ if most <= n
578
+ # (Finite, when it's at most n.)
579
+ return most #: Integer
580
+ end
581
+
582
+ reached = (work >= target) ? reached + 1 : 0
583
+ next unless reached.zero?
584
+
585
+ # (An Integer: most is finite, or more.)
586
+ n = [(n * (target * 1.2 / work).clamp(2, 100)).ceil, most].min #: Integer
587
+ end
588
+ n
589
+ end
590
+
591
+ # The most iterations whose allocations fit HEAP_CAP, by +heap+ bytes
592
+ # over +n+ (at least one, and any number when it allocated nothing).
593
+ def most_iterations(n, heap)
594
+ return Float::INFINITY unless heap.positive?
595
+
596
+ [n * HEAP_CAP / heap, 1].max
597
+ end
598
+
599
+ # The +index+th pair: a sample (of +n+ iterations) from a baseline
600
+ # worker and from a candidate worker, each pair of workers in turn,
601
+ # baseline first on even pairs. A process benchmark's sample is the
602
+ # fastest of +n+ runs (see #measure_process), the two versions' taking
603
+ # turns: so interference, which only ever adds time, is mostly left
604
+ # out, and weighs on both alike.
605
+ def pair(name, n, index, runs: RUNS, count: false)
606
+ k = @bases.size
607
+ base = @bases.fetch(index % k)
608
+ candidate = @candidates.fetch((index + (index / k)) % k)
609
+ order = index.even? ? [base, candidate] : [candidate, base]
610
+ process = benchmarks.fetch(name).fetch("process")
611
+ replies = if process
612
+ # (Each run of one version's next to one of the other's.)
613
+ each_run = Array.new(n) { order.map { |worker| worker.run(process) } }
614
+ order.zip(each_run.transpose).to_h do |worker, samples|
615
+ # (Never nil: each worker in order has its runs.)
616
+ ran = samples #: Array[ProcessSample::sampled]
617
+ [worker, ProcessSample.fastest(ran)]
618
+ end
619
+ else
620
+ order.to_h { |worker| [worker, worker.sample(name, n, runs: runs, count: count)] }
621
+ end
622
+ if process
623
+ @peaks.fetch("baseline") << replies.fetch(base).fetch("peak")
624
+ @peaks.fetch("candidate") << replies.fetch(candidate).fetch("peak")
625
+ end
626
+ [replies.fetch(base), replies.fetch(candidate)]
627
+ end
628
+
629
+ # Each counted measure's fewest, on each version, over COUNTS pairs of
630
+ # samples, and what its metric said of those samples.
631
+ def count(name, n, result)
632
+ samples = Array.new(COUNTS) { |i| pair(name, n, i, runs: 1, count: true) }
633
+ metrics.each do |spec|
634
+ metric = spec.fetch("name")
635
+ base, candidate = samples.transpose.map do |side|
636
+ side.select { |sample| sample.fetch("counted").key?(metric) }.min_by { |sample| sample.fetch("counted").fetch(metric) }
637
+ end
638
+ next unless base && candidate
639
+
640
+ result.counted[metric] = {"baseline" => base.fetch("counted").fetch(metric), "candidate" => candidate.fetch("counted").fetch(metric),
641
+ "n" => n, "tolerance" => spec.fetch("tolerance"), "allowance" => spec.fetch("allowance"),
642
+ "unit" => spec.fetch("unit"), "plural" => spec.fetch("plural")}
643
+ result.note(base, candidate, metric)
644
+ end
645
+ end
646
+
647
+ # Each version's least peak memory over MEMORY_SAMPLES fresh workers
648
+ # (a long-lived one has room left over from earlier work), turn about.
649
+ # Fresh means no trial run either: the allocator keeps what that freed,
650
+ # and the measured run would reuse it rather than grow.
651
+ def peak_memory(name, n, result)
652
+ return unless @candidates.first.memory
653
+
654
+ # @type var peaks: Hash[String, Array[Integer]]
655
+ peaks = {"baseline" => [], "candidate" => []}
656
+ MEMORY_SAMPLES.times do |i|
657
+ sides = i.even? ? %w[baseline candidate] : %w[candidate baseline]
658
+ sides.each do |side|
659
+ peaks.fetch(side) << fresh(side) do |worker|
660
+ worker.request("setup", name, trial: false)
661
+ worker.request("memory", name, n: n).fetch("peak")
662
+ end
663
+ end
664
+ end
665
+ result.memory = peaks.transform_values(&:min)
666
+ end
667
+
668
+ # Yields a worker of +side+ of its own, set up for nothing yet, then
669
+ # stops it and its resources.
670
+ def fresh(side)
671
+ # @type var workers: Integer
672
+ # @type var resources: Integer
673
+ workers = @workers.size
674
+ resources = @resources.size
675
+ worker = spawn(side, @workers.size)
676
+ yield worker
677
+ ensure
678
+ @workers.pop(@workers.size - workers).each(&:stop)
679
+ @resources.pop(@resources.size - resources).reverse_each(&:stop)
680
+ end
681
+
682
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
683
+ end
684
+
685
+ # What one comparison of a benchmark found (Testing::Comparison#results
686
+ # has them): its #name, whether it #passed?, the measures it #regressed
687
+ # on, each #timed measure's verdict and each #counted one's counts,
688
+ # what metrics said of it (#details), its peak #memory, its iterations
689
+ # a sample (#n), the #seconds it took, why it was #skipped or its
690
+ # #error, and its #confirmations (the measurements that confirmed it
691
+ # was worse, not those too close to call: see #confirm). Its writers,
692
+ # and the rest, are @api private.
693
+ class Result
694
+ # Peak memory grows by more than MEMORY_RATIO, and by MEMORY_FLOOR
695
+ # bytes at least, before it's worse (it's measured as a whole, with the
696
+ # garbage collector working, so it isn't exact).
697
+ # @api private
698
+ MEMORY_RATIO = 1.1
699
+
700
+ # @api private
701
+ MEMORY_FLOOR = 1 << 20
702
+
703
+ # What a sample says when no metric said anything (and what a measure
704
+ # it said nothing of did).
705
+ # @api private
706
+ NOTHING_SAID = {} #: Hash[String, String]
707
+
708
+ # The verdicts that say a timing isn't slower.
709
+ # @api private
710
+ CLEARED = %w[same faster].freeze
711
+
712
+ attr_reader :name, :timed, :counted, :details, :confirmations, :n, :seconds, :skipped, :error, :memory
713
+
714
+ # Where a regression's time went (see Profile).
715
+ # @api private
716
+ attr_reader :profile
717
+
718
+ # The measurements again that were too close to call (see #confirm).
719
+ # @api private
720
+ attr_reader :undecided
721
+
722
+ # @api private
723
+ attr_writer :n, :seconds, :skipped, :error, :memory, :profile
724
+
725
+ # @api private
726
+ def initialize(name)
727
+ @name = name
728
+ @timed = {}
729
+ @counted = {}
730
+ @details = {}
731
+ @confirmations = []
732
+ @undecided = []
733
+ end
734
+
735
+ # Keeps what metrics said of a pair of samples (Metric#details): each
736
+ # measure's latest, by version (only +measure+'s, if given).
737
+ # @api private
738
+ def note(base, candidate, measure = nil)
739
+ {"baseline" => base, "candidate" => candidate}.each do |side, sample|
740
+ said = sample["details"] || NOTHING_SAID
741
+ said = said.slice(measure) if measure
742
+ said.each { |name, text| (@details[name] ||= {}).store(side, text) }
743
+ end
744
+ end
745
+
746
+ # The metrics on which the candidate did worse: a timed one slower, or
747
+ # a counted one up by more than its tolerance per iteration and its
748
+ # allowance per sample.
749
+ # @api private
750
+ def worse
751
+ more = @counted.select do |_, c|
752
+ !Metric.within?(c.fetch("candidate") - c.fetch("baseline"), c.fetch("n"), tolerance: c.fetch("tolerance"),
753
+ allowance: c.fetch("allowance"))
754
+ end
755
+ slower + more.keys + (more_memory? ? ["peak_memory"] : [])
756
+ end
757
+
758
+ # The timed metrics on which the candidate was slower.
759
+ # @api private
760
+ def slower = @timed.filter_map { |metric, t| metric if t.fetch("verdict") == "slower" }
761
+
762
+ # Whether its timings say slower: one is, and none says it isn't
763
+ # (same, or faster).
764
+ # @api private
765
+ def slower_in_time? = slower.any? && @timed.none? { |_, t| CLEARED.include?(t.fetch("verdict")) }
766
+
767
+ # Whether it's to be measured again (see Runner#confirm): it looks
768
+ # worse in every measurement so far, and there are fewer confirmations
769
+ # than it takes to fail (Runner::CONFIRM, or one more where it's
770
+ # #mixed?).
771
+ # @api private
772
+ def confirming?
773
+ return false if regressed.empty?
774
+
775
+ @confirmations.size < (mixed? ? Runner::CONFIRM + 1 : Runner::CONFIRM)
776
+ end
777
+
778
+ # Keeps +again+, a measurement of it again from scratch (see
779
+ # Runner#confirm), as a confirmation, or sets it aside where it's too
780
+ # close to call, up to Runner::CONFIRM times: every timing is still
781
+ # undecided there (none slower, none the same or faster), and each
782
+ # measure it regressed on so far is one of them. One that can't tell
783
+ # neither confirms it nor clears it, so it's measured again instead.
784
+ # Returns whether +again+ was set aside.
785
+ # @api private
786
+ def confirm(again)
787
+ timed = again.timed
788
+ close = timed.all? { |_, t| t.fetch("verdict") == "undecided" } && regressed.all? { |metric| timed.key?(metric) } &&
789
+ @undecided.size < Runner::CONFIRM
790
+ (close ? @undecided : @confirmations) << again
791
+ close
792
+ end
793
+
794
+ # Whether its peak memory grew by too much (see MEMORY_RATIO).
795
+ # @api private
796
+ def more_memory?
797
+ memory = @memory
798
+ return false unless memory
799
+
800
+ base = memory.fetch("baseline")
801
+ memory.fetch("candidate") - base > [MEMORY_FLOOR, base * (MEMORY_RATIO - 1)].max
802
+ end
803
+
804
+ # Worse on a metric in the first measurement and in every confirmation.
805
+ # Or, where no one metric is, slower in time in all of them (see
806
+ # #mixed?): the timed metrics slower in any.
807
+ def regressed
808
+ return [] if @error || @skipped
809
+
810
+ agreed = measurements.map(&:worse).reduce(:&)
811
+ return agreed unless agreed.empty? && measurements.all?(&:slower_in_time?)
812
+
813
+ measurements.flat_map(&:slower).uniq
814
+ end
815
+
816
+ # Whether it regressed in time on one timed metric in some
817
+ # measurements and on another in the rest (CPU time in one,
818
+ # instructions in the next, say), slower in every one with none saying
819
+ # it isn't: that takes one measurement more than one metric does.
820
+ # @api private
821
+ def mixed? = measurements.map(&:worse).reduce(:&).empty? && !regressed.empty?
822
+
823
+ # Whether it regressed on nothing, and didn't fail.
824
+ def passed? = regressed.empty? && !@error
825
+
826
+ # Whether a timed measure's verdict was too close to the limit to settle.
827
+ # @api private
828
+ def unsettled? = @timed.any? { |_, t| t.fetch("verdict") == "undecided" }
829
+
830
+ private
831
+
832
+ # The first measurement and its confirmations.
833
+ def measurements = [self, *@confirmations]
834
+ end
835
+
836
+ # Measures every benchmark, confirms what looks worse, and reports.
837
+ # @api private
838
+ class Runner
839
+ # Measurements of each suspect, from scratch, before it fails.
840
+ CONFIRM = 3
841
+
842
+ # +number+ of +noun+, in its plural for any number but one ("1
843
+ # benchmark", "2 benchmarks").
844
+ def self.count(number, noun, plural = "#{noun}s") = "#{number} #{(number == 1) ? noun : plural}"
845
+
846
+ # Across the board (see Across): {metric => verdict}, "confirmed" in
847
+ # those slower in a second measurement too. Set by #run.
848
+ attr_reader :across
849
+
850
+ # The Ruby each version ran on (RUBY_DESCRIPTION), by version, and the
851
+ # seconds the run took. Set by #run.
852
+ attr_reader :rubies, :seconds
853
+
854
+ def initialize(config, settings, base_root, out: $stdout)
855
+ @config = config
856
+ @settings = settings
857
+ @base_root = base_root
858
+ @out = out
859
+ @prepared = []
860
+ end
861
+
862
+ # Returns the Results.
863
+ def run
864
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
865
+ @dir = Lane.tmpdir
866
+ begin
867
+ compare_all(started)
868
+ ensure
869
+ @prepared.reverse_each(&:stop)
870
+ FileUtils.rm_rf(@dir)
871
+ end
872
+ end
873
+
874
+ # Whether nothing got slower: no benchmark, nor all of them together,
875
+ # and none failed.
876
+ def passed?(results) = results.all?(&:passed?) && regressed_across.empty?
877
+
878
+ def regressed_across = @across.select { |_, v| v["confirmed"] }.keys
879
+
880
+ # The plugins the configuration used (Config#plugins), by name.
881
+ def plugins = @config.plugins
882
+
883
+ # What a regression's report says of it, line by line: each worse
884
+ # measure an iteration, what its metric said, and where the extra CPU
885
+ # went (see Profile).
886
+ def explained(result) = explanation(result) + profile_lines(result.profile)
887
+
888
+ private
889
+
890
+ # What #run does, with its versions prepared in its directory.
891
+ def compare_all(started)
892
+ @sides = prepare
893
+ names, results = with_lanes do |lanes|
894
+ found = lanes.first.benchmarks.keys.grep(@settings.filter)
895
+ @benchmarks = lanes.first.benchmarks
896
+ @out.puts "Comparing #{Runner.count(found.size, "benchmark")}, #{@settings.jobs} at a time: baseline #{@base_root} " \
897
+ "against candidate #{File.expand_path(@settings.candidate_dir || @config.root)} (slower: a " \
898
+ "#{(@settings.confidence * 100).round(1)}% interval above #{@settings.limit})"
899
+ rubies = @rubies = lanes.first.rubies
900
+ rubies.each { |side, ruby| @out.puts " #{side} on #{ruby}" } if rubies.values.uniq.size > 1
901
+ @out.puts " with plugins #{plugins.join(", ")}" unless plugins.empty?
902
+ [found, measure_all(lanes, found)]
903
+ end
904
+ confirm(results.select { |r| r.worse.any? })
905
+ @across = judge_across(results, names)
906
+ profile(results.select { |r| (r.regressed & r.timed.keys).any? && !@benchmarks.fetch(r.name).fetch("process") })
907
+ report(results, @seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
908
+ results
909
+ end
910
+
911
+ # Each version, prepared once for the run, before any of its workers
912
+ # start: its Ruby found (Config#ruby, or --ruby), and its
913
+ # Config#prepare(:version) blocks run, each given a Resource (named for
914
+ # the version) whose processes stop when the run ends.
915
+ def prepare
916
+ choice = @settings.ruby || @config.ruby
917
+ Side.unprepared(@config, @settings, @base_root).transform_values do |side|
918
+ side.ruby = Rubies.find(choice, side.root, side.name)
919
+ side.env = Rubies.environment(side.ruby)
920
+ @config.prepares.fetch(:version).each_with_index do |block, i|
921
+ resource = Resource.new(File.join(@dir, "#{side.name}-#{i + 1}"), side.name, side.root, timeout: @settings.timeout,
922
+ cache: File.join(@config.root, "tmp", "duel", "cache"), ruby: side.ruby, env: side.env)
923
+ @prepared << resource
924
+ side.env = Resource.environment(side.name, side.env, block.call(resource))
925
+ end
926
+ side
927
+ end
928
+ end
929
+
930
+ def with_lanes
931
+ # @type var lanes: Array[Lane]
932
+ lanes = []
933
+ @settings.jobs.times { lanes << open_lane }
934
+ yield lanes
935
+ ensure
936
+ lanes.each(&:stop)
937
+ end
938
+
939
+ # The verdicts across the board. Those slower are measured again,
940
+ # every benchmark, in fresh processes, and are confirmed if they're
941
+ # still slower.
942
+ def judge_across(results, names)
943
+ verdicts = Across.new(results, confidence: @settings.confidence).verdicts
944
+ slower = verdicts.select { |_, v| v.fetch("verdict") == "slower" }.keys
945
+ return verdicts if slower.empty?
946
+
947
+ @out.puts "\nMeasuring every benchmark again, in fresh processes: slower across the board in #{slower.join(", ")}?"
948
+ again = Across.new(with_lanes { |lanes| measure_all(lanes, names) }, confidence: @settings.confidence).verdicts
949
+ slower.each { |metric| verdicts.fetch(metric)["confirmed"] = again.dig(metric, "verdict") == "slower" }
950
+ verdicts
951
+ end
952
+
953
+ def open_lane = Lane.new(@config, @settings, @sides)
954
+
955
+ def measure_all(lanes, names)
956
+ queue = Queue.new(names.each_with_index)
957
+ queue.close
958
+ # @type var results: Array[Result]
959
+ results = []
960
+ lanes.each_index.map do |i|
961
+ Thread.new do
962
+ while (name, index = queue.deq)
963
+ result = results[index] = attempt(lanes.fetch(i), name)
964
+ @out.print "#{line(result)}\n" # (in one write: other threads write too)
965
+ next unless result.error
966
+
967
+ # Its workers may be gone: the next benchmark gets new ones.
968
+ lanes.fetch(i).stop
969
+ lanes[i] = open_lane
970
+ end
971
+ end
972
+ end.each(&:join)
973
+ results
974
+ end
975
+
976
+ def attempt(lane, name)
977
+ lane.compare(name)
978
+ rescue Failed, SystemCallError, IOError => e
979
+ Result.new(name).tap { |r| r.error = "#{e.class}: #{e}" }
980
+ end
981
+
982
+ # Measures each suspect again, from scratch, in fresh processes, until
983
+ # one measurement clears it or CONFIRM agree (and once more for each
984
+ # too close to call, up to CONFIRM more: see Result#confirm).
985
+ def confirm(suspects)
986
+ return if suspects.empty?
987
+
988
+ @out.puts "\nMeasuring #{Runner.count(suspects.size, "benchmark")} that looked worse again, in fresh processes, up to " \
989
+ "#{CONFIRM} times each (#{CONFIRM + 1} if what's slower differs, and once more for each too close to call):"
990
+ suspects.each do |result|
991
+ while result.confirming?
992
+ again = Lane.open(@config, @settings, @sides) { |lane| attempt(lane, result.name) }
993
+ @out.puts line(again) + (result.confirm(again) ? " (too close to call: once more)" : "")
994
+ end
995
+ end
996
+ end
997
+
998
+ # Profiles each version on every benchmark slower in time, and keeps
999
+ # the frames whose share of the CPU grew the most (see Profile).
1000
+ def profile(regressed)
1001
+ regressed.each do |result|
1002
+ # (Measured, so with an n, since it regressed.)
1003
+ n = result.n #: Integer
1004
+ profiles = Lane.open(@config, @settings, @sides, processes: 1) { |lane| lane.profile(result.name, n) }
1005
+ result.profile = profiles ? Profile.new(profiles).to_h : {"unavailable" => Profile::UNAVAILABLE}
1006
+ rescue Failed, SystemCallError, IOError => e
1007
+ result.profile = {"unavailable" => "#{e.class}: #{e}"}
1008
+ end
1009
+ end
1010
+
1011
+ def report(results, seconds)
1012
+ regressed = results.reject { |r| r.regressed.empty? }
1013
+ errors = results.select(&:error)
1014
+ @out.puts
1015
+ @across.each do |metric, v|
1016
+ low, high = v.fetch("interval")
1017
+ next unless low # (too few benchmarks to say)
1018
+
1019
+ @out.puts format("across the board: %s %.3f [%.3f, %.3f] %s (limit %.4f, %d benchmarks)", metric, v.fetch("median"),
1020
+ low, high, v.fetch("verdict"), v.fetch("limit"), v.fetch("benchmarks"))
1021
+ end
1022
+ regressed_across.each { |metric| @out.puts "REGRESSION across the board: #{metric} slower in both measurements" }
1023
+ errors.each { |r| @out.puts "ERROR #{r.name}: #{r.error}" }
1024
+ regressed.each do |r|
1025
+ @out.puts "REGRESSION #{r.name}: " +
1026
+ (r.mixed? ? "slower in every measurement, by one of #{r.regressed.join(", ")}" : "worse #{r.regressed.join(", ")} in every measurement")
1027
+ explained(r).each { |line| @out.puts " #{line}" }
1028
+ end
1029
+ # (Each benchmark once: a regression's timing may have been unsettled too.)
1030
+ unsettled = (results - regressed).count(&:unsettled?)
1031
+ @out.puts format("%s in %.0fs: %d regressed, %s, %d with a timing too close to the limit to settle",
1032
+ Runner.count(results.size, "benchmark"), seconds, regressed.size, Runner.count(errors.size, "error"), unsettled)
1033
+ end
1034
+
1035
+ def line(result)
1036
+ return format(" %-48s skipped: %s", result.name, result.skipped) if result.skipped
1037
+ return format(" %-48s error", result.name) if result.error
1038
+
1039
+ timed = result.timed.map do |metric, t|
1040
+ low, high = t.fetch("interval")
1041
+ format("%s %.3f [%.3f, %.3f] %s", metric, t.fetch("median"), low, high, t.fetch("verdict"))
1042
+ end
1043
+ # (An iteration's, as the explanation's are.)
1044
+ counted = result.counted.map do |metric, c|
1045
+ n = c.fetch("n") # (at least one)
1046
+ base = c.fetch("baseline").fdiv(n)
1047
+ candidate = c.fetch("candidate").fdiv(n)
1048
+ # (No unit its name ends in: "retained_objects 2", not "2 objects".)
1049
+ named = c.fetch("plural")&.then { |plural| metric.end_with?(plural.tr(" ", "_")) }
1050
+ [format("%s %.4g%s", metric, base, (format("→%.4g", candidate) unless base == candidate)),
1051
+ (unit(candidate, c) unless named)].compact.join(" ")
1052
+ end
1053
+ memory = result.memory&.then { |m| ["peak_memory #{mb(m.fetch("baseline"))}→#{mb(m.fetch("candidate"))}"] }
1054
+ format("%s %-48s %5.1fs n=%-6d %s", result.worse.empty? ? " " : "!", result.name, result.seconds, result.n,
1055
+ (timed + counted + memory.to_a).join(" "))
1056
+ end
1057
+
1058
+ def mb(bytes) = format("%.1fMB", bytes / 1_048_576.0)
1059
+
1060
+ # The unit of +measured+ (a result's measure), for +number+ of it: the
1061
+ # singular for one, the plural for any other.
1062
+ def unit(number, measured) = (number == 1) ? measured.fetch("unit") : measured.fetch("plural")
1063
+
1064
+ # What each measure that's worse measured, an iteration, on each
1065
+ # version, and what its metric said of it (Metric#details).
1066
+ def explanation(result)
1067
+ # (Measured, so with an n, since it regressed.)
1068
+ n = result.n #: Integer
1069
+ result.regressed.flat_map do |metric|
1070
+ timed = result.timed[metric]
1071
+ counted = result.counted[metric]
1072
+ measured = if timed
1073
+ [format("%s: %s → %s an iteration (%.2fx)", metric, amount(timed.fetch("baseline"), n, timed),
1074
+ amount(timed.fetch("candidate"), n, timed), timed.fetch("median"))]
1075
+ elsif counted
1076
+ ["#{metric}: #{amount(counted.fetch("baseline"), n, counted)} → #{amount(counted.fetch("candidate"), n, counted)} an iteration"]
1077
+ else
1078
+ # (Peak memory: on its line already.)
1079
+ [] #: Array[String]
1080
+ end
1081
+ said = result.details[metric] || Result::NOTHING_SAID
1082
+ # (The candidate's first.)
1083
+ measured + said.sort.reverse.map { |side, text| "#{metric} (#{side}): #{text}" }
1084
+ end
1085
+ end
1086
+
1087
+ # +value+ (a sample's, of +n+ iterations) an iteration, in the unit of
1088
+ # +measured+ (a result's timed or counted measure): a time unit as
1089
+ # time, in the largest unit it makes at least one of, and any other after
1090
+ # the number.
1091
+ def amount(value, n, measured)
1092
+ each = value.fdiv([n, 1].max)
1093
+ ns = Metric::TIME_UNITS[measured.fetch("unit")]
1094
+ return [format("%.4g", each), unit(each, measured)].compact.join(" ") unless ns
1095
+
1096
+ time = each * ns
1097
+ shown, size = Metric::TIME_UNITS.reverse_each.find { |_, unit_ns| time >= unit_ns } || ["ns", 1]
1098
+ format("%.4g%s", time / size, shown)
1099
+ end
1100
+
1101
+ def profile_lines(profile)
1102
+ return [] unless profile
1103
+ return ["profile: #{profile.fetch("unavailable")}"] if profile.key?("unavailable")
1104
+
1105
+ # (A Profile's: Steep doesn't narrow on key?.)
1106
+ grew = profile.fetch("grew") #: Array[Profile::growth]
1107
+ gc = profile.fetch("gc") #: Float
1108
+ lines = ["where the extra CPU went (as a share of the baseline's: in each frame itself, and with what it called):",
1109
+ *grew.map { |frame, own, total| format(" %+6.1f%% %+6.1f%% %s", own * 100, total * 100, frame) }]
1110
+ lines << format(" %+6.1f%% in garbage collection, apart from the code", gc * 100) unless gc.zero?
1111
+ lines
1112
+ end
1113
+ end
1114
+ end