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
data/lib/duel/init.rb ADDED
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "optparse"
7
+ require "pathname"
8
+ require_relative "../duel"
9
+
10
+ module Duel
11
+ # `duel init`: sets Duel up in a gem or an app that isn't a Rails app
12
+ # (duel-rails's generator sets a Rails app up). It writes .duel.rb, a
13
+ # benchmark to start from, and a GitHub Actions workflow, adds a Rake
14
+ # task to the Rakefile, and adds StackProf to the Gemfile (for profiles
15
+ # of regressions, on CRuby). It overwrites nothing without --force, and
16
+ # writes nothing with --dry-run.
17
+ # @api private
18
+ class Init
19
+ USAGE = "Usage: duel init [options]\n\n" \
20
+ "Sets Duel up in this project: .duel.rb, a benchmark to start from in duel/, a GitHub Actions workflow,\n" \
21
+ "a Rake task, and StackProf in the Gemfile.\n\n"
22
+
23
+ TEMPLATES = File.join(__dir__, "templates")
24
+
25
+ # What's added to a Rakefile.
26
+ RAKE_TASK = %(require "duel/rake_task"\nDuel::RakeTask.new\n)
27
+
28
+ # What's added to a Gemfile, for profiles of regressions: StackProf
29
+ # runs on CRuby only, so it's left out of JRuby's and TruffleRuby's
30
+ # bundles.
31
+ STACKPROF = %(gem "stackprof", platforms: :mri)
32
+
33
+ # A Gemfile's development group (or development and test's), with the
34
+ # indentation of its first line.
35
+ DEVELOPMENT = /^([ \t]*)group[ \t(]+(?::development(?:,[ \t]*:test)?|:test,[ \t]*:development)[ \t)]+do[ \t]*\n/
36
+
37
+ def initialize(argv, out: $stdout, err: $stderr, root: Dir.pwd)
38
+ @argv = argv
39
+ @out = out
40
+ @err = err
41
+ @root = root
42
+ end
43
+
44
+ # Returns the exit status: 0 set up, 1 a Rails app (not set up), 2 a
45
+ # bad option.
46
+ def run
47
+ rest = catch(:help) { parser.parse(@argv) } or return 0
48
+ raise OptionParser::NeedlessArgument, rest.first unless rest.empty?
49
+ return rails if File.exist?(File.join(@root, "config", "application.rb"))
50
+
51
+ @out.puts "duel init: a dry run, so nothing is written" if @dry_run
52
+ files.each { |path, content| write(path, content) }
53
+ add_rake_task
54
+ add_stackprof
55
+ @out.print next_steps
56
+ 0
57
+ rescue OptionParser::ParseError => e
58
+ @err.puts "duel init: #{e}"
59
+ 2
60
+ end
61
+
62
+ # The files it writes, from the project's root, to what's in them.
63
+ def files
64
+ workflow = Pathname(File.realpath(repository)).relative_path_from(File.realpath(@root)).join(".github", "workflows", workflow_name)
65
+ {".duel.rb" => render("duel.rb"), benchmark_file => render("benchmark_duel.rb"), workflow.to_s => render("perf.yml")}
66
+ end
67
+
68
+ private
69
+
70
+ def parser
71
+ OptionParser.new do |o|
72
+ o.banner = USAGE
73
+ o.on("--force", "Overwrite files that exist") { @force = true }
74
+ o.on("--dry-run", "Say what it would do, and write nothing") { @dry_run = true }
75
+ o.on("-h", "--help", "Print this help") do
76
+ @out.puts o
77
+ throw :help
78
+ end
79
+ end
80
+ end
81
+
82
+ # Says to use duel-rails's generator instead.
83
+ def rails
84
+ @err.puts "duel init: this is a Rails app (config/application.rb). Add duel-rails to the Gemfile's development group, " \
85
+ "and run `bin/rails generate duel:install` instead"
86
+ 1
87
+ end
88
+
89
+ # Writes +content+ to +path+, unless it exists (without --force), and
90
+ # says what it did.
91
+ def write(path, content)
92
+ full = File.join(@root, path)
93
+ exists = File.exist?(full)
94
+ return said("skip", "#{path} (it exists: --force to overwrite)") if exists && !@force
95
+
96
+ said(exists ? "overwrite" : "create", path)
97
+ return if @dry_run
98
+
99
+ FileUtils.mkdir_p(File.dirname(full))
100
+ File.write(full, content)
101
+ end
102
+
103
+ # Adds Duel's Rake task to the Rakefile, if there is one without it.
104
+ def add_rake_task
105
+ rakefile = File.join(@root, "Rakefile")
106
+ return unless File.exist?(rakefile)
107
+
108
+ text = File.read(rakefile)
109
+ return said("skip", "Rakefile (it has Duel's task)") if text.include?("duel/rake_task")
110
+
111
+ said("append", "Rakefile")
112
+ File.write(rakefile, gap(text) + RAKE_TASK, mode: "a") unless @dry_run
113
+ end
114
+
115
+ # Adds StackProf to the Gemfile, if there is one without it: in its
116
+ # development group, if it has one, or else at its end.
117
+ def add_stackprof
118
+ gemfile = File.join(@root, "Gemfile")
119
+ return unless File.exist?(gemfile)
120
+
121
+ text = File.read(gemfile)
122
+ return said("skip", "Gemfile (it has stackprof)") if text.include?("stackprof")
123
+
124
+ @stackprof = true
125
+ grouped = in_development_group(text)
126
+ said(grouped ? "insert" : "append", "Gemfile (#{STACKPROF}#{", in the development group" if grouped})")
127
+ File.write(gemfile, grouped || at_the_end(text)) unless @dry_run
128
+ end
129
+
130
+ # The Gemfile's +text+ with StackProf last in its development group,
131
+ # or nil without one (or without the end of one).
132
+ def in_development_group(text)
133
+ group = DEVELOPMENT.match(text) or return
134
+ indent = group[1]
135
+ ending = /^#{indent}end\b/.match(group.post_match) or return
136
+ "#{group.pre_match}#{group}#{ending.pre_match}#{indent} #{STACKPROF}\n#{ending}#{ending.post_match}"
137
+ end
138
+
139
+ # What goes between a file's +text+ and what's added at its end: a
140
+ # blank line, if there's anything before it.
141
+ def gap(text) = text.empty? ? "" : "#{"\n" unless text.end_with?("\n")}\n"
142
+
143
+ # The Gemfile's +text+ with StackProf at its end.
144
+ def at_the_end(text) = "#{text}#{gap(text)}# For profiles of regressions (StackProf runs on CRuby only)\n#{STACKPROF}\n"
145
+
146
+ def said(what, path) = @out.puts(format(" %-10s%s", what, path))
147
+
148
+ # What to do next, numbered, after a blank line.
149
+ def next_steps
150
+ steps = [] #: Array[String]
151
+ if !gemfile?
152
+ steps << %(Add gem "duel" to the Gemfile's development group, and run `bundle install`.)
153
+ elsif @stackprof
154
+ steps << "Run `bundle install`, for StackProf (added to the Gemfile)."
155
+ end
156
+ steps << "Set c.library in .duel.rb to what each version's workers require." unless required
157
+ steps << "Replace the example in #{benchmark_file} with benchmarks of your own code."
158
+ steps << "Run `bundle exec duel --force`. It compares the code with itself, and passes when the benchmarks are sound."
159
+ steps << "Commit and push. On a pull request, the workflow compares the code with the main branch, and comments with the report."
160
+ "\nNext:\n#{steps.each_with_index.map { |step, i| " #{i + 1}. #{step}\n" }.join}"
161
+ end
162
+
163
+ def render(name) = ERB.new(File.read(File.join(TEMPLATES, "#{name}.erb")), trim_mode: "-").result(binding)
164
+
165
+ # The library the gemspec names (see Config#library), or nil.
166
+ def library = Config.new(@root).library
167
+
168
+ # The library, without a gemspec: lib/'s only Ruby file, or nil.
169
+ def guess
170
+ found = Dir.glob("*.rb", base: File.join(@root, "lib"))
171
+ File.basename(found.first, ".rb") if found.size == 1
172
+ end
173
+
174
+ # What each version's workers require: the library, or the guess.
175
+ def required = library || guess
176
+
177
+ def benchmark_file = File.join("duel", "#{(required || "example").tr("/", "_")}_duel.rb")
178
+
179
+ def gemfile? = File.exist?(File.join(@root, "Gemfile")) && File.read(File.join(@root, "Gemfile")).match?(/gem ["']duel["']/)
180
+
181
+ def ext = Dir.exist?(File.join(@root, "ext"))
182
+
183
+ def version_file = File.exist?(File.join(@root, ".ruby-version"))
184
+
185
+ # The Ruby the workflow sets up: this one's (its minor version), or
186
+ # none, where .ruby-version names it.
187
+ def ruby_version = (RUBY_VERSION[/\A\d+\.\d+/] unless version_file)
188
+
189
+ # The root of the project's git repository: the project's own
190
+ # directory, outside one.
191
+ def repository
192
+ top, _, status = Open3.capture3("git", "-C", @root, "rev-parse", "--show-toplevel")
193
+ status.success? ? top.chomp : @root
194
+ end
195
+
196
+ # The project's directory, from its repository's root ("gems/parser"),
197
+ # or nil at the root.
198
+ def working_directory
199
+ path = Pathname(File.realpath(@root)).relative_path_from(File.realpath(repository)).to_s
200
+ path unless path == "."
201
+ end
202
+
203
+ # perf.yml, or for a project in a subdirectory of its repository,
204
+ # perf-<its path>.yml (perf-gems-parser.yml), so that each of a
205
+ # monorepo's projects can have its own.
206
+ def workflow_name = working_directory&.then { |dir| "perf-#{dir.tr("/", "-")}.yml" } || "perf.yml"
207
+
208
+ # What marks the workflow's comment on a pull request.
209
+ def marker = ["duel", working_directory].compact.join(" ")
210
+ end
211
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "runner"
5
+ require_relative "summary"
6
+ require_relative "version"
7
+
8
+ module Duel
9
+ # A run's report as JSON, for a program to read (`--json PATH`, see
10
+ # CLI): the same results the Markdown report has (see Summary), in plain
11
+ # JSON types, numbers as numbers. Its schema is documented in
12
+ # docs/json.md, and SCHEMA is its version, raised for any change that
13
+ # isn't a new key.
14
+ # @api private
15
+ class JSONReport
16
+ SCHEMA = 1
17
+
18
+ # A run that didn't compare: +status+ ("nothing_to_compare", or
19
+ # "error"), its +exit+ status, and +message+, what it said.
20
+ def self.note(status, exit, message, settings)
21
+ none = [] #: Array[value]
22
+ nothing = {} #: report
23
+ head(status, exit, message, settings).merge("seconds" => nil, "baseline" => nil, "candidate" => nil, "plugins" => none,
24
+ "benchmarks" => none, "across" => nothing, "errors" => none)
25
+ end
26
+
27
+ # What every report starts with.
28
+ def self.head(status, exit, message, settings)
29
+ {"schema" => SCHEMA, "duel_version" => VERSION, "status" => status, "exit" => exit, "message" => message,
30
+ "settings" => {"confidence" => settings.confidence, "limit" => settings.limit, "filter" => settings.filter.source,
31
+ "jobs" => settings.jobs, "processes" => settings.processes, "timeout" => settings.timeout,
32
+ "warm" => settings.warm, "force" => settings.force}}
33
+ end
34
+
35
+ # +runner+ ran, and made +results+, comparing +baseline+ with
36
+ # +candidate+ (each {"ref", "commit", "root"} or {"commit",
37
+ # "uncommitted_changes", "root"}) as +settings+ say, and exits with
38
+ # +exit+.
39
+ def initialize(runner, results, settings:, exit:, baseline:, candidate:)
40
+ @runner = runner
41
+ @results = results
42
+ @settings = settings
43
+ @exit = exit
44
+ @versions = {"baseline" => baseline, "candidate" => candidate}
45
+ end
46
+
47
+ def to_h
48
+ rubies = @runner.rubies
49
+ JSONReport.head(@exit.zero? ? "passed" : "failed", @exit, nil, @settings).merge(
50
+ "seconds" => @runner.seconds,
51
+ **@versions.to_h { |side, version| [side, version.merge("ruby" => rubies.fetch(side))] },
52
+ "plugins" => @runner.plugins.map(&:to_s),
53
+ "benchmarks" => @results.map { |result| benchmark(result) },
54
+ "across" => across,
55
+ "errors" => @results.select(&:error).map { |result| {"benchmark" => result.name, "message" => result.error} }
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ # (Runner#explained says nothing of a benchmark that didn't regress.)
62
+ def benchmark(result)
63
+ {"name" => result.name, "verdict" => Summary.verdict(result), "passed" => result.passed?, "regressed" => result.regressed,
64
+ "unsettled" => result.unsettled?, "iterations" => result.n, "seconds" => result.seconds,
65
+ "confirmations" => result.confirmations.size, "timed" => timed(result), "counted" => counted(result),
66
+ "peak_memory" => peak_memory(result), "details" => result.details,
67
+ "explanation" => @runner.explained(result), "error" => result.error, "skipped" => result.skipped}
68
+ end
69
+
70
+ # Each timed measure: its ratio's median, interval and verdict, and
71
+ # each version's median an iteration.
72
+ def timed(result)
73
+ # (Timed, so measured, with an n.)
74
+ result.timed.to_h do |metric, t|
75
+ n = result.n #: Integer
76
+ [metric, {"ratio" => t.fetch("median"), "interval" => t.fetch("interval"), "limit" => t.fetch("limit"),
77
+ "pairs" => t.fetch("pairs"), "verdict" => t.fetch("verdict"), "baseline" => t.fetch("baseline").fdiv(n),
78
+ "candidate" => t.fetch("candidate").fdiv(n), "unit" => t.fetch("unit"), "plural" => t.fetch("plural"),
79
+ "across" => t.fetch("across")}]
80
+ end
81
+ end
82
+
83
+ # Each counted measure: each version's fewest an iteration, and
84
+ # whether that's worse.
85
+ def counted(result)
86
+ worse = result.worse
87
+ result.counted.to_h do |metric, c|
88
+ n = c.fetch("n")
89
+ [metric, {"baseline" => c.fetch("baseline").fdiv(n), "candidate" => c.fetch("candidate").fdiv(n), "unit" => c.fetch("unit"),
90
+ "plural" => c.fetch("plural"), "worse" => worse.include?(metric)}]
91
+ end
92
+ end
93
+
94
+ def peak_memory(result)
95
+ result.memory&.then do |memory|
96
+ {"baseline" => memory.fetch("baseline"), "candidate" => memory.fetch("candidate"), "unit" => "B", "worse" => result.more_memory?}
97
+ end
98
+ end
99
+
100
+ def across
101
+ @runner.across.transform_values do |v|
102
+ {"ratio" => v.fetch("median"), "interval" => v.fetch("interval"), "limit" => v.fetch("limit"), "benchmarks" => v.fetch("benchmarks"),
103
+ "verdict" => v.fetch("verdict"), "confirmed" => v.fetch("confirmed", false)}
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/duel/lock.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../duel"
5
+
6
+ module Duel
7
+ # One run at a time in a project: two would share its tmp/duel and its
8
+ # baseline's checkout, each checking out and building over the other's.
9
+ # A run holds an flock on tmp/duel/lock, which ends with its process
10
+ # however that ends, so a run that crashed leaves nothing to clear. The
11
+ # file says whose it is, for a run that finds it taken.
12
+ # @api private
13
+ module Lock
14
+ # An exclusive lock, or none if another holds it.
15
+ EXCLUSIVE = File::LOCK_EX | File::LOCK_NB
16
+
17
+ # Runs the block holding +dir+'s lock. Fails, naming the run that
18
+ # holds it, if another does.
19
+ def self.hold(dir)
20
+ FileUtils.mkdir_p(dir)
21
+ # (Read from the start, and written at the end: there's no more.)
22
+ file = File.new(File.join(dir, "lock"), "a+")
23
+ begin
24
+ unless file.flock(EXCLUSIVE)
25
+ # (Empty for a moment, while its run writes its pid.)
26
+ pid = file.read
27
+ raise Error, "another duel run#{" (pid #{pid})" unless pid.empty?} is using #{dir}: wait for it to end, or stop it"
28
+ end
29
+ file.truncate(0)
30
+ file.write(Process.pid)
31
+ file.flush
32
+ yield
33
+ ensure
34
+ file.close
35
+ end
36
+ end
37
+ end
38
+ end