|
| 1 | +#!/usr/bin/env ruby |
| 2 | +require 'fileutils' |
| 3 | +require 'pathname' |
| 4 | +require 'shellwords' |
| 5 | +require 'English' |
| 6 | + |
| 7 | +############################ |
| 8 | +# USAGE |
| 9 | +# |
| 10 | +# bundle exec bin/bench_regression <ref1> <ref2> |
| 11 | +# <ref1> defaults to the current branch |
| 12 | +# <ref2> defaults to the master branch |
| 13 | +# bundle exec bin/bench_regression current # will run on the current branch |
| 14 | +# bundle exec bin/bench_regression revisions 792fb8a90 master # every revision inclusive |
| 15 | +# bundle exec bin/bench_regression 792fb8a90 master --repeat-count 2 --env CACHE_ON=off |
| 16 | +# bundle exec bin/bench_regression vendor |
| 17 | +########################### |
| 18 | + |
| 19 | +class BenchRegression |
| 20 | + ROOT = Pathname File.expand_path(File.join(*['..', '..']), __FILE__) |
| 21 | + TMP_DIR_NAME = File.join('tmp', 'bench') |
| 22 | + TMP_DIR = File.join(ROOT, TMP_DIR_NAME) |
| 23 | + E_TMP_DIR = Shellwords.shellescape(TMP_DIR) |
| 24 | + load ROOT.join('bin', 'bench') |
| 25 | + |
| 26 | + attr_reader :source_stasher |
| 27 | + |
| 28 | + def initialize |
| 29 | + @source_stasher = SourceStasher.new |
| 30 | + end |
| 31 | + |
| 32 | + class SourceStasher |
| 33 | + attr_reader :gem_require_paths, :gem_paths |
| 34 | + attr_writer :vendor |
| 35 | + |
| 36 | + def initialize |
| 37 | + @gem_require_paths = [] |
| 38 | + @gem_paths = [] |
| 39 | + refresh_temp_dir |
| 40 | + @vendor = false |
| 41 | + end |
| 42 | + |
| 43 | + def temp_dir_empty? |
| 44 | + File.directory?(TMP_DIR) && |
| 45 | + Dir[File.join(TMP_DIR, '*')].none? |
| 46 | + end |
| 47 | + |
| 48 | + def empty_temp_dir |
| 49 | + return if @vendor |
| 50 | + return if temp_dir_empty? |
| 51 | + FileUtils.mkdir_p(TMP_DIR) |
| 52 | + Dir[File.join(TMP_DIR, '*')].each do |file| |
| 53 | + if File.directory?(file) |
| 54 | + FileUtils.rm_rf(file) |
| 55 | + else |
| 56 | + FileUtils.rm(file) |
| 57 | + end |
| 58 | + end |
| 59 | + end |
| 60 | + |
| 61 | + def fill_temp_dir |
| 62 | + vendor_files(Dir[File.join(ROOT, 'test', 'benchmark', '*.{rb,ru}')]) |
| 63 | + # vendor_file(File.join('bin', 'bench')) |
| 64 | + housekeeping { empty_temp_dir } |
| 65 | + vendor_gem('benchmark-ips') |
| 66 | + end |
| 67 | + |
| 68 | + def vendor_files(files) |
| 69 | + files.each do |file| |
| 70 | + vendor_file(file) |
| 71 | + end |
| 72 | + end |
| 73 | + |
| 74 | + def vendor_file(file) |
| 75 | + FileUtils.cp(file, File.join(TMP_DIR, File.basename(file))) |
| 76 | + end |
| 77 | + |
| 78 | + def vendor_gem(gem_name) |
| 79 | + directory_name = `bundle exec gem unpack benchmark-ips --target=#{E_TMP_DIR}`[/benchmark-ips.+\d/] |
| 80 | + gem_paths << File.join(TMP_DIR, directory_name) |
| 81 | + gem_require_paths << File.join(TMP_DIR_NAME, directory_name, 'lib') |
| 82 | + housekeeping { remove_vendored_gems } |
| 83 | + end |
| 84 | + |
| 85 | + def remove_vendored_gems |
| 86 | + return if @vendor |
| 87 | + FileUtils.rm_rf(*gem_paths) |
| 88 | + end |
| 89 | + |
| 90 | + def refresh_temp_dir |
| 91 | + empty_temp_dir |
| 92 | + fill_temp_dir |
| 93 | + end |
| 94 | + |
| 95 | + def housekeeping |
| 96 | + at_exit { yield } |
| 97 | + end |
| 98 | + end |
| 99 | + |
| 100 | + module RevisionMethods |
| 101 | + module_function |
| 102 | + def current_branch |
| 103 | + @current_branch ||= `cat .git/HEAD | cut -d/ -f3,4,5`.chomp |
| 104 | + end |
| 105 | + |
| 106 | + def current_revision |
| 107 | + `git rev-parse --short HEAD`.chomp |
| 108 | + end |
| 109 | + |
| 110 | + def revision_description(rev) |
| 111 | + `git log --oneline -1 #{rev}`.chomp |
| 112 | + end |
| 113 | + |
| 114 | + def revisions(start_ref, end_ref) |
| 115 | + cmd = "git rev-list --reverse #{start_ref}..#{end_ref}" |
| 116 | + `#{cmd}`.chomp.split("\n") |
| 117 | + end |
| 118 | + |
| 119 | + def checkout_ref(ref) |
| 120 | + `git checkout #{ref}`.chomp |
| 121 | + if $CHILD_STATUS |
| 122 | + STDERR.puts "Checkout failed: #{ref}, #{$CHILD_STATUS.exitstatus}" unless $CHILD_STATUS.success? |
| 123 | + $CHILD_STATUS.success? |
| 124 | + else |
| 125 | + true |
| 126 | + end |
| 127 | + end |
| 128 | + |
| 129 | + def clean_head |
| 130 | + system('git reset --hard --quiet') |
| 131 | + end |
| 132 | + end |
| 133 | + module ShellMethods |
| 134 | + |
| 135 | + def sh(cmd) |
| 136 | + puts cmd |
| 137 | + # system(cmd) |
| 138 | + run(cmd) |
| 139 | + # env = {} |
| 140 | + # # out = STDOUT |
| 141 | + # pid = spawn(env, cmd) |
| 142 | + # Process.wait(pid) |
| 143 | + # pid = fork do |
| 144 | + # exec cmd |
| 145 | + # end |
| 146 | + # Process.waitpid2(pid) |
| 147 | + # puts $CHILD_STATUS.exitstatus |
| 148 | + end |
| 149 | + |
| 150 | + require 'pty' |
| 151 | + # should consider trapping SIGINT in here |
| 152 | + def run(cmd) |
| 153 | + puts cmd |
| 154 | + child_process = '' |
| 155 | + result = '' |
| 156 | + # http://stackoverflow.com/a/1162850 |
| 157 | + # stream output of subprocess |
| 158 | + begin |
| 159 | + PTY.spawn(cmd) do |stdin, _stdout, pid| |
| 160 | + begin |
| 161 | + # Do stuff with the output here. Just printing to show it works |
| 162 | + stdin.each do |line| |
| 163 | + print line |
| 164 | + result << line |
| 165 | + end |
| 166 | + child_process = PTY.check(pid) |
| 167 | + rescue Errno::EIO |
| 168 | + puts 'Errno:EIO error, but this probably just means ' \ |
| 169 | + 'that the process has finished giving output' |
| 170 | + end |
| 171 | + end |
| 172 | + rescue PTY::ChildExited |
| 173 | + puts 'The child process exited!' |
| 174 | + end |
| 175 | + unless (child_process && child_process.success?) |
| 176 | + exitstatus = child_process.exitstatus |
| 177 | + puts "FAILED: #{child_process.pid} exited with status #{exitstatus.inspect} due to failed command #{cmd}" |
| 178 | + exit exitstatus || 1 |
| 179 | + end |
| 180 | + result |
| 181 | + end |
| 182 | + |
| 183 | + def bundle(ref) |
| 184 | + system("rm -f Gemfile.lock") |
| 185 | + # This is absolutely critical for bundling to work |
| 186 | + Bundler.with_clean_env do |
| 187 | + system("bundle check || |
| 188 | + bundle install --local || |
| 189 | + bundle install || |
| 190 | + bundle update") |
| 191 | + end |
| 192 | + |
| 193 | + # if $CHILD_STATUS |
| 194 | + # STDERR.puts "Bundle failed at: #{ref}, #{$CHILD_STATUS.exitstatus}" unless $CHILD_STATUS.success? |
| 195 | + # $CHILD_STATUS.success? |
| 196 | + # else |
| 197 | + # false |
| 198 | + # end |
| 199 | + end |
| 200 | + end |
| 201 | + include ShellMethods |
| 202 | + include RevisionMethods |
| 203 | + |
| 204 | + def benchmark_refs(ref1: nil, ref2: nil, cmd:) |
| 205 | + checking_out = false |
| 206 | + ref0 = current_branch |
| 207 | + ref1 ||= current_branch |
| 208 | + ref2 ||= 'master' |
| 209 | + p [ref0, ref1, ref2, current_revision] |
| 210 | + |
| 211 | + run_benchmark_at_ref(cmd, ref1) |
| 212 | + p [ref0, ref1, ref2, current_revision] |
| 213 | + run_benchmark_at_ref(cmd, ref2) |
| 214 | + p [ref0, ref1, ref2, current_revision] |
| 215 | + |
| 216 | + checking_out = true |
| 217 | + checkout_ref(ref0) |
| 218 | + rescue Exception # rubocop:disable Lint/RescueException |
| 219 | + STDERR.puts "[ERROR] #{$!.message}" |
| 220 | + checkout_ref(ref0) unless checking_out |
| 221 | + raise |
| 222 | + end |
| 223 | + |
| 224 | + def benchmark_revisions(ref1: nil, ref2: nil, cmd:) |
| 225 | + checking_out = false |
| 226 | + ref0 = current_branch |
| 227 | + ref1 ||= current_branch |
| 228 | + ref2 ||= 'master' |
| 229 | + |
| 230 | + revisions(ref1, ref2).each do |rev| |
| 231 | + STDERR.puts "Checking out: #{revision_description(rev)}" |
| 232 | + |
| 233 | + run_benchmark_at_ref(cmd, rev) |
| 234 | + clean_head |
| 235 | + end |
| 236 | + checking_out = true |
| 237 | + checkout_ref(ref0) |
| 238 | + rescue Exception # rubocop:disable Lint/RescueException |
| 239 | + STDERR.puts "[ERROR]: #{$!.message}" |
| 240 | + checkout_ref(ref0) unless checking_out |
| 241 | + raise |
| 242 | + end |
| 243 | + |
| 244 | + def run_benchmark_at_ref(cmd, ref) |
| 245 | + checkout_ref(ref) |
| 246 | + run_benchmark(cmd, ref) |
| 247 | + end |
| 248 | + |
| 249 | + def run_benchmark(cmd, ref = nil) |
| 250 | + ref ||= current_revision |
| 251 | + bundle(ref) && |
| 252 | + benchmark_tests(cmd, ref) |
| 253 | + end |
| 254 | + |
| 255 | + def benchmark_tests(cmd, ref) |
| 256 | + base = E_TMP_DIR |
| 257 | + # cmd.sub('bin/bench', 'tmp/revision_runner/bench') |
| 258 | + # bundle = Gem.bin('bunle' |
| 259 | + # Bundler.with_clean_env(&block) |
| 260 | + |
| 261 | + # cmd = Shellwords.shelljoin(cmd) |
| 262 | + # cmd = "COMMIT_HASH=#{ref} BASE=#{base} bundle exec ruby -rbenchmark/ips #{cmd}" |
| 263 | + # Add vendoring benchmark/ips to load path |
| 264 | + |
| 265 | + # CURRENT THINKING: IMPORTANT |
| 266 | + # Pass into require statement as RUBYOPTS i.e. via env rather than command line argument |
| 267 | + # otherwise, have a 'fast ams benchmarking' module that extends benchmarkings to add the 'ams' |
| 268 | + # method but doesn't depend on benchmark-ips |
| 269 | + options = { |
| 270 | + commit_hash: ref, |
| 271 | + base: base, |
| 272 | + rubyopt: Shellwords.shellescape("-Ilib:#{source_stasher.gem_require_paths.join(':')}") |
| 273 | + } |
| 274 | + BenchmarkDriver.parse_argv_and_run(ARGV.dup, options) |
| 275 | + end |
| 276 | +end |
| 277 | + |
| 278 | +if $PROGRAM_NAME == __FILE__ |
| 279 | + benchmarking = BenchRegression.new |
| 280 | + |
| 281 | + case ARGV[0] |
| 282 | + when 'current' |
| 283 | + # Run current branch only |
| 284 | + |
| 285 | + # super simple command line parsing |
| 286 | + args = ARGV.dup |
| 287 | + _ = args.shift # remove 'current' from args |
| 288 | + cmd = args |
| 289 | + benchmarking.run_benchmark(cmd) |
| 290 | + when 'revisions' |
| 291 | + # Runs on every revision |
| 292 | + |
| 293 | + # super simple command line parsing |
| 294 | + args = ARGV.dup |
| 295 | + _ = args.shift |
| 296 | + ref1 = args.shift # remove 'revisions' from args |
| 297 | + ref2 = args.shift |
| 298 | + cmd = args |
| 299 | + benchmarking.benchmark_revisions(ref1: ref1, ref2: ref2, cmd: cmd) |
| 300 | + when 'vendor' |
| 301 | + # Just prevents vendored files from being cleaned up |
| 302 | + # at exit. (They are vendored at initialize.) |
| 303 | + benchmarking.source_stasher.vendor = true |
| 304 | + else |
| 305 | + # Default: Compare current_branch to master |
| 306 | + # Optionally: pass in two refs as args to `bin/bench_regression` |
| 307 | + # TODO: Consider checking across more revisions, to automatically find problems. |
| 308 | + |
| 309 | + # super simple command line parsing |
| 310 | + args = ARGV.dup |
| 311 | + ref1 = args.shift |
| 312 | + ref2 = args.shift |
| 313 | + cmd = args |
| 314 | + benchmarking.benchmark_refs(ref1: ref1, ref2: ref2, cmd: cmd) |
| 315 | + end |
| 316 | +end |
0 commit comments