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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "open3"
6
+ require_relative "../duel"
7
+
8
+ module Duel
9
+ # The version to compare against: a directory (Settings#base_dir), or a
10
+ # git commit checked out (and built, see Config#build) where #checkout
11
+ # says. Settings#base names the commit:
12
+ #
13
+ # - nil: where HEAD left the main branch (their merge base):
14
+ # Config#main_branch, or the one origin/HEAD names, or origin/main, or
15
+ # origin/master, or (a repository without a remote) main, or master
16
+ # - "last-release": the newest release tag (Config#release_tags) before
17
+ # HEAD, to catch slowdowns too small for any one change to fail but
18
+ # that add up between releases
19
+ # - anything else: a git ref
20
+ # @api private
21
+ class Baseline
22
+ LAST_RELEASE = "last-release"
23
+ # The main branch's usual names, origin's first (see #main_branch).
24
+ BRANCHES = %w[origin/main origin/master main master].freeze
25
+
26
+ def initialize(config, settings)
27
+ @config = config
28
+ @settings = settings
29
+ end
30
+
31
+ # The commit, or nil for "last-release" with no release yet.
32
+ def ref
33
+ return @ref if instance_variable_defined?(:@ref)
34
+
35
+ @ref = case @settings.base
36
+ when nil then merge_base
37
+ when LAST_RELEASE then git("describe", "--tags", "--abbrev=0", "--match", @config.release_tags, "HEAD^", allow_failure: true)
38
+ else @settings.base
39
+ end
40
+ end
41
+
42
+ # Whether nothing Config#watch names changed since the baseline (only
43
+ # for a git baseline compared with the working tree).
44
+ def unchanged?
45
+ return false if @settings.base_dir || @settings.candidate_dir
46
+
47
+ # (There's a ref: CLI#run checks first.)
48
+ commit = ref #: String
49
+ _, status = Open3.capture2e("git", "-C", @config.root, "diff", "--quiet", commit, "--", *@config.watch)
50
+ status.success?
51
+ end
52
+
53
+ # The baseline's tree, checked out and built if it's a commit.
54
+ def root
55
+ base_dir = @settings.base_dir
56
+ return File.expand_path(base_dir) if base_dir
57
+
58
+ sha = git("rev-parse", "#{ref}^{commit}")
59
+ dir = checkout
60
+ unless git("-C", dir, "rev-parse", "HEAD", allow_failure: true) == sha
61
+ FileUtils.rm_rf(dir)
62
+ git("worktree", "prune") # (git's record of it)
63
+ git("worktree", "add", dir, sha)
64
+ build(within(dir), "the baseline") if Dir.exist?(within(dir))
65
+ end
66
+ raise Error, "the baseline (#{ref}) has no #{prefix}: compare with --base REF, a commit that has it" unless Dir.exist?(within(dir))
67
+
68
+ within(dir)
69
+ end
70
+
71
+ # Where the project is in a checkout of its repository at +dir+: as far
72
+ # from the top as it is in its own (a project in a subdirectory of a
73
+ # monorepo, say).
74
+ def within(dir) = prefix.empty? ? dir : File.join(dir, prefix)
75
+
76
+ # The project's path from its repository's top level ("" at the top,
77
+ # and outside a repository).
78
+ def prefix = @prefix ||= git("rev-parse", "--show-prefix", allow_failure: true).to_s.chomp("/")
79
+
80
+ # Where a git baseline is checked out (the whole repository, whose
81
+ # #within is the project): next to the repository's top level, under a
82
+ # hidden name as long as its own, from the project's path's hash
83
+ # (".3f9c2a1b7", next to duel-rails), so the two versions' paths (with
84
+ # symlinks resolved, as Rails resolves its root) are alike: as long,
85
+ # and as deep. What a version does with its own paths (looking for a
86
+ # file a request might be for, say) takes longer on a longer or a
87
+ # deeper one, and a baseline that measured slower would hide a
88
+ # regression that small. (A repository named with one letter has a
89
+ # name of two.) Where that can't be (a directory Duel can't write in,
90
+ # or something else of that name there), tmp/duel/base.
91
+ def checkout
92
+ root = File.realpath(@config.root)
93
+ top = root.delete_suffix("/#{prefix}")
94
+ length = [File.basename(top).length - 1, 1].max
95
+ sibling = File.join(File.dirname(top), ".#{Digest::SHA256.hexdigest(root).ljust(length, "0")[0, length]}")
96
+ return sibling if File.writable?(File.dirname(top)) && (!File.exist?(sibling) || ours?(sibling))
97
+
98
+ File.join(@config.root, "tmp", "duel", "base")
99
+ end
100
+
101
+ # Removes what Duel keeps: tmp/duel, and the baseline's checkout (and
102
+ # git's record of it as a worktree).
103
+ def clean
104
+ FileUtils.rm_rf(File.join(@config.root, "tmp", "duel"))
105
+ FileUtils.rm_rf(checkout)
106
+ git("worktree", "prune", allow_failure: true) # (not a git repository, say)
107
+ end
108
+
109
+ # The main branch (see #ref).
110
+ def main_branch
111
+ @config.main_branch ||
112
+ git("symbolic-ref", "--short", "refs/remotes/origin/HEAD", allow_failure: true) ||
113
+ BRANCHES.find { |branch| git("rev-parse", branch, allow_failure: true) } ||
114
+ raise(Error, "no main branch to compare with (set c.main_branch in .duel.rb, or --base)")
115
+ end
116
+
117
+ # Builds the tree in +dir+ (Config#build), if there's a build: the
118
+ # baseline's when it's checked out, and the candidate's before every
119
+ # run, so a stale or missing build (a C extension the code quietly
120
+ # does without, say) never decides a comparison. Builds are
121
+ # incremental, so one already done costs little.
122
+ def build(dir, what)
123
+ command = @config.build
124
+ return unless command
125
+
126
+ output, status = Open3.capture2e(command, chdir: dir)
127
+ raise Error, "building #{what} (#{command}) failed:\n#{output}" unless status.success?
128
+ end
129
+
130
+ private
131
+
132
+ # Where HEAD left the main branch: their merge base, which there isn't
133
+ # when their histories are unrelated, or in a shallow clone (CI's,
134
+ # fetched without the history), where git fails saying nothing.
135
+ def merge_base
136
+ unless git("rev-parse", "HEAD", allow_failure: true)
137
+ raise Error, "this repository has no commits yet, so there's nothing to compare with: commit, " \
138
+ "or compare with a directory (--base DIR)"
139
+ end
140
+ branch = main_branch
141
+ out, err, status = Open3.capture3("git", "-C", @config.root, "merge-base", "HEAD", branch)
142
+ return out.chomp if status.success?
143
+ raise Error, "git merge-base HEAD #{branch} failed: #{err.chomp}" unless err.empty?
144
+
145
+ why = if git("rev-parse", "--is-shallow-repository") == "true"
146
+ "this is a shallow clone. Fetch the whole history (on GitHub Actions, fetch-depth: 0 for actions/checkout)"
147
+ else
148
+ "their histories are unrelated. Set c.main_branch in .duel.rb"
149
+ end
150
+ raise Error, "HEAD and #{branch} have no commit in common: #{why}, or compare with --base REF"
151
+ end
152
+
153
+ # Whether +dir+ is a checkout of this repository (a worktree of it).
154
+ def ours?(dir) = common_dir(dir) == common_dir(@config.root)
155
+
156
+ # The repository a directory is a checkout of (its git directory), or
157
+ # nil for none.
158
+ def common_dir(dir) = git("-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir", allow_failure: true)
159
+
160
+ def git(*args, allow_failure: false)
161
+ out, err, status = Open3.capture3("git", "-C", @config.root, *args)
162
+ return out.chomp if status.success?
163
+ raise Error, "git #{args.join(" ")} failed: #{err.chomp}" unless allow_failure
164
+ end
165
+ end
166
+ end
data/lib/duel/cli.rb ADDED
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "optparse"
7
+ require_relative "runner"
8
+ require_relative "baseline"
9
+ require_relative "lock"
10
+ require_relative "summary"
11
+ require_relative "json_report"
12
+ require_relative "init"
13
+
14
+ module Duel
15
+ # The `duel` command (and `duel init`: see Init). What a run behind a
16
+ # rake task or a CI expression can't pass as an option can be set in
17
+ # the environment (DUEL_BASE, DUEL_FILTER, DUEL_FORCE), and options win.
18
+ # On GitHub Actions (GITHUB_STEP_SUMMARY), it adds its report to the
19
+ # job's summary, in Markdown (see Summary). --markdown writes that to a
20
+ # file, and --json the report as JSON (see JSONReport), whatever the
21
+ # run's outcome, but not for a command line that isn't valid.
22
+ # @api private
23
+ class CLI
24
+ USAGE = "Usage: duel [options]\n duel init [options]\n\n" \
25
+ "Fails if this tree is slower than a baseline at any benchmark. `duel init` sets Duel up in a project.\n\n"
26
+
27
+ # Where GitHub Actions has a step add to its job's summary.
28
+ STEP_SUMMARY = "GITHUB_STEP_SUMMARY"
29
+
30
+ # The variables that set options, to their values, and the one that
31
+ # sets a flag by being set at all.
32
+ ENV_OPTIONS = {"DUEL_BASE" => "--base", "DUEL_FILTER" => "--filter"}.freeze
33
+ ENV_FLAGS = {"DUEL_FORCE" => "--force"}.freeze
34
+
35
+ def initialize(argv, env: ENV, out: $stdout, err: $stderr)
36
+ @argv = argv
37
+ @env = env
38
+ @out = out
39
+ @err = err
40
+ end
41
+
42
+ # Runs, and returns the exit status: 0 not slower (or nothing to compare),
43
+ # 1 slower (or a benchmark failed), 2 couldn't compare.
44
+ def run
45
+ return Init.new(@argv.drop(1), out: @out, err: @err).run if @argv.first == "init"
46
+
47
+ settings = @settings = catch(:version) { parse } or return 0
48
+ config = settings.config_path ? Duel.load_config(settings.config_path) : Config.new
49
+ Lock.hold(File.join(config.root, "tmp", "duel")) { compare(config, settings) }
50
+ rescue Error => e
51
+ @err.puts "duel: #{e}"
52
+ unfinished("couldn't compare", "error", "duel: #{e}", 2)
53
+ rescue OptionParser::ParseError => e
54
+ @err.puts "duel: #{e}"
55
+ 2
56
+ end
57
+
58
+ # +source+ as a Regexp: one that isn't is an option's invalid
59
+ # argument (a usage error, as any is).
60
+ def self.regexp(source)
61
+ Regexp.new(source)
62
+ rescue RegexpError => e
63
+ raise OptionParser::InvalidArgument, "#{source} (#{e})"
64
+ end
65
+
66
+ # The settings, from the environment and then the options.
67
+ def parse
68
+ settings = Settings.defaults
69
+ settings.jobs = [Etc.nprocessors / 2, 1].max
70
+ parser(settings).parse!(env_options + @argv)
71
+ settings.config_path ||= (".duel.rb" if File.exist?(".duel.rb"))
72
+ settings.config_path &&= File.expand_path(settings.config_path)
73
+ settings
74
+ end
75
+
76
+ private
77
+
78
+ # What #run does, holding the project's lock (see Lock).
79
+ def compare(config, settings)
80
+ baseline = Baseline.new(config, settings)
81
+ if @clean
82
+ baseline.clean
83
+ @out.puts "duel: removed tmp/duel and its baseline's worktree"
84
+ return 0
85
+ end
86
+ return nothing("no release tag (#{config.release_tags}) before HEAD to compare against") if settings.base_dir.nil? && baseline.ref.nil?
87
+ if !settings.force && baseline.unchanged?
88
+ return nothing("#{config.watch.join(", ")} unchanged since the baseline, so there's nothing to compare (--force to anyway)")
89
+ end
90
+
91
+ candidate = settings.candidate_dir || config.root
92
+ baseline.build(candidate, "the candidate")
93
+ base_root = baseline.root
94
+ uneven(base_root, candidate)
95
+ runner = Runner.new(config, settings, base_root, out: @out)
96
+ results = runner.run
97
+ status = runner.passed?(results) ? 0 : 1
98
+ publish(
99
+ -> {
100
+ Summary.new(runner, results, settings: settings, baseline: described_baseline(config, settings, baseline),
101
+ candidate: described_candidate(config, settings)).markdown
102
+ },
103
+ -> {
104
+ JSONReport.new(runner, results, settings: settings, exit: status, baseline: baseline_json(config, settings, baseline, base_root),
105
+ candidate: candidate_json(config, settings)).to_h
106
+ }
107
+ )
108
+ status
109
+ end
110
+
111
+ # Says there's nothing to compare, and why, and passes.
112
+ def nothing(why)
113
+ @out.puts "duel: #{why}"
114
+ unfinished("nothing to compare", "nothing_to_compare", "duel: #{why}", 0)
115
+ end
116
+
117
+ # Reports a run that didn't compare (+title+ says why in Markdown,
118
+ # and +status+ in JSON) with +message+, and returns +exit+.
119
+ def unfinished(title, status, message, exit)
120
+ # (Parsed, so set, where there's JSON to write: see #publish.)
121
+ settings = @settings #: Settings
122
+ publish(-> { Summary.note(title, message) }, -> { JSONReport.note(status, exit, message, settings) })
123
+ exit
124
+ end
125
+
126
+ # Writes the reports where they're asked for: the JSON +json+ makes
127
+ # to the file --json names, and the Markdown +markdown+ makes to the
128
+ # file --markdown names, and added to the job's summary on GitHub
129
+ # Actions (STEP_SUMMARY). Each is only made if it's written.
130
+ def publish(markdown, json)
131
+ path = @json
132
+ write(path, "#{JSON.pretty_generate(json.call)}\n") if path
133
+ path = @markdown
134
+ summary = (variables.fetch(STEP_SUMMARY) if variables.key?(STEP_SUMMARY))
135
+ return unless path || summary
136
+
137
+ text = markdown.call
138
+ write(path, text) if path
139
+ File.write(summary, text, mode: "a") if summary
140
+ end
141
+
142
+ # Writes +text+ to +path+, in a directory made for it.
143
+ def write(path, text)
144
+ FileUtils.mkdir_p(File.dirname(path))
145
+ File.write(path, text)
146
+ end
147
+
148
+ # The baseline, in Markdown: its directory, or its commit, and what
149
+ # named it.
150
+ def described_baseline(config, settings, baseline)
151
+ dir = settings.base_dir
152
+ return Summary.code(dir) if dir
153
+
154
+ # (There's one: #compare checked.)
155
+ ref = baseline.ref #: String
156
+ commit = Summary.code(git(config.root, "rev-parse", "--short", "#{ref}^{commit}").first.chomp)
157
+ case settings.base
158
+ when nil then "#{commit}, where HEAD left #{Summary.code(baseline.main_branch)}"
159
+ when Baseline::LAST_RELEASE then "#{Summary.code(ref)} (#{commit}), the last release"
160
+ else ref.match?(/\A\h+\z/) ? commit : "#{Summary.code(ref)} (#{commit})"
161
+ end
162
+ end
163
+
164
+ # The candidate, in Markdown: its directory, or its commit, and
165
+ # whether what Config#watch names has changes not committed.
166
+ def described_candidate(config, settings)
167
+ dir = settings.candidate_dir || config.root
168
+ head, found = git(dir, "rev-parse", "--short", "HEAD")
169
+ return Summary.code(dir) unless found.success?
170
+
171
+ _, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.watch)
172
+ "#{Summary.code(head.chomp)}#{", with changes not committed" unless clean.success?}"
173
+ end
174
+
175
+ # The baseline, for JSON: the ref that named it (the main branch,
176
+ # where HEAD left it), its commit, and its tree (+root+), or only its
177
+ # tree, a directory.
178
+ def baseline_json(config, settings, baseline, root)
179
+ described = {"ref" => nil, "commit" => nil, "root" => File.expand_path(root)}
180
+ return described if settings.base_dir
181
+
182
+ # (There's one: #compare checked.)
183
+ ref = baseline.ref #: String
184
+ named = case settings.base
185
+ when nil then baseline.main_branch
186
+ when Baseline::LAST_RELEASE then ref
187
+ else settings.base
188
+ end
189
+ described.merge("ref" => named, "commit" => git(config.root, "rev-parse", "#{ref}^{commit}").first.chomp)
190
+ end
191
+
192
+ # The candidate, for JSON: its commit, and whether what Config#watch
193
+ # names has changes not committed (each nil outside a repository), and
194
+ # its tree.
195
+ def candidate_json(config, settings)
196
+ dir = File.expand_path(settings.candidate_dir || config.root)
197
+ head, found = git(dir, "rev-parse", "HEAD")
198
+ return {"commit" => nil, "uncommitted_changes" => nil, "root" => dir} unless found.success?
199
+
200
+ _, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.watch)
201
+ {"commit" => head.chomp, "uncommitted_changes" => !clean.success?, "root" => dir}
202
+ end
203
+
204
+ # Git's output, and its status, in +dir+.
205
+ def git(dir, *args)
206
+ out, _, status = Open3.capture3("git", "-C", dir, *args)
207
+ [out, status]
208
+ end
209
+
210
+ # Says so if the two versions' paths differ in length (resolved): what
211
+ # a version does with its own paths takes longer on a longer path, so
212
+ # one may measure a little slower than the other.
213
+ def uneven(base_root, candidate)
214
+ longer = File.realpath(base_root).length - File.realpath(candidate).length
215
+ return if longer.zero?
216
+
217
+ @out.puts "duel: the baseline's path is #{Runner.count(longer.abs, "character")} #{longer.positive? ? "longer" : "shorter"} than the " \
218
+ "candidate's, so it may measure a little #{longer.positive? ? "slower" : "faster"}"
219
+ end
220
+
221
+ # The DUEL_* variables set, as options (empty is as good as unset,
222
+ # which CI expressions find handy).
223
+ def env_options
224
+ set = variables
225
+ ENV_OPTIONS.flat_map { |name, option| set.key?(name) ? [option, set.fetch(name)] : [] } +
226
+ ENV_FLAGS.filter_map { |name, flag| flag if set.key?(name) }
227
+ end
228
+
229
+ # The environment's variables that are set (not empty).
230
+ def variables = @env.reject { |_, value| value.empty? }
231
+
232
+ def parser(settings)
233
+ OptionParser.new do |o|
234
+ o.banner = USAGE
235
+ o.on("--base REF|DIR", "Baseline: a commit (default: where HEAD left the main branch), last-release (the newest",
236
+ "release tag), or a directory, already built") do |v|
237
+ # (A directory is one, whatever else it could name, and the last given wins.)
238
+ directory = File.directory?(v)
239
+ settings.base = (v unless directory)
240
+ settings.base_dir = (v if directory)
241
+ end
242
+ o.on("--candidate-dir DIR", "Candidate tree (default: this one)") { |v| settings.candidate_dir = v }
243
+ o.on("--filter REGEXP", "Only benchmarks whose names match") { |v| settings.filter = CLI.regexp(v) }
244
+ o.on("--jobs N", Integer, "Benchmarks measured at once (default: half the CPUs)") { |v| settings.jobs = v }
245
+ o.on("--processes N", Integer, "Worker processes per version (default: 3)") { |v| settings.processes = v }
246
+ o.on("--timeout SECONDS", Float, "Most a worker may take to load or answer (default: 60)") { |v| settings.timeout = v }
247
+ o.on("--limit RATIO", Float, "Ratio above which a timing is slower (default: 1.02)") { |v| settings.limit = v }
248
+ o.on("--warm SECONDS", Float, "Warm-up per benchmark (default: 5 on JRuby and TruffleRuby, 2 with YJIT on, else 0)") { |v| settings.warm = v }
249
+ o.on("--ruby version-file|PATH", "Each version's Ruby: the one its version file names (.ruby-version, say), or a",
250
+ "path (default: c.ruby, or this one)") { |v| settings.ruby = (v == "version-file") ? :version_file : v }
251
+ o.on("--force", "Compare even if nothing watched changed") { settings.force = true }
252
+ o.on("--config PATH", "Config file (default: .duel.rb)") { |v| settings.config_path = v }
253
+ o.on("--markdown PATH", "Also write the report to PATH in Markdown, for a pull request comment (on GitHub",
254
+ "Actions, it's added to the job's summary too)") { |v| @markdown = v }
255
+ o.on("--json PATH", "Also write the report to PATH as JSON, for a program to read") { |v| @json = v }
256
+ o.on("--clean", "Remove tmp/duel, and the baseline's git worktree, and stop") { @clean = true }
257
+ o.on("-v", "--version", "Print the version") do
258
+ @out.puts "duel #{VERSION}"
259
+ throw :version
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end