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/runner.rb
ADDED
|
@@ -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
|