discourse/script/bench.rb
Loïc Guitaut 581fb97bfa DEV: Fix benchmark script
Following a recent commit (cb4b8146a3),
the benchmark script wasn’t working anymore (and the related rake task).

This patch fixes it. It also adds some information about Ruby YJIT being
enabled or not.
2024-11-20 14:36:44 +01:00

353 lines
9.4 KiB
Ruby

# frozen_string_literal: true
require "socket"
require "csv"
require "yaml"
require "optparse"
require "fileutils"
require "net/http"
require "uri"
@include_env = false
@result_file = nil
@iterations = 500
@best_of = 1
@mem_stats = false
@unicorn = false
@dump_heap = false
@concurrency = 1
@skip_asset_bundle = false
@unicorn_workers = 3
opts =
OptionParser.new do |o|
o.banner = "Usage: ruby bench.rb [options]"
o.on("-n", "--with_default_env", "Include recommended Discourse env") { @include_env = true }
o.on("-o", "--output [FILE]", "Output results to this file") { |f| @result_file = f }
o.on("-i", "--iterations [ITERATIONS]", "Number of iterations to run the bench for") do |i|
@iterations = i.to_i
end
o.on("-b", "--best_of [NUM]", "Number of times to run the bench taking best as result") do |i|
@best_of = i.to_i
end
o.on("-d", "--heap_dump") do
@dump_heap = true
# We need an env var for config/boot.rb to enable allocation tracing prior to framework init
ENV["DISCOURSE_DUMP_HEAP"] = "1"
end
o.on("-m", "--memory_stats") { @mem_stats = true }
o.on(
"-c",
"--concurrency [NUM]",
"Run benchmark with this number of concurrent requests (default: 1)",
) { |i| @concurrency = i.to_i }
o.on(
"-w",
"--unicorn_workers [NUM]",
"Run benchmark with this number of unicorn workers (default: 3)",
) { |i| @unicorn_workers = i.to_i }
o.on("-s", "--skip-bundle-assets", "Skip bundling assets") { @skip_asset_bundle = true }
o.on(
"-t",
"--tests [STRING]",
"List of tests to run. Example: '--tests topic,categories')",
) { |i| @tests = i.split(",") }
end
opts.parse!
def run(command, opt = nil)
exit_status =
if opt == :quiet
system(command, out: "/dev/null", err: :out)
else
system(command, out: $stdout, err: :out)
end
abort("Command '#{command}' failed with exit status #{$?}") unless exit_status
end
begin
require "facter"
raise LoadError if Gem::Version.new(Facter.version) < Gem::Version.new("4.0")
rescue LoadError
run "gem install facter"
puts "please rerun script"
exit
end
@timings = {}
def measure(name)
start = Time.now
yield
@timings[name] = ((Time.now - start) * 1000).to_i
end
def prereqs
puts "Be sure to following packages are installed:
sudo apt-get -y install build-essential libssl-dev libyaml-dev git libtool libxslt-dev libxml2-dev libpq-dev gawk curl pngcrush python-software-properties software-properties-common tasksel
sudo tasksel install postgresql-server
OR
apt-get install postgresql-server^
sudo apt-add-repository -y ppa:rwky/redis
sudo apt-get update
sudo apt-get install redis-server
"
end
puts "Running bundle"
if run("bundle", :quiet)
puts "Quitting, some of the gems did not install"
prereqs
exit
end
puts "Ensuring config is setup"
`which ab > /dev/null 2>&1`
unless $? == 0
abort "Apache Bench is not installed. Try: apt-get install apache2-utils or brew install ab"
end
unless File.exist?("config/database.yml")
puts "Copying database.yml.development.sample to database.yml"
`cp config/database.yml.development-sample config/database.yml`
end
ENV["RAILS_ENV"] = "profile"
discourse_env_vars = %w[
DISCOURSE_DUMP_HEAP
RUBY_GC_HEAP_INIT_SLOTS
RUBY_GC_HEAP_FREE_SLOTS
RUBY_GC_HEAP_GROWTH_FACTOR
RUBY_GC_HEAP_GROWTH_MAX_SLOTS
RUBY_GC_MALLOC_LIMIT
RUBY_GC_OLDMALLOC_LIMIT
RUBY_GC_MALLOC_LIMIT_MAX
RUBY_GC_OLDMALLOC_LIMIT_MAX
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR
RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
LD_PRELOAD
]
if @include_env
puts "Running with tuned environment"
discourse_env_vars.each { |v| ENV.delete v }
ENV["RUBY_GC_HEAP_GROWTH_MAX_SLOTS"] = "40000"
ENV["RUBY_GC_HEAP_INIT_SLOTS"] = "400000"
ENV["RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR"] = "1.5"
else
# clean env
puts "Running with the following custom environment"
end
discourse_env_vars.each { |w| puts "#{w}: #{ENV[w]}" if ENV[w].to_s.length > 0 }
def port_available?(port)
server = TCPServer.open("0.0.0.0", port)
server.close
true
rescue Errno::EADDRINUSE
false
end
@port = 60_079
@port += 1 while !port_available? @port
puts "Ensuring profiling DB exists and is migrated"
puts `bundle exec rake db:create`
`bundle exec rake db:migrate`
puts "Timing loading Rails"
measure("load_rails") { `bundle exec rails middleware` }
puts "Populating Profile DB"
run("bundle exec ruby script/profile_db_generator.rb")
puts "Getting admin api key"
admin_api_key = `bundle exec rake api_key:create_master[bench]`.split("\n")[-1]
raise "Failed to obtain a user API key" if admin_api_key.to_s.empty?
puts "Getting user api key"
user_api_key = `bundle exec rake user_api_key:create[user1]`.split("\n")[-1]
raise "Failed to obtain a user API key" if user_api_key.to_s.empty?
def bench(path, name, headers)
puts "Running apache bench warmup"
add = ""
add = "-c #{@concurrency} " if @concurrency > 1
header_string = headers&.map { |k, v| "-H \"#{k}:#{v}\"" }&.join(" ")
`ab #{add} #{header_string} -n 20 -l "http://127.0.0.1:#{@port}#{path}"`
puts "Benchmarking #{name} @ #{path}"
`ab #{add} #{header_string} -n #{@iterations} -l -e tmp/ab.csv "http://127.0.0.1:#{@port}#{path}"`
percentiles = Hash[*[50, 75, 90, 99].zip([]).flatten]
CSV.foreach("tmp/ab.csv") do |percent, time|
percentiles[percent.to_i] = time.to_i if percentiles.key? percent.to_i
end
percentiles
end
begin
# critical cause cache may be incompatible
unless @skip_asset_bundle
puts "precompiling assets"
run("bundle exec rake assets:precompile")
end
ENV["UNICORN_PORT"] = @port.to_s
ENV["UNICORN_WORKERS"] = @unicorn_workers.to_s
FileUtils.mkdir_p(File.join("tmp", "pids"))
pid = spawn("bundle exec unicorn -c config/unicorn.conf.rb")
while (
unicorn_master_pid =
`ps aux | grep "unicorn master" | grep -v "grep" | awk '{print $2}'`.strip.to_i
) == 0
sleep 1
end
while `pgrep -P #{unicorn_master_pid}`.split("\n").map(&:to_i).size != @unicorn_workers.to_i
sleep 1
end
puts "Starting benchmark..."
admin_headers = { "Api-Key" => admin_api_key, "Api-Username" => "admin1" }
user_headers = { "User-Api-Key" => user_api_key }
# asset precompilation is a dog, wget to force it
run "curl -s -o /dev/null http://127.0.0.1:#{@port}/"
redirect_response = `curl -s -I "http://127.0.0.1:#{@port}/t/i-am-a-topic-used-for-perf-tests"`
raise "Unable to locate topic for perf tests" if redirect_response !~ /301 Moved Permanently/
topic_url =
redirect_response.match(%r{^location: .+(/t/i-am-a-topic-used-for-perf-tests/.+)$}i)[1].strip
all_tests = [
%w[categories /categories],
%w[home /],
["topic", topic_url],
["topic.json", "#{topic_url}.json"],
["user activity", "/u/admin1/activity"],
]
@tests ||= %w[categories home topic]
tests_to_run = all_tests.select { |test_name, path| @tests.include?(test_name) }
tests_to_run.concat(
tests_to_run.map { |k, url| ["#{k} user", "#{url}", user_headers] },
tests_to_run.map { |k, url| ["#{k} admin", "#{url}", admin_headers] },
)
tests_to_run.each do |test_name, path, headers_for_path|
uri = URI.parse("http://127.0.0.1:#{@port}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Get.new(uri.request_uri)
headers_for_path&.each { |key, value| request[key] = value }
response = http.request(request)
raise "#{test_name} #{path} returned non 200 response code" if response.code != "200"
end
# NOTE: we run the most expensive page first in the bench
def best_of(a, b)
return a unless b
return b unless a
a[50] < b[50] ? a : b
end
results = {}
@best_of.times do
tests_to_run.each do |name, url, headers|
results[name] = best_of(bench(url, name, headers), results[name])
end
end
puts "Your Results: (note for timings- percentile is first, duration is second in millisecs)"
puts "Unicorn: (workers: #{@unicorn_workers})"
puts "Include env: #{@include_env}"
puts "Iterations: #{@iterations}, Best of: #{@best_of}"
puts "Concurrency: #{@concurrency}"
puts
# Prevent using external facts because it breaks when running in the
# discourse/discourse_bench docker container.
Facter.reset
facts = Facter.to_hash
facts.delete_if do |k, v|
!%w[
operatingsystem
architecture
kernelversion
memorysize
physicalprocessorcount
processor0
virtual
].include?(k)
end
run("RAILS_ENV=profile bundle exec rake assets:clean")
def get_mem(pid)
YAML.safe_load `ruby script/memstats.rb #{pid} --yaml`
end
mem = get_mem(pid)
results =
results.merge(
"timings" => @timings,
"ruby-version" => "#{RUBY_DESCRIPTION}",
"yjit" => RubyVM::YJIT.enabled?,
"rss_kb" => mem["rss_kb"],
"pss_kb" => mem["pss_kb"],
).merge(facts)
if @unicorn
child_pids = `ps --ppid #{pid} | awk '{ print $1; }' | grep -v PID`.split("\n")
child_pids.each do |child|
mem = get_mem(child)
results["rss_kb_#{child}"] = mem["rss_kb"]
results["pss_kb_#{child}"] = mem["pss_kb"]
end
end
puts results.to_yaml
if @mem_stats
puts
puts open("http://127.0.0.1:#{@port}/admin/memory_stats", headers).read
end
if @dump_heap
puts
puts open("http://127.0.0.1:#{@port}/admin/dump_heap", headers).read
end
File.open(@result_file, "wb") { |f| f.write(results) } if @result_file
ensure
Process.kill "KILL", pid
end