Skip to content

Commit c54532a

Browse files
authored
Merge pull request #2066 from yosiat/benchmarks
[0.9] Benchmarks [ci skip]
2 parents 1e04d11 + 4fdb414 commit c54532a

File tree

6 files changed

+418
-0
lines changed

6 files changed

+418
-0
lines changed

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ tzinfo_platforms = @windows_platforms
4545
tzinfo_platforms += [:jruby] if version >= '4.1'
4646
gem 'tzinfo-data', platforms: tzinfo_platforms
4747

48+
group :bench do
49+
gem 'benchmark-ips', '>= 2.7.2'
50+
end
51+
4852
group :test do
4953
gem 'sqlite3', platform: (@windows_platforms + [:ruby])
5054
gem 'activerecord-jdbcsqlite3-adapter', platform: :jruby

bin/bench

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env ruby
2+
# ActiveModelSerializers Benchmark driver
3+
# Adapted from
4+
# https://github.com/ruby-bench/ruby-bench-suite/blob/8ad567f7e43a044ae48c36833218423bb1e2bd9d/rails/benchmarks/driver.rb
5+
require 'bundler'
6+
Bundler.setup
7+
require 'json'
8+
require 'pathname'
9+
require 'optparse'
10+
require 'digest'
11+
require 'pathname'
12+
require 'shellwords'
13+
require 'logger'
14+
require 'English'
15+
16+
class BenchmarkDriver
17+
ROOT = Pathname File.expand_path(File.join('..', '..'), __FILE__)
18+
BASE = ENV.fetch('BASE') { ROOT.join('test', 'benchmark') }
19+
ESCAPED_BASE = Shellwords.shellescape(BASE)
20+
21+
def self.benchmark(options)
22+
new(options).run
23+
end
24+
25+
def self.parse_argv_and_run(argv = ARGV, options = {})
26+
options = {
27+
repeat_count: 1,
28+
pattern: [],
29+
env: 'CACHE_ON=on'
30+
}.merge!(options)
31+
32+
OptionParser.new do |opts|
33+
opts.banner = 'Usage: bin/bench [options]'
34+
35+
opts.on('-r', '--repeat-count [NUM]', 'Run benchmarks [NUM] times taking the best result') do |value|
36+
options[:repeat_count] = value.to_i
37+
end
38+
39+
opts.on('-p', '--pattern <PATTERN1,PATTERN2,PATTERN3>', 'Benchmark name pattern') do |value|
40+
options[:pattern] = value.split(',')
41+
end
42+
43+
opts.on('-e', '--env <var1=val1,var2=val2,var3=vale>', 'ENV variables to pass in') do |value|
44+
options[:env] = value.split(',')
45+
end
46+
end.parse!(argv)
47+
48+
benchmark(options)
49+
end
50+
51+
attr_reader :commit_hash, :base
52+
53+
# Based on logfmt:
54+
# https://www.brandur.org/logfmt
55+
# For more complete implementation see:
56+
# see https://github.com/arachnid-cb/logfmtr/blob/master/lib/logfmtr/base.rb
57+
# For usage see:
58+
# https://blog.codeship.com/logfmt-a-log-format-thats-easy-to-read-and-write/
59+
# https://engineering.heroku.com/blogs/2014-09-05-hutils-explore-your-structured-data-logs/
60+
# For Ruby parser see:
61+
# https://github.com/cyberdelia/logfmt-ruby
62+
def self.summary_logger(device = 'output.txt')
63+
require 'time'
64+
logger = Logger.new(device)
65+
logger.level = Logger::INFO
66+
logger.formatter = proc { |severity, datetime, progname, msg|
67+
msg = "'#{msg}'"
68+
"level=#{severity} time=#{datetime.utc.iso8601(6)} pid=#{Process.pid} progname=#{progname} msg=#{msg}#{$INPUT_RECORD_SEPARATOR}"
69+
}
70+
logger
71+
end
72+
73+
def self.stdout_logger
74+
logger = Logger.new(STDOUT)
75+
logger.level = Logger::INFO
76+
logger.formatter = proc { |_, _, _, msg| "#{msg}#{$INPUT_RECORD_SEPARATOR}" }
77+
logger
78+
end
79+
80+
def initialize(options)
81+
@writer = ENV['SUMMARIZE'] ? self.class.summary_logger : self.class.stdout_logger
82+
@repeat_count = options[:repeat_count]
83+
@pattern = options[:pattern]
84+
@commit_hash = options.fetch(:commit_hash) { `git rev-parse --short HEAD`.chomp }
85+
@base = options.fetch(:base) { ESCAPED_BASE }
86+
@env = Array(options[:env]).join(' ')
87+
@rubyopt = options[:rubyopt] # TODO: rename
88+
end
89+
90+
def run
91+
files.each do |path|
92+
next if !@pattern.empty? && /#{@pattern.join('|')}/ !~ File.basename(path)
93+
run_single(Shellwords.shellescape(path))
94+
end
95+
end
96+
97+
private
98+
99+
def files
100+
Dir[File.join(base, 'bm_*')]
101+
end
102+
103+
def run_single(path)
104+
script = "RAILS_ENV=production #{@env} ruby #{@rubyopt} #{path}"
105+
environment = `ruby -v`.chomp.strip[/\d+\.\d+\.\d+\w+/]
106+
107+
runs_output = measure(script)
108+
if runs_output.empty?
109+
results = { error: :no_results }
110+
return
111+
end
112+
113+
results = {}
114+
results['commit_hash'] = commit_hash
115+
results['version'] = runs_output.first['version']
116+
results['rails_version'] = runs_output.first['rails_version']
117+
results['benchmark_run[environment]'] = environment
118+
results['runs'] = []
119+
120+
runs_output.each do |output|
121+
results['runs'] << {
122+
'benchmark_type[category]' => output['label'],
123+
'benchmark_run[result][iterations_per_second]' => output['iterations_per_second'].round(3),
124+
'benchmark_run[result][total_allocated_objects_per_iteration]' => output['total_allocated_objects_per_iteration']
125+
}
126+
end
127+
ensure
128+
results && report(results)
129+
end
130+
131+
def report(results)
132+
@writer.info { 'Benchmark results:' }
133+
@writer.info { JSON.pretty_generate(results) }
134+
end
135+
136+
def summarize(result)
137+
puts "#{result['label']} #{result['iterations_per_second']}/ips; #{result['total_allocated_objects_per_iteration']} objects"
138+
end
139+
140+
# FIXME: ` provides the full output but it'll return failed output as well.
141+
def measure(script)
142+
results = Hash.new { |h, k| h[k] = [] }
143+
144+
@repeat_count.times do
145+
output = sh(script)
146+
output.each_line do |line|
147+
next if line.nil?
148+
begin
149+
result = JSON.parse(line)
150+
rescue JSON::ParserError
151+
result = { error: line } # rubocop:disable Lint/UselessAssignment
152+
else
153+
summarize(result)
154+
results[result['label']] << result
155+
end
156+
end
157+
end
158+
159+
results.map do |_, bm_runs|
160+
bm_runs.sort_by do |run|
161+
run['iterations_per_second']
162+
end.last
163+
end
164+
end
165+
166+
def sh(cmd)
167+
`#{cmd}`
168+
end
169+
end
170+
171+
BenchmarkDriver.parse_argv_and_run if $PROGRAM_NAME == __FILE__

test/benchmark/app.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# https://github.com/rails-api/active_model_serializers/pull/872
2+
# approx ref 792fb8a9053f8db3c562dae4f40907a582dd1720 to test against
3+
require 'bundler/setup'
4+
5+
require 'rails'
6+
require 'active_model'
7+
require 'active_support'
8+
require 'active_support/json'
9+
require 'action_controller'
10+
require 'action_controller/test_case'
11+
require 'action_controller/railtie'
12+
abort "Rails application already defined: #{Rails.application.class}" if Rails.application
13+
14+
class NullLogger < Logger
15+
def initialize(*_args)
16+
end
17+
18+
def add(*_args, &_block)
19+
end
20+
end
21+
class BenchmarkLogger < ActiveSupport::Logger
22+
def initialize
23+
@file = StringIO.new
24+
super(@file)
25+
end
26+
27+
def messages
28+
@file.rewind
29+
@file.read
30+
end
31+
end
32+
# ref: https://gist.github.com/bf4/8744473
33+
class BenchmarkApp < Rails::Application
34+
# Set up production configuration
35+
config.eager_load = true
36+
config.cache_classes = true
37+
# CONFIG: CACHE_ON={on,off}
38+
config.action_controller.perform_caching = ENV['CACHE_ON'] != 'off'
39+
config.action_controller.cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
40+
41+
config.active_support.test_order = :random
42+
config.secret_token = 'S' * 30
43+
config.secret_key_base = 'abc123'
44+
config.consider_all_requests_local = false
45+
46+
# otherwise deadlock occurred
47+
config.middleware.delete 'Rack::Lock'
48+
49+
# to disable log files
50+
config.logger = NullLogger.new
51+
config.active_support.deprecation = :log
52+
config.log_level = :info
53+
end
54+
55+
require 'active_model_serializers'
56+
57+
# Initialize app before any serializers are defined, for running across revisions.
58+
# ref: https://github.com/rails-api/active_model_serializers/pull/1478
59+
Rails.application.initialize!
60+
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require 'benchmark/ips'
2+
require 'json'
3+
4+
# Add benchmarking runner from ruby-bench-suite
5+
# https://github.com/ruby-bench/ruby-bench-suite/blob/master/rails/benchmarks/support/benchmark_rails.rb
6+
module Benchmark
7+
module ActiveModelSerializers
8+
module TestMethods
9+
def request(method, path)
10+
response = Rack::MockRequest.new(BenchmarkApp).send(method, path)
11+
if response.status.in?([404, 500])
12+
fail "omg, #{method}, #{path}, '#{response.status}', '#{response.body}'"
13+
end
14+
response
15+
end
16+
end
17+
18+
# extend Benchmark with an `ams` method
19+
def ams(label = nil, time:, disable_gc: true, warmup: 3, &block)
20+
fail ArgumentError.new, 'block should be passed' unless block_given?
21+
22+
if disable_gc
23+
GC.disable
24+
else
25+
GC.enable
26+
end
27+
28+
report = Benchmark.ips(time, warmup, true) do |x|
29+
x.report(label) { yield }
30+
end
31+
32+
entry = report.entries.first
33+
34+
output = {
35+
label: label,
36+
version: ::ActiveModel::Serializer::VERSION.to_s,
37+
rails_version: ::Rails.version.to_s,
38+
iterations_per_second: entry.ips,
39+
iterations_per_second_standard_deviation: entry.error_percentage,
40+
total_allocated_objects_per_iteration: count_total_allocated_objects(&block)
41+
}.to_json
42+
43+
puts output
44+
output
45+
end
46+
47+
def count_total_allocated_objects
48+
if block_given?
49+
key =
50+
if RUBY_VERSION < '2.2'
51+
:total_allocated_object
52+
else
53+
:total_allocated_objects
54+
end
55+
56+
before = GC.stat[key]
57+
yield
58+
after = GC.stat[key]
59+
after - before
60+
else
61+
-1
62+
end
63+
end
64+
end
65+
66+
extend Benchmark::ActiveModelSerializers
67+
end

test/benchmark/bm_active_record.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
require_relative './benchmarking_support'
2+
require_relative './app'
3+
require_relative './setup'
4+
5+
time = 10
6+
disable_gc = true
7+
8+
9+
10+
authors_query = Author.preload(:posts).preload(:profile)
11+
author = authors_query.first
12+
authors = authors_query.to_a
13+
14+
15+
Benchmark.ams('Single: DefaultSerializer', time: time, disable_gc: disable_gc) do
16+
ActiveModel::DefaultSerializer.new(author).to_json
17+
end
18+
19+
Benchmark.ams('ArraySerializer', time: time, disable_gc: disable_gc) do
20+
ActiveModel::ArraySerializer.new(authors).to_json
21+
end
22+
23+
Benchmark.ams('ArraySerializer: each_serializer: DefaultSerializer', time: time, disable_gc: disable_gc) do
24+
ActiveModel::ArraySerializer.new(authors, each_serializer:ActiveModel::DefaultSerializer).to_json
25+
end
26+
27+
Benchmark.ams('FlatAuthorSerializer', time: time, disable_gc: disable_gc) do
28+
FlatAuthorSerializer.new(author).to_json
29+
end
30+
31+
Benchmark.ams('ArraySerializer: each_serializer: FlatAuthorSerializer', time: time, disable_gc: disable_gc) do
32+
ActiveModel::ArraySerializer.new(authors, each_serializer: FlatAuthorSerializer).to_json
33+
end
34+
35+
Benchmark.ams('AuthorWithDefaultRelationshipsSerializer', time: time, disable_gc: disable_gc) do
36+
AuthorWithDefaultRelationshipsSerializer.new(author).to_json
37+
end
38+
39+
Benchmark.ams('ArraySerializer: each_serializer: AuthorWithDefaultRelationshipsSerializer', time: time, disable_gc: disable_gc) do
40+
ActiveModel::ArraySerializer.new(authors, each_serializer: AuthorWithDefaultRelationshipsSerializer).to_json
41+
end

0 commit comments

Comments
 (0)