rails_benchmark_suite 0.2.9 → 0.3.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -1
  3. data/CHANGELOG.md +65 -0
  4. data/Gemfile.lock +29 -1
  5. data/README.md +103 -10
  6. data/bin/rails_benchmark_suite +35 -12
  7. data/docs/images/report_v0_3_1.png +0 -0
  8. data/lib/dummy/app/models/benchmark_job.rb +7 -0
  9. data/lib/dummy/app/models/benchmark_post.rb +9 -0
  10. data/lib/dummy/app/models/benchmark_user.rb +9 -0
  11. data/lib/dummy/app/views/rails_benchmark_suite/heft_view.html.erb +11 -0
  12. data/lib/dummy/config/benchmark_database.yml +16 -0
  13. data/lib/rails_benchmark_suite/configuration.rb +22 -0
  14. data/lib/rails_benchmark_suite/database_manager.rb +56 -0
  15. data/lib/rails_benchmark_suite/db_setup.rb +3 -0
  16. data/lib/rails_benchmark_suite/models/user.rb +4 -3
  17. data/lib/rails_benchmark_suite/reporter.rb +215 -5
  18. data/lib/rails_benchmark_suite/reporters/html_reporter.rb +52 -0
  19. data/lib/rails_benchmark_suite/runner.rb +46 -191
  20. data/lib/rails_benchmark_suite/schema.rb +5 -5
  21. data/lib/rails_benchmark_suite/templates/report.html.erb +187 -0
  22. data/lib/rails_benchmark_suite/version.rb +1 -1
  23. data/lib/rails_benchmark_suite/workload_runner.rb +158 -0
  24. data/lib/rails_benchmark_suite/{suites/active_record_suite.rb → workloads/active_record_workload.rb} +7 -7
  25. data/lib/rails_benchmark_suite/{suites/cache_heft_suite.rb → workloads/cache_heft_workload.rb} +2 -2
  26. data/lib/rails_benchmark_suite/{suites/image_heft_suite.rb → workloads/image_heft_workload.rb} +3 -4
  27. data/lib/rails_benchmark_suite/{suites/job_heft_suite.rb → workloads/job_heft_workload.rb} +4 -4
  28. data/lib/rails_benchmark_suite/workloads/view_heft_workload.rb +36 -0
  29. data/lib/rails_benchmark_suite.rb +3 -22
  30. data/rails_benchmark_suite.gemspec +7 -2
  31. metadata +92 -10
  32. data/lib/rails_benchmark_suite/suites/view_heft_suite.rb +0 -44
@@ -1,15 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "tty-table"
5
+ require "tty-box"
6
+ require "pastel"
7
+ require "tty-cursor"
8
+
3
9
  module RailsBenchmarkSuite
4
10
  module Reporter
5
11
  module_function
6
12
 
13
+ def pastel
14
+ @pastel ||= Pastel.new
15
+ end
16
+
17
+ def cursor
18
+ @cursor ||= TTY::Cursor
19
+ end
20
+
7
21
  def system_info
8
22
  {
9
23
  ruby_version: RUBY_VERSION,
10
24
  platform: RUBY_PLATFORM,
11
- processors: Concurrent.processor_count,
12
- libvips: libvips?,
25
+ processors: defined?(Etc) ? Etc.nprocessors : Concurrent.processor_count,
13
26
  yjit: yjit_enabled?
14
27
  }
15
28
  end
@@ -18,9 +31,206 @@ module RailsBenchmarkSuite
18
31
  defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
19
32
  end
20
33
 
21
- def libvips?
22
- # Naive check for libvips presence
23
- system("vips --version", out: File::NULL, err: File::NULL)
34
+ def header(info)
35
+ print cursor.hide
36
+ # Build YJIT Status
37
+ yjit_status = info[:yjit] ? pastel.green("ON") : pastel.red("OFF")
38
+ yjit_hint = info[:yjit] ? "" : " (use RUBY_OPT=\"--yjit\")"
39
+
40
+ content = [
41
+ "System: #{info[:processors]} Cores | Ruby #{info[:ruby_version]}",
42
+ "DB: #{info[:db_mode] || 'SQLite (Memory)'} | YJIT: #{yjit_status}#{yjit_hint}"
43
+ ].join("\n")
44
+
45
+ print TTY::Box.frame(
46
+ width: 80,
47
+ title: { top_left: " Rails Benchmark Suite v#{RailsBenchmarkSuite::VERSION} " },
48
+ padding: 1,
49
+ style: {
50
+ fg: :white,
51
+ border: { fg: :bright_blue }
52
+ }
53
+ ) { content }
54
+ puts ""
55
+ end
56
+
57
+ def render(payload)
58
+ results = payload[:results]
59
+ total_score = payload[:total_score]
60
+ tier = payload[:tier]
61
+ threads = payload[:threads] || 4
62
+
63
+ puts ""
64
+
65
+ # 1. Comparison Table
66
+ rows = results.map do |data|
67
+ report = data[:report]
68
+ entries = report.entries
69
+
70
+ entry_1t = entries.find { |e| e.label.include?("(1 thread)") }
71
+ entry_mt = entries.find { |e| e.label.match?(/\(\d+ threads\)/) }
72
+
73
+ ips_1t = entry_1t ? entry_1t.ips : 0
74
+ ips_mt = entry_mt ? entry_mt.ips : 0
75
+
76
+ scaling = ips_1t > 0 ? (ips_mt / ips_1t) : 0
77
+ efficiency = (ips_mt / (ips_1t * threads)) * 100 if ips_1t > 0 && threads > 0
78
+ efficiency ||= 0
79
+
80
+ # Color coding
81
+ eff_color = if efficiency >= 75
82
+ :green
83
+ elsif efficiency >= 50
84
+ :yellow
85
+ else
86
+ :red
87
+ end
88
+
89
+ # Colorize scaling (Simple heuristic per user request)
90
+ scale_str = "%.2fx" % scaling
91
+ if scaling >= 1.0
92
+ scale_str = pastel.green(scale_str)
93
+ else
94
+ scale_str = pastel.red(scale_str)
95
+ end
96
+
97
+ [
98
+ data[:name],
99
+ humanize(ips_1t),
100
+ humanize(ips_mt),
101
+ scale_str,
102
+ pastel.decorate("#{efficiency.round(1)}%", eff_color),
103
+ data[:adjusted_weight].round(2)
104
+ ]
105
+ end
106
+
107
+ table = TTY::Table.new(
108
+ header: ["Workload", "1T IPS", "MaxT IPS", "Scaling", "Efficiency", "Weight"],
109
+ rows: rows
110
+ )
111
+
112
+ puts table.render(:unicode, padding: [0, 1]) do |renderer|
113
+ renderer.border.separator = :each_row
114
+ renderer.border.style = :blue
115
+ end
116
+
117
+ # 2. Insights List
118
+ puts ""
119
+ check_scaling_insights(results)
120
+ check_yjit_insight
121
+ check_memory_insights(results)
122
+
123
+ # 3. Final Score Dashboard
124
+ render_final_score(total_score)
125
+ show_hardware_tier(tier)
126
+ end
127
+
128
+ def check_scaling_insights(results)
129
+ poor_scaling = results.select do |r|
130
+ entries = r[:report].entries
131
+ entry_1t = entries.find { |e| e.label.include?("(1 thread)") }
132
+ entry_mt = entries.find { |e| e.label.match?(/\(\d+ threads\)/) }
133
+
134
+ ips_1t = entry_1t ? entry_1t.ips : 0
135
+ ips_mt = entry_mt ? entry_mt.ips : 0
136
+ scaling = ips_1t > 0 ? (ips_mt / ips_1t) : 0
137
+ scaling < 0.8
138
+ end
139
+
140
+ if poor_scaling.any?
141
+ puts pastel.yellow.bold("💡 Insight (Scaling):") + " Scaling below 1.0x detected."
142
+ puts " This indicates SQLite lock contention or Ruby GIL saturation."
143
+ end
144
+ end
145
+
146
+ def check_yjit_insight
147
+ unless yjit_enabled?
148
+ puts ""
149
+ puts pastel.yellow.bold("💡 Insight (YJIT):") + " YJIT is OFF."
150
+ puts " Run with RUBY_OPT=\"--yjit\" for ~20% boost."
151
+ end
152
+ end
153
+
154
+ def check_memory_insights(results)
155
+ results.select { |r| r[:memory_delta_mb] > 20 }.each do |r|
156
+ puts ""
157
+ puts pastel.yellow.bold("💡 Insight (Memory):") + " High growth in #{r[:name]} (#{r[:memory_delta_mb].round(1)}MB)"
158
+ puts " Suggests heavy object allocation."
159
+ end
160
+ end
161
+
162
+ def show_hardware_tier(tier)
163
+ comparison = case tier
164
+ when "Entry/Dev"
165
+ "Entry-Level (Suitable for dev/testing)"
166
+ when "Production-Ready"
167
+ "Professional-Grade (Matches dedicated instances)"
168
+ else
169
+ "High-Performance (Bare-metal speed)"
170
+ end
171
+
172
+ puts ""
173
+ puts pastel.bold("📊 Performance Tier: ") + comparison
174
+ puts ""
175
+ print cursor.show
176
+ end
177
+
178
+ def render_final_score(score)
179
+ score_str = "#{score.round(0)}"
180
+ puts ""
181
+
182
+ print TTY::Box.frame(
183
+ width: 40,
184
+ height: 5,
185
+ align: :center,
186
+ padding: 1,
187
+ title: { top_left: " RAILS HEFT INDEX " },
188
+ style: { border: { fg: :green }, fg: :green }
189
+ ) {
190
+ pastel.bold(score_str)
191
+ }
192
+ end
193
+
194
+ def as_json(payload)
195
+ out = {
196
+ system: system_info,
197
+ total_score: payload[:total_score].round(0),
198
+ tier: payload[:tier],
199
+ workloads: []
200
+ }
201
+
202
+ payload[:results].each do |data|
203
+ entries = data[:report].entries
204
+ entry_1t = entries.find { |e| e.label.include?("(1 thread)") }
205
+ entry_mt = entries.find { |e| e.label.match?(/\(\d+ threads\)/) }
206
+
207
+ ips_1t = entry_1t ? entry_1t.ips : 0
208
+ ips_mt = entry_mt ? entry_mt.ips : 0
209
+
210
+ out[:workloads] << {
211
+ name: data[:name],
212
+ adjusted_weight: data[:adjusted_weight],
213
+ ips_1t: ips_1t,
214
+ ips_mt: ips_mt,
215
+ threads: payload[:threads],
216
+ scaling: ips_1t > 0 ? (ips_mt / ips_1t) : 0,
217
+ efficiency: data[:efficiency],
218
+ memory_delta_mb: data[:memory_delta_mb]
219
+ }
220
+ end
221
+
222
+ puts out.to_json
223
+ end
224
+
225
+ def humanize(ips)
226
+ return "0" if ips.nil? || ips == 0
227
+ if ips >= 1_000_000
228
+ "#{(ips / 1_000_000.0).round(1)}M"
229
+ elsif ips >= 1_000
230
+ "#{(ips / 1_000.0).round(1)}k"
231
+ else
232
+ ips.round(1).to_s
233
+ end
24
234
  end
25
235
  end
26
236
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module RailsBenchmarkSuite
8
+ module Reporters
9
+ class HtmlReporter
10
+ def initialize(payload)
11
+ @payload = payload
12
+ end
13
+
14
+ def generate
15
+ template_path = File.expand_path("../templates/report.html.erb", __dir__)
16
+ template = File.read(template_path)
17
+
18
+ # Prepare data for JS injection (Flatten complex objects to simple Hash)
19
+ chart_data = {
20
+ labels: @payload[:results].map { |r| r[:name] },
21
+ data_1t: [],
22
+ data_mt: []
23
+ }
24
+
25
+ @payload[:results].each do |res|
26
+ entry_1t = res[:report].entries.find { |e| e.label.include?("(1 thread)") }
27
+ entry_mt = res[:report].entries.find { |e| e.label.match?(/\(\d+ threads\)/) }
28
+
29
+ chart_data[:data_1t] << (entry_1t ? entry_1t.ips : 0)
30
+ chart_data[:data_mt] << (entry_mt ? entry_mt.ips : 0)
31
+ end
32
+
33
+ @chart_payload = chart_data.to_json
34
+
35
+ # Render
36
+ html = ERB.new(template).result(binding)
37
+
38
+ # Output file
39
+ dir = Dir.exist?("tmp") ? "tmp" : "."
40
+ file_path = File.join(dir, "rails_benchmark_report.html")
41
+ File.write(file_path, html)
42
+
43
+ puts "\n"
44
+ puts "✅ HTML Report Generated!"
45
+ puts "📂 Location: #{File.expand_path(file_path)}"
46
+ puts "👉 View (Local): open '#{file_path}'"
47
+ puts "👉 View (Remote): scp user@server:#{File.expand_path(file_path)} ."
48
+ puts "\n"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,211 +1,66 @@
1
- # frozen_string_literal: true
2
-
3
- require "benchmark/ips"
4
- require "get_process_mem"
1
+ require_relative "database_manager"
2
+ require_relative "workload_runner"
3
+ require_relative "reporter"
4
+ require_relative "schema"
5
+ require_relative "../dummy/app/models/benchmark_user"
6
+ require_relative "../dummy/app/models/benchmark_post"
7
+ require_relative "../dummy/app/models/benchmark_job"
5
8
 
6
9
  module RailsBenchmarkSuite
7
10
  class Runner
8
- def initialize(suites, json: false)
9
- @suites = suites
10
- @json_output = json
11
- end
11
+ # Registry for workloads
12
+ @workloads = []
12
13
 
13
- def register(name, &block)
14
- @suites << { name: name, block: block }
14
+ def self.register_workload(name, weight: 1.0, &block)
15
+ @workloads << { name: name, weight: weight, block: block }
15
16
  end
16
17
 
17
- SETUP_MUTEX = Mutex.new
18
-
19
- def run
20
- # Ultimate Hardening: Massive pool and timeout for zero lock contention (v0.2.9)
21
- ActiveRecord::Base.establish_connection(
22
- adapter: "sqlite3",
23
- database: "file:heft_db?mode=memory&cache=shared",
24
- pool: 50,
25
- timeout: 30000
26
- )
27
-
28
- # The 'Busy Timeout' Hammer - force it directly on the raw connection
29
- ActiveRecord::Base.connection.raw_connection.busy_timeout = 10000
30
-
31
- # Setup Schema once safely with Mutex
32
- SETUP_MUTEX.synchronize do
33
- # Verify if schema already loaded by checking for a table
34
- unless ActiveRecord::Base.connection.table_exists?(:users)
35
- RailsBenchmarkSuite::Schema.load
36
- end
37
- end
38
-
39
- # High-Performance Pragmas for WAL + NORMAL sync
40
- ActiveRecord::Base.connection.raw_connection.execute("PRAGMA journal_mode = WAL")
41
- ActiveRecord::Base.connection.raw_connection.execute("PRAGMA synchronous = NORMAL")
42
- ActiveRecord::Base.connection.raw_connection.execute("PRAGMA mmap_size = 268435456") # 256MB - reduce disk I/O
43
-
44
- puts "Running RailsBenchmarkSuite Benchmarks..." unless @json_output
45
- puts system_report unless @json_output
46
- puts "\n" unless @json_output
47
-
48
- results = {}
49
-
50
- @suites.each do |suite|
51
- puts "== Running Suite: #{suite[:name]} ==" unless @json_output
52
-
53
- # Capture memory before
54
- mem_before = GetProcessMem.new.mb
55
-
56
- # Run benchmark
57
- report = Benchmark.ips do |x|
58
- x.config(:time => 5, :warmup => 2)
59
-
60
- # Single Threaded
61
- x.report("#{suite[:name]} (1 thread)") do
62
- with_retries { suite[:block].call }
63
- end
64
-
65
- # Multi Threaded (4 threads)
66
- x.report("#{suite[:name]} (4 threads)") do
67
- threads = 4.times.map do
68
- Thread.new do
69
- # Ensure each thread gets a dedicated connection
70
- ActiveRecord::Base.connection_pool.with_connection do
71
- with_retries { suite[:block].call }
72
- end
73
- end
74
- end
75
- threads.each(&:join)
76
- end
77
-
78
- x.compare!
79
- end
80
-
81
- # Capture memory after
82
- mem_after = GetProcessMem.new.mb
83
-
84
- results[suite[:name]] = {
85
- report: report,
86
- memory_delta_mb: mem_after - mem_before,
87
- weight: suite[:weight]
88
- }
89
-
90
- puts "Memory Footprint: #{mem_after.round(2)} MB (+#{(mem_after - mem_before).round(2)} MB)" unless @json_output
91
- puts "\n" unless @json_output
92
- end
93
-
94
- print_summary(results)
95
- results
18
+ def initialize(config)
19
+ @config = config
96
20
  end
97
21
 
98
- private
99
-
100
- def with_retries
101
- yield
102
- rescue ActiveRecord::StatementInvalid => e
103
- if e.message =~ /locked/i
104
- # Specifically drop the lock for THIS connection only
105
- ActiveRecord::Base.connection.reset!
106
- sleep(rand(0.01..0.05))
107
- retry
108
- else
109
- raise e
110
- end
111
- end
112
-
113
- def system_report
114
- info = RailsBenchmarkSuite::Reporter.system_info
115
- yjit_status = if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
116
- "Enabled"
117
- else
118
- "Disabled (Requires Ruby with YJIT support for best results)"
119
- end
120
- "System: Ruby #{info[:ruby_version]} (#{info[:platform]}), #{info[:processors]} Cores. YJIT: #{yjit_status}. Libvips: #{info[:libvips]}"
121
- end
122
-
123
- def print_summary(results)
124
- if @json_output
125
- print_json(results)
126
- return
22
+ def run
23
+ # Load workloads dynamically if not already loaded (idempotent)
24
+ if Runner.instance_variable_get(:@workloads).empty?
25
+ Dir[File.join(__dir__, "workloads", "*.rb")].each { |f| require f }
127
26
  end
128
27
 
129
- puts "\n"
130
- puts "=========================================================================================="
131
- puts "| %-25s | %-25s | %-12s | %-15s |" % ["Suite", "IPS (1t / 4t)", "Scaling", "Mem Delta"]
132
- puts "=========================================================================================="
28
+ # 1. Setup Database
29
+ DatabaseManager.new.setup(use_local_db: @config.db)
133
30
 
134
- total_score = 0
135
-
136
- results.each do |name, data|
137
- report = data[:report]
138
- entries = report.entries
139
-
140
- entry_1t = entries.find { |e| e.label.include?("(1 thread)") }
141
- entry_4t = entries.find { |e| e.label.include?("(4 threads)") }
142
-
143
- ips_1t = entry_1t ? entry_1t.ips : 0
144
- ips_4t = entry_4t ? entry_4t.ips : 0
145
-
146
- scaling = ips_1t > 0 ? (ips_4t / ips_1t) : 0
147
- mem = data[:memory_delta_mb]
148
-
149
- # Heft Score: Weighted Sum of 4t IPS
150
- weight = data[:weight] || 1.0
151
- weighted_score = ips_4t * weight
152
- total_score += weighted_score
153
-
154
- puts "| %-25s | %-25s | x%-11.2f | +%-14.2fMB |" % [
155
- name + " (w: #{weight})",
156
- "#{humanize(ips_1t)} / #{humanize(ips_4t)}",
157
- scaling,
158
- mem
159
- ]
160
- end
161
- puts "=========================================================================================="
162
- puts "\n"
163
- puts " >>> FINAL HEFT SCORE: #{total_score.round(0)} <<<"
164
- puts "\n"
165
- end
166
-
167
- def print_json(results)
168
- require "json"
31
+ # 2. Display Header
32
+ header_info = Reporter.system_info.merge(
33
+ threads: @config.threads,
34
+ db_mode: @config.db_mode
35
+ )
36
+ Reporter.header(header_info) unless @config.json
169
37
 
170
- out = {
171
- system: RailsBenchmarkSuite::Reporter.system_info,
172
- total_score: 0,
173
- suites: []
38
+ # 3. Execute Workloads
39
+ # Passing config values as options to WorkloadRunner for compatibility
40
+ # Ideally we'd pass the config object but WorkloadRunner expects a hash currently
41
+ # We will refactor WorkloadRunner to accept config later or wrap it here
42
+ runner_options = {
43
+ threads: @config.threads,
44
+ profile: @config.profile
174
45
  }
175
46
 
176
- total_score = 0
47
+ payload = WorkloadRunner.new(
48
+ Runner.instance_variable_get(:@workloads),
49
+ options: runner_options,
50
+ show_progress: !@config.json
51
+ ).execute
177
52
 
178
- results.each do |name, data|
179
- weight = data[:weight] || 1.0
180
-
181
- # Parse reports
182
- ips_1t = data[:report].entries.find { |e| e.label.include?("(1 thread)") }&.ips || 0
183
- ips_4t = data[:report].entries.find { |e| e.label.include?("(4 threads)") }&.ips || 0
184
-
185
- weighted_score = ips_4t * weight
186
- total_score += weighted_score
187
-
188
- out[:suites] << {
189
- name: name,
190
- weight: weight,
191
- ips_1t: ips_1t,
192
- ips_4t: ips_4t,
193
- scaling: ips_1t > 0 ? (ips_4t / ips_1t) : 0,
194
- memory_delta_mb: data[:memory_delta_mb],
195
- score: weighted_score
196
- }
53
+ # 4. Report Results
54
+ if @config.json
55
+ Reporter.as_json(payload)
56
+ else
57
+ Reporter.render(payload)
197
58
  end
198
-
199
- out[:total_score] = total_score.round(0)
200
- puts out.to_json
201
- end
202
59
 
203
- def humanize(ips)
204
- return "0" if ips.nil?
205
- if ips > 1000
206
- "%.1fk" % (ips / 1000.0)
207
- else
208
- "%.1f" % ips
60
+ # 5. HTML Report Generation
61
+ if @config.html
62
+ require_relative "reporters/html_reporter"
63
+ Reporters::HtmlReporter.new(payload).generate
209
64
  end
210
65
  end
211
66
  end
@@ -6,16 +6,16 @@ module RailsBenchmarkSuite
6
6
  module Schema
7
7
  def self.load
8
8
  ActiveRecord::Schema.define do
9
- # Users
10
- create_table :users, force: true do |t|
9
+ # BenchmarkUsers
10
+ create_table :benchmark_users, force: true do |t|
11
11
  t.string :name
12
12
  t.string :email
13
13
  t.timestamps
14
14
  end
15
15
 
16
- # Posts
17
- create_table :posts, force: true do |t|
18
- t.references :user
16
+ # BenchmarkPosts
17
+ create_table :benchmark_posts, force: true do |t|
18
+ t.references :benchmark_user, foreign_key: { to_table: :benchmark_users }
19
19
  t.string :title
20
20
  t.text :body
21
21
  t.integer :views, default: 0