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/testing.rb
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "../duel"
|
|
7
|
+
require_relative "runner"
|
|
8
|
+
require_relative "worker"
|
|
9
|
+
|
|
10
|
+
module Duel
|
|
11
|
+
# What a plugin's tests need: its prepare blocks run as a worker's
|
|
12
|
+
# would be, its metrics measuring a block, and whole comparisons of two
|
|
13
|
+
# small projects.
|
|
14
|
+
#
|
|
15
|
+
# require "duel/testing"
|
|
16
|
+
#
|
|
17
|
+
# config = Duel::Config.new
|
|
18
|
+
# config.use :my_plugin
|
|
19
|
+
# Duel::Testing.prepare(config) do |env|
|
|
20
|
+
# Duel::Testing.measure(config) { MyClient.new(env.fetch("MY_URL")).get }
|
|
21
|
+
# # => {"my_requests" => 1, ...}
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# And stand-ins for what Duel gives a metric (#sample, #reading), to test
|
|
25
|
+
# or benchmark what it says of a sample (#details).
|
|
26
|
+
module Testing
|
|
27
|
+
# What Testing.compare found: a Result for each benchmark (#passed?,
|
|
28
|
+
# #regressed, #timed, #counted), what the run printed, and whether it
|
|
29
|
+
# passed, as `duel` would have (no benchmark worse, and nothing worse
|
|
30
|
+
# across the board): #results, #output and #passed?. Its +passed+, and
|
|
31
|
+
# making one, are @api private.
|
|
32
|
+
Comparison = Data.define(:results, :output, :passed)
|
|
33
|
+
|
|
34
|
+
class Comparison
|
|
35
|
+
def passed? = passed
|
|
36
|
+
|
|
37
|
+
# The benchmark's Result.
|
|
38
|
+
def [](name) = results.find { |result| result.name == name }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Where prepare blocks' builds (Resource#cached) are kept, for every
|
|
42
|
+
# test of this process.
|
|
43
|
+
# @api private
|
|
44
|
+
CACHE = Dir.mktmpdir("duel-cache")
|
|
45
|
+
at_exit { FileUtils.rm_rf(CACHE) }
|
|
46
|
+
|
|
47
|
+
# (Its functions, called on it: Duel::Testing.measure.)
|
|
48
|
+
extend self
|
|
49
|
+
|
|
50
|
+
# Runs +config+'s prepare blocks as they'd prepare a worker (+name+,
|
|
51
|
+
# running the version at +root+): its version's (Config#prepare
|
|
52
|
+
# (:version), named for the version: "candidate"), then the worker's,
|
|
53
|
+
# with their builds (Resource#cached) in +cache+ (by default, one for
|
|
54
|
+
# every test of this process). Sets the environment they give while
|
|
55
|
+
# the block runs, yields it, and stops what they started after.
|
|
56
|
+
def prepare(config, name: "candidate-1", root: config.root, cache: CACHE)
|
|
57
|
+
dir = Lane.tmpdir
|
|
58
|
+
started = [] #: Array[Resource]
|
|
59
|
+
begin
|
|
60
|
+
none = {} #: Hash[String, String?]
|
|
61
|
+
version = name.sub(/-\d+\z/, "")
|
|
62
|
+
blocks = config.prepares.fetch(:version).map { |block| [version, block] } +
|
|
63
|
+
config.prepares.fetch(:worker).map { |block| [name, block] } #: Array[[String, Config::prepare]]
|
|
64
|
+
env = blocks.each_with_index.reduce(none) do |vars, ((label, block), i)|
|
|
65
|
+
resource = Resource.new(File.join(dir, "#{label}-#{i + 1}"), label, root, timeout: Settings.defaults.timeout, cache: cache)
|
|
66
|
+
started << resource
|
|
67
|
+
Resource.environment(label, vars, block.call(resource))
|
|
68
|
+
end
|
|
69
|
+
with_env(env) { yield env }
|
|
70
|
+
ensure
|
|
71
|
+
started.reverse_each(&:stop)
|
|
72
|
+
FileUtils.rm_rf(dir)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs the block with +env+ set, and the environment as it was after.
|
|
77
|
+
# @api private
|
|
78
|
+
def with_env(env)
|
|
79
|
+
saved = env.to_h { |var, _| [var, ENV.fetch(var, nil)] }
|
|
80
|
+
begin
|
|
81
|
+
ENV.update(env)
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
ENV.update(saved)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# What #measure measured, by name, and what metrics said of it
|
|
89
|
+
# (#details, by measure: see Metric#details).
|
|
90
|
+
class Measurement < Hash
|
|
91
|
+
attr_reader :details
|
|
92
|
+
|
|
93
|
+
# @api private
|
|
94
|
+
attr_writer :details
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# What +config+'s metrics (a plugin's: not Duel's own) measure of the
|
|
98
|
+
# block, as one counted sample of +n+ iterations of a benchmark would,
|
|
99
|
+
# with +only:+ (:timing, say) and its own +hooks+ (around_iteration:,
|
|
100
|
+
# say), as DSL#duel takes them: {name => measured}, with what they
|
|
101
|
+
# said of it (Measurement#details). It runs with the configuration's
|
|
102
|
+
# hooks too, as a worker runs a benchmark: its setup hooks, then the
|
|
103
|
+
# sample (its before_sample hooks, then the block, measured
|
|
104
|
+
# (Duel.measuring?), then its after_iterations hooks, measured with
|
|
105
|
+
# it), then its teardown hooks. The block gets the benchmark's
|
|
106
|
+
# Context (with its #untimed), and keeps its own self.
|
|
107
|
+
#
|
|
108
|
+
# The block runs once, but with around_iteration hooks (the
|
|
109
|
+
# configuration's, or its own), it's one iteration, run +n+ times,
|
|
110
|
+
# each inside the hooks, as a worker runs a benchmark with them: what
|
|
111
|
+
# they do isn't measured, and what they add to metrics costly to read
|
|
112
|
+
# (Metric.costly?) is learned first, and taken away.
|
|
113
|
+
#
|
|
114
|
+
# Inside #in_worker, it's that worker's. Outside, it's a worker of its
|
|
115
|
+
# own, ended after (its metrics closed, and what its blocks gave
|
|
116
|
+
# at_stop run).
|
|
117
|
+
def measure(config, n: 1, only: nil, **hooks, &block)
|
|
118
|
+
return in_worker(config) { measure(config, n: n, only: only, **hooks, &block) } unless STOPS.key?(config)
|
|
119
|
+
|
|
120
|
+
# @type var body: Duel::body
|
|
121
|
+
body = if hooks.key?(:around_iteration) || config.hooks.fetch(:around_iteration).any?
|
|
122
|
+
-> { block.call(self) } #: ^() [self: Context] -> void
|
|
123
|
+
else
|
|
124
|
+
->(_n) { block.call(self) } #: ^(Integer) [self: Context] -> void
|
|
125
|
+
end
|
|
126
|
+
benchmark = Definition.new("test", only, false, __FILE__, body, DSL.hooks("test", hooks, body))
|
|
127
|
+
harness = Harness.new(benchmark, config.hooks)
|
|
128
|
+
metrics = metrics(config)
|
|
129
|
+
sample = harness.sample(metrics, n: n)
|
|
130
|
+
harness.prepare
|
|
131
|
+
begin
|
|
132
|
+
leak = harness.leak(metrics)
|
|
133
|
+
harness.run(n, sample)
|
|
134
|
+
sample.less(leak)
|
|
135
|
+
ensure
|
|
136
|
+
harness.finish
|
|
137
|
+
end
|
|
138
|
+
measured = Measurement[sample.timed.merge(sample.counted)]
|
|
139
|
+
measured.details = sample.details
|
|
140
|
+
measured
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# +config+'s metrics, made once (its in_worker blocks run first), as a
|
|
144
|
+
# worker makes them, and made again if its measures' settings changed
|
|
145
|
+
# since (Config#measure), so a change isn't left to mean nothing.
|
|
146
|
+
def metrics(config)
|
|
147
|
+
made = MADE[config]
|
|
148
|
+
return made.last if made && made.first == config.measures
|
|
149
|
+
|
|
150
|
+
made&.last&.reverse_each(&:close)
|
|
151
|
+
stops = Stops.new
|
|
152
|
+
STOPS[config] = stops
|
|
153
|
+
config.in_workers.each { |block| block.call(stops) }
|
|
154
|
+
MADE[config] = [config.measures.transform_values(&:dup), Worker.made(config.metrics, config.measures).first]
|
|
155
|
+
MADE.fetch(config).last
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Runs the block as a worker of +config+ would run (its in_worker
|
|
159
|
+
# blocks first, its metrics made: see #metrics), then ends it, as a
|
|
160
|
+
# worker ends (its metrics closed, last first, then what its blocks
|
|
161
|
+
# gave at_stop). Returns what the block does.
|
|
162
|
+
def in_worker(config)
|
|
163
|
+
metrics(config)
|
|
164
|
+
yield
|
|
165
|
+
ensure
|
|
166
|
+
MADE.delete(config)&.last&.reverse_each(&:close)
|
|
167
|
+
# (Registered before any in_worker block runs.)
|
|
168
|
+
stops = STOPS.delete(config) #: Stops
|
|
169
|
+
stops.run
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Each config's worker's Stops (see #in_worker).
|
|
173
|
+
# @api private
|
|
174
|
+
STOPS = {} #: Hash[Config, Stops]
|
|
175
|
+
STOPS.compare_by_identity
|
|
176
|
+
|
|
177
|
+
# Each config's metrics (see #metrics).
|
|
178
|
+
# @api private
|
|
179
|
+
MADE = {} #: Hash[Config, [Hash[String, Hash[Symbol, untyped]], Array[Metric]]]
|
|
180
|
+
MADE.compare_by_identity
|
|
181
|
+
|
|
182
|
+
# A stand-in Sample of +n+ iterations of a benchmark named +name+
|
|
183
|
+
# (with +only:+ and +peak_memory:+, as DSL#duel takes them), measuring
|
|
184
|
+
# nothing: what a metric's #start_sample, and its details block
|
|
185
|
+
# (Snapshot.details), are given.
|
|
186
|
+
#
|
|
187
|
+
# metric.start_sample(Duel::Testing.sample(n: 10))
|
|
188
|
+
def sample(n: 1, name: "test", only: nil, peak_memory: false)
|
|
189
|
+
nothing = -> {} #: ^() [self: Context] -> void
|
|
190
|
+
Sample.new(Definition.new(name, only, peak_memory, __FILE__, nothing), [], n: n)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A stand-in reading of running totals (Snapshot::Reading): +totals+,
|
|
194
|
+
# by name, and the +context+ read with them. A sample's stretches are
|
|
195
|
+
# pairs of them, [started, stopped].
|
|
196
|
+
#
|
|
197
|
+
# stretches = [[Duel::Testing.reading({"rows" => 1}), Duel::Testing.reading({"rows" => 4})]]
|
|
198
|
+
def reading(totals = {}, context = nil) = Snapshot::Reading.new(totals, context)
|
|
199
|
+
|
|
200
|
+
# What running totals' details block (Snapshot.details: +metric+ is
|
|
201
|
+
# what Config#metric returned) says of +stretches+ of +sample+: nil,
|
|
202
|
+
# without one.
|
|
203
|
+
#
|
|
204
|
+
# Duel::Testing.details(metric, stretches, Duel::Testing.sample(n: 3))
|
|
205
|
+
def details(metric, stretches, of = sample) = metric.explainer&.call(stretches, of)
|
|
206
|
+
|
|
207
|
+
# Compares the project at +base+ with the one at +candidate+ (its
|
|
208
|
+
# .duel.rb, if any), in worker processes, as `duel` does: with
|
|
209
|
+
# +settings+ (as `duel`'s options name them: processes:, timeout:,
|
|
210
|
+
# filter:, limit:...) instead of the defaults, and two workers of each
|
|
211
|
+
# version unless they say.
|
|
212
|
+
def compare(base, candidate, **settings)
|
|
213
|
+
config_path = File.join(candidate, ".duel.rb")
|
|
214
|
+
saved = Duel.config
|
|
215
|
+
begin
|
|
216
|
+
config = File.exist?(config_path) ? Duel.load_config(config_path) : Config.new(candidate)
|
|
217
|
+
run = Settings.defaults
|
|
218
|
+
run.config_path = config_path if File.exist?(config_path)
|
|
219
|
+
run.processes = 2
|
|
220
|
+
settings.each { |setting, value| run[setting] = value }
|
|
221
|
+
out = StringIO.new
|
|
222
|
+
runner = Runner.new(config, run, base, out: out)
|
|
223
|
+
results = runner.run
|
|
224
|
+
Comparison.new(results, out.string, runner.passed?(results))
|
|
225
|
+
ensure
|
|
226
|
+
Duel.config = saved
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Writes a project into +dir+: +files+, by path from it, with their
|
|
231
|
+
# contents. Returns the directory.
|
|
232
|
+
def project(dir, files)
|
|
233
|
+
files.each do |path, contents|
|
|
234
|
+
file = File.join(dir, path)
|
|
235
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
236
|
+
File.write(file, contents)
|
|
237
|
+
end
|
|
238
|
+
dir
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/duel/version.rb
ADDED
data/lib/duel/worker.rb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "../duel"
|
|
7
|
+
require_relative "context"
|
|
8
|
+
require_relative "profile"
|
|
9
|
+
|
|
10
|
+
module Duel
|
|
11
|
+
# A worker process: loads one version of the code and the candidate's
|
|
12
|
+
# benchmarks, and answers the runner's requests. Started (see
|
|
13
|
+
# WorkerProcess) with Worker.main(VERSION_ROOT, CANDIDATE_ROOT, CONTROL
|
|
14
|
+
# [, CONFIG]), it gives its main object the DSL, loads the benchmarks,
|
|
15
|
+
# connects to the runner's Unix socket at CONTROL, sends what it has
|
|
16
|
+
# (see #hello), and answers one JSON request per line with one JSON
|
|
17
|
+
# reply. (Not over stdin: a JRuby started by JRuby sees its stdin pipe
|
|
18
|
+
# closed.)
|
|
19
|
+
# @api private
|
|
20
|
+
class Worker
|
|
21
|
+
# The built-in metrics, outermost first: allocations inside
|
|
22
|
+
# instructions, whose counter allocates as it's read (a sample's own
|
|
23
|
+
# allocations are none but the benchmark's), and CPU time innermost.
|
|
24
|
+
BUILT_IN = [Metrics::Instructions, Metrics::Allocations, Metrics::CPU].freeze
|
|
25
|
+
|
|
26
|
+
def self.main(version_root, candidate_root, control, config_path = nil)
|
|
27
|
+
config = config_path ? Duel.load_config(config_path) : (Duel.config = Config.new(candidate_root))
|
|
28
|
+
config.load_into(File.expand_path(version_root))
|
|
29
|
+
TOPLEVEL_BINDING.receiver.extend(DSL)
|
|
30
|
+
config.benchmark_files.each { |file| load file }
|
|
31
|
+
Context.check
|
|
32
|
+
stops = Stops.new
|
|
33
|
+
config.in_workers.each { |block| block.call(stops) }
|
|
34
|
+
begin
|
|
35
|
+
# The benchmarks are the only work in this process: it starts tidy.
|
|
36
|
+
GC.start
|
|
37
|
+
worker = new(config.metrics, config.measures, hooks: config.hooks)
|
|
38
|
+
begin
|
|
39
|
+
socket = UNIXSocket.new(control)
|
|
40
|
+
socket.puts JSON.generate(worker.hello)
|
|
41
|
+
socket.each_line { |line| socket.puts JSON.generate(worker.handle(JSON.parse(line))) }
|
|
42
|
+
ensure
|
|
43
|
+
worker.close
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
stops.run
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Its metric instances: the plugins' outermost, then the built-in
|
|
51
|
+
# ones, CPU innermost (see Worker.made). +hooks+: the configuration's
|
|
52
|
+
# (see Harness).
|
|
53
|
+
def initialize(plugin_metrics = [], measures = {}, hooks: NO_HOOKS)
|
|
54
|
+
@metrics, @specs = Worker.made(plugin_metrics + BUILT_IN, measures)
|
|
55
|
+
@benchmarks = Duel.benchmarks
|
|
56
|
+
@hooks = hooks
|
|
57
|
+
@harnesses = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Closes its metrics (see Metric#close), last first, as it ends.
|
|
61
|
+
def close = @metrics.reverse_each(&:close)
|
|
62
|
+
|
|
63
|
+
# +metrics+ (classes) made and opened (Metric#open), and their
|
|
64
|
+
# measures' specs: only those available here, with +measures+
|
|
65
|
+
# (Config#measure) changing their settings (each metric's own too:
|
|
66
|
+
# Metric#settings), and leaving out those skipped (and a metric whose
|
|
67
|
+
# measures all are). Two that measure the same name, a change to one
|
|
68
|
+
# none measures, or a setting there isn't, fail.
|
|
69
|
+
def self.made(metrics, measures)
|
|
70
|
+
metrics.each { |metric| metric.measures.each { |name, settings| Metric.check(name, settings) } }
|
|
71
|
+
names = metrics.flat_map { |metric| metric.measures.keys }
|
|
72
|
+
twice = names.select { |name| names.count(name) > 1 }.uniq
|
|
73
|
+
raise Error, "more than one metric measures #{twice.join(", ")}" unless twice.empty?
|
|
74
|
+
unknown = measures.keys - names
|
|
75
|
+
raise Error, "no metric measures #{unknown.join(", ")} (c.measure)" unless unknown.empty?
|
|
76
|
+
|
|
77
|
+
skipped = measures.select { |_, settings| settings[:skip] }.keys
|
|
78
|
+
made = metrics.select(&:available?).reject { |metric| (metric.measures.keys - skipped).empty? }.map(&:new)
|
|
79
|
+
made.each do |metric|
|
|
80
|
+
metric.settings = metric.class.settings(measures)
|
|
81
|
+
metric.open
|
|
82
|
+
end
|
|
83
|
+
[made, made.flat_map { |metric| metric.class.specs(measures) }.reject { |spec| skipped.include?(spec.fetch("name")) }]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# What the runner learns first: the benchmarks, the metrics, whether
|
|
87
|
+
# peak memory and profiles can be measured here, and whether YJIT is on.
|
|
88
|
+
def hello
|
|
89
|
+
{"benchmarks" => @benchmarks.values.map(&:to_h), "metrics" => @specs, "ruby" => RUBY_DESCRIPTION,
|
|
90
|
+
"memory" => !Native::Memory.resident.nil?, "profiler" => Worker.profiler?, "yjit" => Worker.yjit?,
|
|
91
|
+
"extensions" => Worker.extensions, "gem_dirs" => Worker.gem_dirs}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The native extensions loaded (CRuby's and TruffleRuby's builds, and
|
|
95
|
+
# JRuby's jars), as {feature => real path}: a feature is the path
|
|
96
|
+
# from the directory on the load path it was found in (the deepest,
|
|
97
|
+
# of several), "my_gem/my_gem.bundle", say, or else its file's name.
|
|
98
|
+
# (For the runner to check each version loaded its own builds: see
|
|
99
|
+
# Lane#check_builds.)
|
|
100
|
+
def self.extensions(features = $LOADED_FEATURES, load_path = $LOAD_PATH)
|
|
101
|
+
dirs = load_path.map { |dir| File.join(File.expand_path(dir), "") }
|
|
102
|
+
features.select { |feature| File.absolute_path?(feature) && feature.end_with?(".#{RbConfig::CONFIG.fetch("DLEXT")}") }.to_h do |path|
|
|
103
|
+
dir = dirs.select { |prefix| path.start_with?(prefix) }.max_by(&:size)
|
|
104
|
+
[dir ? path.delete_prefix(dir) : File.basename(path), File.realpath(path)]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The directories gems are installed in, Gem.path's that are there, as
|
|
109
|
+
# real paths ending in "/". Under Bundler, Gem.path is its bundle's
|
|
110
|
+
# (vendor/bundle/ruby/4.0.0, say, with its git gems in bundler/gems).
|
|
111
|
+
# (For the runner to tell an installed gem's extension from a build,
|
|
112
|
+
# where the bundle is in a version's tree: see Lane#check_builds.)
|
|
113
|
+
def self.gem_dirs(dirs = Gem.path)
|
|
114
|
+
dirs.select { |dir| File.directory?(dir) }.map { |dir| File.join(File.realpath(dir), "") }.uniq
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether YJIT is on, by Ruby: JRuby and TruffleRuby have none, and a
|
|
118
|
+
# CRuby built without it has no RubyVM::YJIT.
|
|
119
|
+
YJIT = {"ruby" => -> { RubyVM.const_defined?(:YJIT) && RubyVM::YJIT.enabled? }}.fetch(RUBY_ENGINE, -> { false })
|
|
120
|
+
|
|
121
|
+
# Whether YJIT is on (which the version may have turned on as it
|
|
122
|
+
# loaded), for a longer warm-up (YJIT_WARM).
|
|
123
|
+
def self.yjit? = YJIT.call
|
|
124
|
+
|
|
125
|
+
# Whether StackProf is installed, for profiles: on CRuby (+engine+)
|
|
126
|
+
# only. TruffleRuby installs it, but it fails to start there, and
|
|
127
|
+
# JRuby can't install it.
|
|
128
|
+
def self.profiler?(engine = RUBY_ENGINE)
|
|
129
|
+
return false unless engine == "ruby"
|
|
130
|
+
|
|
131
|
+
require "stackprof"
|
|
132
|
+
true
|
|
133
|
+
rescue LoadError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Answers a request: setup, sample or teardown of a benchmark. Errors
|
|
138
|
+
# are answered, not raised.
|
|
139
|
+
def handle(request)
|
|
140
|
+
name = request.fetch("name")
|
|
141
|
+
case request.fetch("op")
|
|
142
|
+
when "setup" then setup(name, request.fetch("trial", true))
|
|
143
|
+
when "sample" then sample(name, request.fetch("n"), request.fetch("count", false), request.fetch("runs", 1))
|
|
144
|
+
when "memory" then memory(name, request.fetch("n"))
|
|
145
|
+
when "profile" then profile(name, request.fetch("n"), request.fetch("runs"))
|
|
146
|
+
when "teardown" then teardown(name)
|
|
147
|
+
else raise ArgumentError, "unknown request #{request}"
|
|
148
|
+
end
|
|
149
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
150
|
+
{"error" => "#{e.class}: #{e}", "backtrace" => e.backtrace.first(5)}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Runs its setup hooks, then (if +trial+) the benchmark once,
|
|
156
|
+
# unmeasured (see Harness#trial).
|
|
157
|
+
def setup(name, trial)
|
|
158
|
+
harness = Harness.new(@benchmarks.fetch(name), @hooks)
|
|
159
|
+
harness.prepare
|
|
160
|
+
harness.trial if trial
|
|
161
|
+
@harnesses[name] = harness
|
|
162
|
+
{"ok" => true}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Runs +n+ iterations +runs+ times, less what pausing and resuming
|
|
166
|
+
# around each iteration leaves (see Harness#leak), and reports each
|
|
167
|
+
# timed metric's least (interference only ever adds time, so the
|
|
168
|
+
# least is the closest to what the code itself costs) and the last
|
|
169
|
+
# run's counts.
|
|
170
|
+
def sample(name, n, count, runs)
|
|
171
|
+
harness = @harnesses.fetch(name)
|
|
172
|
+
leak = harness.leak(@metrics)
|
|
173
|
+
samples = Array.new(runs) do
|
|
174
|
+
harness.sample(@metrics, n: n, counting: count).tap do |sample|
|
|
175
|
+
harness.run(n, sample)
|
|
176
|
+
sample.less(leak)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
timed = samples.first.timed.to_h { |metric| [metric, samples.map { |s| s.timed.fetch(metric) }.min] }
|
|
180
|
+
{"timed" => timed, "counted" => samples.last.counted, "heap" => samples.last.heap, "details" => samples.last.details}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The peak memory of +n+ iterations (see MemorySample), in bytes.
|
|
184
|
+
def memory(name, n)
|
|
185
|
+
sample = MemorySample.new
|
|
186
|
+
@harnesses.fetch(name).run_memory(n, sample)
|
|
187
|
+
{"peak" => sample.peak}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A profile of +runs+ samples of +n+ iterations (see ProfileSample
|
|
191
|
+
# and Profile.of). StackProf runs throughout: started again for each
|
|
192
|
+
# stretch, its timer would start again too, and never sample a
|
|
193
|
+
# stretch shorter than its interval.
|
|
194
|
+
def profile(name, n, runs)
|
|
195
|
+
require "stackprof"
|
|
196
|
+
harness = @harnesses.fetch(name)
|
|
197
|
+
StackProf.start(mode: ProfileSample::MODE, interval: ProfileSample::INTERVAL, raw: true)
|
|
198
|
+
begin
|
|
199
|
+
runs.times { harness.run(n, ProfileSample.new) }
|
|
200
|
+
ensure
|
|
201
|
+
StackProf.stop
|
|
202
|
+
end
|
|
203
|
+
Profile.of(StackProf.results)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def teardown(name)
|
|
207
|
+
@harnesses.delete(name)&.finish
|
|
208
|
+
{"ok" => true}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|