derailed_benchmarks 1.4.3 → 1.8.1

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/check_changelog.yml +11 -8
  3. data/.travis.yml +9 -7
  4. data/Appraisals +16 -16
  5. data/CHANGELOG.md +29 -1
  6. data/README.md +14 -4
  7. data/derailed_benchmarks.gemspec +4 -3
  8. data/gemfiles/rails_5_1.gemfile +3 -1
  9. data/gemfiles/rails_5_2.gemfile +3 -3
  10. data/lib/derailed_benchmarks.rb +2 -1
  11. data/lib/derailed_benchmarks/core_ext/kernel_require.rb +29 -24
  12. data/lib/derailed_benchmarks/git/commit.rb +36 -0
  13. data/lib/derailed_benchmarks/git/in_path.rb +59 -0
  14. data/lib/derailed_benchmarks/git/switch_project.rb +128 -0
  15. data/lib/derailed_benchmarks/git_switch_project.rb +1 -0
  16. data/lib/derailed_benchmarks/load_tasks.rb +11 -4
  17. data/lib/derailed_benchmarks/require_tree.rb +11 -1
  18. data/lib/derailed_benchmarks/{stats_in_file.rb → stats_for_file.rb} +8 -2
  19. data/lib/derailed_benchmarks/stats_from_dir.rb +68 -13
  20. data/lib/derailed_benchmarks/tasks.rb +34 -63
  21. data/lib/derailed_benchmarks/version.rb +1 -1
  22. data/test/derailed_benchmarks/core_ext/kernel_require_test.rb +70 -11
  23. data/test/derailed_benchmarks/git_switch_project_test.rb +83 -0
  24. data/test/derailed_benchmarks/require_tree_test.rb +1 -1
  25. data/test/derailed_benchmarks/stats_from_dir_test.rb +57 -9
  26. data/test/fixtures/require/autoload_child.rb +5 -0
  27. data/test/fixtures/require/autoload_parent.rb +8 -0
  28. data/test/fixtures/require/child_one.rb +1 -1
  29. data/test/fixtures/require/child_two.rb +1 -1
  30. data/test/fixtures/require/load_child.rb +3 -0
  31. data/test/fixtures/require/load_parent.rb +5 -0
  32. data/test/fixtures/require/parent_one.rb +1 -1
  33. data/test/integration/tasks_test.rb +43 -5
  34. data/test/rails_app/config/application.rb +2 -0
  35. data/test/test_helper.rb +6 -1
  36. metadata +46 -12
@@ -30,7 +30,7 @@ namespace :perf do
30
30
  DERAILED_APP.initialize! unless DERAILED_APP.instance_variable_get(:@initialized)
31
31
  end
32
32
 
33
- if ENV["DERAILED_SKIP_ACTIVE_RECORD"] && defined? ActiveRecord
33
+ if !ENV["DERAILED_SKIP_ACTIVE_RECORD"] && defined? ActiveRecord
34
34
  if defined? ActiveRecord::Tasks::DatabaseTasks
35
35
  ActiveRecord::Tasks::DatabaseTasks.create_current
36
36
  else # Rails 3.2
@@ -39,7 +39,14 @@ namespace :perf do
39
39
 
40
40
  ActiveRecord::Migrator.migrations_paths = DERAILED_APP.paths['db/migrate'].to_a
41
41
  ActiveRecord::Migration.verbose = true
42
- ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, nil)
42
+
43
+ if Rails.version.start_with? '6'
44
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths, ActiveRecord::SchemaMigration).migrate
45
+ elsif Rails.version.start_with? '5.2'
46
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths).migrate
47
+ else
48
+ ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, nil)
49
+ end
43
50
  end
44
51
 
45
52
  DERAILED_APP.config.consider_all_requests_local = true
@@ -103,7 +110,7 @@ namespace :perf do
103
110
  STDERR.puts "Bad request to #{cmd.inspect} \n\n***RESPONSE***:\n\n#{ response.inspect }"
104
111
 
105
112
  FileUtils.mkdir_p("tmp")
106
- File.open("tmp/fail.html", "w+") {|f| f.write response.body }
113
+ File.open("tmp/fail.html", "w+") {|f| f.write response }
107
114
 
108
115
  `open #{File.expand_path("tmp/fail.html")}` if ENV["DERAILED_DEBUG"]
109
116
 
@@ -135,4 +142,4 @@ namespace :perf do
135
142
  WARM_COUNT.times { call_app }
136
143
  end
137
144
  end
138
- end
145
+ end
@@ -13,6 +13,16 @@ module DerailedBenchmarks
13
13
  def initialize(name)
14
14
  @name = name
15
15
  @children = {}
16
+ @cost = 0
17
+ end
18
+
19
+ def self.reset!
20
+ REQUIRED_BY.clear
21
+ if defined?(Kernel::REQUIRE_STACK)
22
+ Kernel::REQUIRE_STACK.clear
23
+
24
+ Kernel::REQUIRE_STACK.push(TOP_REQUIRE)
25
+ end
16
26
  end
17
27
 
18
28
  def <<(tree)
@@ -40,7 +50,7 @@ module DerailedBenchmarks
40
50
  end
41
51
 
42
52
  def to_string
43
- str = +"#{name}: #{cost.round(4)} MiB"
53
+ str = String.new("#{name}: #{cost.round(4)} MiB")
44
54
  if parent && REQUIRED_BY[self.name.to_s]
45
55
  names = REQUIRED_BY[self.name.to_s].uniq - [parent.name.to_s]
46
56
  if names.any?
@@ -16,24 +16,30 @@ module DerailedBenchmarks
16
16
  # x.average # => 10.5
17
17
  # x.name # => "muhfile"
18
18
  class StatsForFile
19
- attr_reader :name, :values, :desc, :time
19
+ attr_reader :name, :values, :desc, :time, :short_sha
20
20
 
21
- def initialize(file:, name:, desc: "", time: )
21
+ def initialize(file:, name:, desc: "", time: , short_sha: nil)
22
22
  @file = Pathname.new(file)
23
23
  FileUtils.touch(@file)
24
24
 
25
25
  @name = name
26
26
  @desc = desc
27
27
  @time = time
28
+ @short_sha = short_sha
28
29
  end
29
30
 
30
31
  def call
31
32
  load_file!
33
+ return if values.empty?
32
34
 
33
35
  @median = (values[(values.length - 1) / 2] + values[values.length/ 2]) / 2.0
34
36
  @average = values.inject(:+) / values.length
35
37
  end
36
38
 
39
+ def empty?
40
+ values.empty?
41
+ end
42
+
37
43
  def median
38
44
  @median.to_f
39
45
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'bigdecimal'
4
4
  require 'statistics'
5
+ require 'stringio'
6
+ require 'mini_histogram'
7
+ require 'mini_histogram/plot'
5
8
 
6
9
  module DerailedBenchmarks
7
10
  # A class used to read several benchmark files
@@ -26,14 +29,28 @@ module DerailedBenchmarks
26
29
  FORMAT = "%0.4f"
27
30
  attr_reader :stats, :oldest, :newest
28
31
 
29
- def initialize(hash)
32
+ def initialize(input)
30
33
  @files = []
31
34
 
32
- hash.each do |branch, info_hash|
33
- file = info_hash.fetch(:file)
34
- desc = info_hash.fetch(:desc)
35
- time = info_hash.fetch(:time)
36
- @files << StatsForFile.new(file: file, desc: desc, time: time, name: branch)
35
+ if input.is_a?(Hash)
36
+ hash = input
37
+ hash.each do |branch, info_hash|
38
+ file = info_hash.fetch(:file)
39
+ desc = info_hash.fetch(:desc)
40
+ time = info_hash.fetch(:time)
41
+ short_sha = info_hash["short_sha"]
42
+ @files << StatsForFile.new(file: file, desc: desc, time: time, name: branch, short_sha: short_sha)
43
+ end
44
+ else
45
+ input.each do |commit|
46
+ @files << StatsForFile.new(
47
+ file: commit.file,
48
+ desc: commit.desc,
49
+ time: commit.time,
50
+ name: commit.ref,
51
+ short_sha: commit.short_sha
52
+ )
53
+ end
37
54
  end
38
55
  @files.sort_by! { |f| f.time }
39
56
  @oldest = @files.first
@@ -42,14 +59,27 @@ module DerailedBenchmarks
42
59
 
43
60
  def call
44
61
  @files.each(&:call)
45
- @stats = statistical_test
62
+
63
+ return self if @files.detect(&:empty?)
64
+
65
+ stats_95 = statistical_test(confidence: 95)
66
+
67
+ # If default check is good, see if we also pass a more rigorous test
68
+ # if so, then use the more rigourous test
69
+ if stats_95[:alternative]
70
+ stats_99 = statistical_test(confidence: 99)
71
+ @stats = stats_99 if stats_99[:alternative]
72
+ end
73
+ @stats ||= stats_95
74
+
46
75
  self
47
76
  end
48
77
 
49
- def statistical_test(series_1=oldest.values, series_2=newest.values)
78
+ def statistical_test(series_1=oldest.values, series_2=newest.values, confidence: 95)
50
79
  StatisticalTest::KSTest.two_samples(
51
80
  group_one: series_1,
52
- group_two: series_2
81
+ group_two: series_2,
82
+ alpha: (100 - confidence) / 100.0
53
83
  )
54
84
  end
55
85
 
@@ -86,10 +116,31 @@ module DerailedBenchmarks
86
116
  end
87
117
 
88
118
  def align
89
- " " * (("%i" % percent_faster).length - ("%i" % x_faster).length)
119
+ " " * (percent_faster.to_s.index(".") - x_faster.to_s.index("."))
120
+ end
121
+
122
+ def histogram(io = $stdout)
123
+ newest_histogram = MiniHistogram.new(newest.values)
124
+ oldest_histogram = MiniHistogram.new(oldest.values)
125
+ MiniHistogram.set_average_edges!(newest_histogram, oldest_histogram)
126
+
127
+ {newest => newest_histogram, oldest => oldest_histogram}.each do |report, histogram|
128
+ plot = histogram.plot(
129
+ title: "\n#{' ' * 18 }Histogram - [#{report.short_sha || report.name}] #{report.desc.inspect}",
130
+ ylabel: "Time (s)",
131
+ xlabel: "# of runs in range"
132
+ )
133
+
134
+ plot.render(io)
135
+ io.puts
136
+ end
137
+
138
+ io.puts
90
139
  end
91
140
 
92
- def banner(io = Kernel)
141
+ def banner(io = $stdout)
142
+ return if @files.detect(&:empty?)
143
+
93
144
  io.puts
94
145
  if significant?
95
146
  io.puts "❤️ ❤️ ❤️ (Statistically Significant) ❤️ ❤️ ❤️"
@@ -97,19 +148,23 @@ module DerailedBenchmarks
97
148
  io.puts "👎👎👎(NOT Statistically Significant) 👎👎👎"
98
149
  end
99
150
  io.puts
100
- io.puts "[#{newest.name}] #{newest.desc.inspect} - (#{newest.median} seconds)"
151
+ io.puts "[#{newest.short_sha || newest.name}] (#{FORMAT % newest.median} seconds) #{newest.desc.inspect} ref: #{newest.name.inspect}"
101
152
  io.puts " #{change_direction} by:"
102
153
  io.puts " #{align}#{FORMAT % x_faster}x [older/newer]"
103
154
  io.puts " #{FORMAT % percent_faster}\% [(older - newer) / older * 100]"
104
- io.puts "[#{oldest.name}] #{oldest.desc.inspect} - (#{oldest.median} seconds)"
155
+ io.puts "[#{oldest.short_sha || oldest.name}] (#{FORMAT % oldest.median} seconds) #{oldest.desc.inspect} ref: #{oldest.name.inspect}"
105
156
  io.puts
106
157
  io.puts "Iterations per sample: #{ENV["TEST_COUNT"]}"
107
158
  io.puts "Samples: #{newest.values.length}"
108
159
  io.puts
109
160
  io.puts "Test type: Kolmogorov Smirnov"
161
+ io.puts "Confidence level: #{@stats[:confidence_level] * 100} %"
110
162
  io.puts "Is significant? (max > critical): #{significant?}"
111
163
  io.puts "D critical: #{d_critical}"
112
164
  io.puts "D max: #{d_max}"
165
+
166
+ histogram(io)
167
+
113
168
  io.puts
114
169
  end
115
170
  end
@@ -1,6 +1,12 @@
1
1
  require_relative 'load_tasks'
2
2
 
3
3
  namespace :perf do
4
+ desc "runs the performance test against two most recent commits of the current app"
5
+ task :app do
6
+ ENV["DERAILED_PATH_TO_LIBRARY"] = '.'
7
+ Rake::Task["perf:library"].invoke
8
+ end
9
+
4
10
  desc "runs the same test against two different branches for statistical comparison"
5
11
  task :library do
6
12
  begin
@@ -11,92 +17,57 @@ namespace :perf do
11
17
  script = ENV["DERAILED_SCRIPT"] || "bundle exec derailed exec perf:test"
12
18
 
13
19
  if ENV["DERAILED_PATH_TO_LIBRARY"]
14
- library_dir = ENV["DERAILED_PATH_TO_LIBRARY"]
20
+ library_dir = ENV["DERAILED_PATH_TO_LIBRARY"].chomp
15
21
  else
16
22
  library_dir = DerailedBenchmarks.rails_path_on_disk
17
23
  end
18
-
19
- raise "Must be a path with a .git directory '#{library_dir}'" unless File.exist?(File.join(library_dir, ".git"))
20
-
21
- # Use either the explicit SHAs when present or grab last two SHAs from commit history
22
- # if only one SHA is given, then use it and the last SHA from commit history
23
- branch_names = []
24
- branch_names = ENV.fetch("SHAS_TO_TEST").split(",") if ENV["SHAS_TO_TEST"]
25
- if branch_names.length < 2
26
- Dir.chdir(library_dir) do
27
- run!("git checkout '#{branch_names.first}'") unless branch_names.empty?
28
-
29
- branches = run!('git log --format="%H" -n 2').chomp.split($/)
30
- if branch_names.empty?
31
- branch_names = branches
32
- else
33
- branches.shift
34
- branch_names << branches.shift
35
- end
36
- end
37
- end
38
-
39
- current_library_branch = ""
40
- Dir.chdir(library_dir) { current_library_branch = run!('git describe --contains --all HEAD').chomp }
24
+ library_dir = Pathname.new(library_dir)
41
25
 
42
26
  out_dir = Pathname.new("tmp/compare_branches/#{Time.now.strftime('%Y-%m-%d-%H-%M-%s-%N')}")
43
27
  out_dir.mkpath
44
28
 
45
- branches_to_test = branch_names.each_with_object({}) {|elem, hash| hash[elem] = out_dir + "#{elem.gsub('/', ':')}.bench.txt" }
46
- branch_info = {}
47
- branch_to_sha = {}
29
+ ref_string = ENV["SHAS_TO_TEST"] || ENV["REFS_TO_TEST"] || ""
48
30
 
49
- branches_to_test.each do |branch, file|
50
- Dir.chdir(library_dir) do
51
- run!("git checkout '#{branch}'")
52
- description = run!("git log --oneline --format=%B -n 1 HEAD | head -n 1").strip
53
- time_stamp = run!("git log -n 1 --pretty=format:%ci").strip # https://stackoverflow.com/a/25921837/147390
54
- short_sha = run!("git rev-parse --short HEAD").strip
55
- branch_to_sha[branch] = short_sha
31
+ project = DerailedBenchmarks::Git::SwitchProject.new(
32
+ path: library_dir,
33
+ ref_array: ref_string.split(","),
34
+ log_dir: out_dir
35
+ )
56
36
 
57
- branch_info[short_sha] = { desc: description, time: DateTime.parse(time_stamp), file: file }
58
- end
59
- run!("#{script}")
60
- end
37
+ stats = DerailedBenchmarks::StatsFromDir.new(project.commits)
61
38
 
39
+ # Advertise branch names early to make sure people know what they're testing
62
40
  puts
63
41
  puts
64
- branches_to_test.each.with_index do |(branch, _), i|
65
- short_sha = branch_to_sha[branch]
66
- desc = branch_info[short_sha][:desc]
67
- puts "Testing #{i + 1}: #{short_sha}: #{desc}"
42
+ project.commits.each_with_index do |commit, i|
43
+ puts "Testing #{i + 1}: #{commit.short_sha}: #{commit.description}"
68
44
  end
69
45
  puts
70
46
  puts
71
47
 
72
- raise "SHAs to test must be different" if branch_info.length == 1
73
- stats = DerailedBenchmarks::StatsFromDir.new(branch_info)
74
- ENV["DERAILED_STOP_VALID_COUNT"] ||= "50"
75
- stop_valid_count = Integer(ENV["DERAILED_STOP_VALID_COUNT"])
76
-
77
- times_significant = 0
78
- DERAILED_SCRIPT_COUNT.times do |i|
79
- puts "Sample: #{i.next}/#{DERAILED_SCRIPT_COUNT} iterations per sample: #{ENV['TEST_COUNT']}"
80
- branches_to_test.each do |branch, file|
81
- Dir.chdir(library_dir) { run!("git checkout '#{branch}'") }
82
- run!(" #{script} 2>&1 | tail -n 1 >> '#{file}'")
83
- end
84
- times_significant += 1 if i >= 2 && stats.call.significant?
85
- break if stop_valid_count != 0 && times_significant == stop_valid_count
86
- end
48
+ project.restore_branch_on_return do
49
+ DERAILED_SCRIPT_COUNT.times do |i|
50
+ puts "Sample: #{i.next}/#{DERAILED_SCRIPT_COUNT} iterations per sample: #{ENV['TEST_COUNT']}"
51
+ project.commits.each do |commit|
52
+ commit.checkout!
87
53
 
88
- ensure
89
- if library_dir && current_library_branch
90
- puts "Resetting git dir of '#{library_dir.to_s}' to #{current_library_branch.inspect}"
91
- Dir.chdir(library_dir) do
92
- run!("git checkout '#{current_library_branch}'")
54
+ output = run!("#{script} 2>&1")
55
+ commit.log.open("a") {|f| f.puts output.lines.last }
56
+ end
57
+
58
+ if (i % 50).zero?
59
+ puts "Intermediate result"
60
+ stats.call.banner
61
+ puts "Continuing execution"
62
+ end
93
63
  end
94
64
  end
95
65
 
66
+ ensure
96
67
  if stats
97
68
  stats.call.banner
98
69
 
99
- result_file = out_dir + "results.txt"
70
+ result_file = out_dir.join("results.txt")
100
71
  File.open(result_file, "w") do |f|
101
72
  stats.banner(f)
102
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DerailedBenchmarks
4
- VERSION = "1.4.3"
4
+ VERSION = "1.8.1"
5
5
  end
@@ -3,7 +3,6 @@
3
3
  require 'test_helper'
4
4
 
5
5
  class KernelRequireTest < ActiveSupport::TestCase
6
-
7
6
  setup do
8
7
  require 'derailed_benchmarks/core_ext/kernel_require'
9
8
  GC.disable
@@ -11,23 +10,83 @@ class KernelRequireTest < ActiveSupport::TestCase
11
10
 
12
11
  teardown do
13
12
  GC.enable
13
+ DerailedBenchmarks::RequireTree.reset!
14
+ end
15
+
16
+ test "profiles load" do
17
+ in_fork do
18
+ require fixtures_dir("require/load_parent.rb")
19
+
20
+ parent = assert_node_in_parent("load_parent.rb", TOP_REQUIRE)
21
+
22
+ assert_node_in_parent("load_child.rb", parent)
23
+ end
24
+ end
25
+
26
+ test "profiles autoload" do
27
+ skip if RUBY_VERSION.start_with?("2.2") # Fails on CI, I can't install Ruby 2.2 locally to debug https://stackoverflow.com/questions/63926460/install-ruby-2-2-on-mac-osx-catalina-with-ruby-install, https://github.com/postmodern/ruby-install/issues/375
28
+
29
+ in_fork do
30
+ require fixtures_dir("require/autoload_parent.rb")
31
+ parent = assert_node_in_parent("autoload_parent.rb", TOP_REQUIRE)
32
+
33
+ assert_node_in_parent("autoload_child.rb", parent)
34
+ end
14
35
  end
15
36
 
37
+ test "core extension profiles useage" do
38
+ in_fork do
39
+ require fixtures_dir("require/parent_one.rb")
40
+ parent = assert_node_in_parent("parent_one.rb", TOP_REQUIRE)
41
+ assert_node_in_parent("child_one.rb", parent)
42
+ child_two = assert_node_in_parent("child_two.rb", parent)
43
+ assert_node_in_parent("relative_child", parent)
44
+ assert_node_in_parent("relative_child_two", parent)
45
+ assert_node_in_parent("raise_child.rb", child_two)
46
+ end
47
+ end
48
+
49
+ # Checks to see that the given file name is present in the
50
+ # parent tree node and that the memory of that file
51
+ # is less than the parent (since the parent should include itself
52
+ # plus its children)
53
+ #
54
+ # Returns the child node
16
55
  def assert_node_in_parent(file_name, parent)
17
56
  file = fixtures_dir(File.join("require", file_name))
18
57
  node = parent[file]
19
- assert node, "Expected:\n#{parent.children}\nto include:\n#{file.inspect}"
20
- assert node.cost < parent.cost, "Expected:\n#{node.inspect}\nto cost less than:\n#{parent.inspect}" unless parent == TOP_REQUIRE
58
+ assert node, "Expected: #{parent.name} to include: #{file.to_s} but it did not.\nChildren: #{parent.children.map(&:name).map(&:to_s)}"
59
+ unless parent == TOP_REQUIRE
60
+ assert node.cost < parent.cost, "Expected: #{node.name.inspect} (#{node.cost}) to cost less than: #{parent.name.inspect} (#{parent.cost})"
61
+ end
21
62
  node
22
63
  end
23
64
 
24
- test "core extension profiles useage" do
25
- require fixtures_dir("require/parent_one.rb")
26
- parent = assert_node_in_parent("parent_one.rb", TOP_REQUIRE)
27
- assert_node_in_parent("child_one.rb", parent)
28
- child_two = assert_node_in_parent("child_two.rb", parent)
29
- assert_node_in_parent("relative_child", parent)
30
- assert_node_in_parent("relative_child_two", parent)
31
- assert_node_in_parent("raise_child.rb", child_two)
65
+ # Used to get semi-clean process memory
66
+ # It would be better to run the requires in a totally different process
67
+ # but...that would take engineering
68
+ #
69
+ # If I was going to do that, I would find a way to serialize RequireTree
70
+ # into a json structure with file names and costs, run the script
71
+ # dump the json to a file, then in this process read the file and
72
+ # run assertions
73
+ def in_fork
74
+ Tempfile.create("stdout") do |tmp_file|
75
+ pid = fork do
76
+ $stdout.reopen(tmp_file, "w")
77
+ $stderr.reopen(tmp_file, "w")
78
+ $stdout.sync = true
79
+ $stderr.sync = true
80
+ yield
81
+ Kernel.exit!(0) # needed for https://github.com/seattlerb/minitest/pull/683
82
+ end
83
+ Process.waitpid(pid)
84
+
85
+ if $?.success?
86
+ print File.read(tmp_file)
87
+ else
88
+ raise File.read(tmp_file)
89
+ end
90
+ end
32
91
  end
33
92
  end