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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ VERSION = "0.1.0"
5
+ end
@@ -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