Benchmarks - add ResponseTimeWrk files (#2895)

* Update & add new test/rackup files

* Add benchmarks/local files
This commit is contained in:
MSP-Greg 2022-09-11 10:08:54 -05:00 committed by GitHub
parent 528912e9eb
commit f02cdcd5c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1431 additions and 1 deletions

View File

@ -0,0 +1,392 @@
# frozen_string_literal: true
require 'optparse'
module TestPuma
HOST4 = ENV.fetch('PUMA_TEST_HOST4', '127.0.0.1')
HOST6 = ENV.fetch('PUMA_TEST_HOST6', '::1')
PORT = ENV.fetch('PUMA_TEST_PORT', 40001).to_i
# Array of response body sizes. If specified, set by ENV['PUMA_TEST_SIZES']
#
SIZES = if (t = ENV['PUMA_TEST_SIZES'])
t.split(',').map(&:to_i).freeze
else
[1, 10, 100, 256, 512, 1024, 2048].freeze
end
TYPES = [[:a, 'array'].freeze, [:c, 'chunk'].freeze,
[:s, 'string'].freeze, [:i, 'io'].freeze].freeze
# Creates files used by 'i' (File/IO) responses. Placed in
# "#{Dir.tmpdir}/.puma_response_body_io"
# @param sizes [Array <Integer>] Array of sizes
#
def self.create_io_files(sizes = SIZES)
require 'tmpdir'
tmp_folder = "#{Dir.tmpdir}/.puma_response_body_io"
Dir.mkdir(tmp_folder) unless Dir.exist? tmp_folder
fn_format = "#{tmp_folder}/body_io_%04d.txt"
str = ("── Puma Hello World! ── " * 31) + "── Puma Hello World! ──\n" # 1 KB
sizes.each do |len|
suf = format "%04d", len
fn = format fn_format, len
unless File.exist? fn
body = "Hello World\n#{str}".byteslice(0,1023) + "\n" + (str * (len-1))
File.write fn, body
end
end
end
# Base class for generating client request streams
#
class BenchBase
# We're running under GitHub Actions
IS_GHA = ENV['GITHUB_ACTIONS'] == 'true'
WRK_PERCENTILE = [0.50, 0.75, 0.9, 0.99, 1.0].freeze
HDR_BODY_CONF = "Body-Conf: "
# extracts 'type' string from `-b` argument
TYPES_RE = /\A[acis]+/.freeze
# extracts 'size' string from `-b` argument
SIZES_RE = /\d[\d,]*\z/.freeze
def initialize
sleep 5 # wait for server to boot
@thread_loops = nil
@clients_per_thread = nil
@req_per_client = nil
@body_sizes = SIZES
@body_types = TYPES
@dly_app = nil
@bind_type = :tcp
@ios_to_close = []
setup_options
unless File.exist? @state_file
puts "Can't find state file '#{@state_file}'"
exit 1
end
mstr_pid = File.binread(@state_file)[/^pid: +(\d+)/, 1].to_i
begin
Process.kill 0, mstr_pid
rescue Errno::ESRCH
puts 'Puma server stopped?'
exit 1
rescue Errno::EPERM
end
case @bind_type
when :ssl, :ssl4, :tcp, :tcp4
@bind_host = HOST4
@bind_port = PORT
when :ssl6, :tcp6
@bind_host = HOST6
@bind_port = PORT
when :unix
@bind_path = 'tmp/benchmark_skt.unix'
when :aunix
@bind_path = '@benchmark_skt.aunix'
else
exit 1
end
end
def setup_options
OptionParser.new do |o|
o.on "-T", "--stream-threads THREADS", OptionParser::DecimalInteger, "request_stream: loops/threads" do |arg|
@stream_threads = arg.to_i
end
o.on "-c", "--wrk-connections CONNECTIONS", OptionParser::DecimalInteger, "request_stream: clients_per_thread" do |arg|
@wrk_connections = arg.to_i
end
o.on "-R", "--requests REQUESTS", OptionParser::DecimalInteger, "request_stream: requests per socket" do |arg|
@req_per_socket = arg.to_i
end
o.on "-D", "--duration DURATION", OptionParser::DecimalInteger, "wrk/stream: duration" do |arg|
@duration = arg.to_i
end
o.on "-b", "--body_conf BODY_CONF", String, "CI RackUp: type and size of response body in kB" do |arg|
if (types = arg[TYPES_RE])
@body_types = TYPES.select { |a| types.include? a[0].to_s }
end
if (sizes = arg[SIZES_RE])
@body_sizes = sizes.split(',').map(&:to_i).sort
end
end
o.on "-d", "--dly_app DELAYAPP", Float, "CI RackUp: app response delay" do |arg|
@dly_app = arg.to_f
end
o.on "-s", "--socket SOCKETTYPE", String, "Bind type: tcp, ssl, tcp6, ssl6, unix, aunix" do |arg|
@bind_type = arg.to_sym
end
o.on "-S", "--state PUMA_STATEFILE", String, "Puma Server: state file" do |arg|
@state_file = arg
end
o.on "-t", "--threads PUMA_THREADS", String, "Puma Server: threads" do |arg|
@threads = arg
end
o.on "-w", "--workers PUMA_WORKERS", OptionParser::DecimalInteger, "Puma Server: workers" do |arg|
@workers = arg.to_i
end
o.on "-W", "--wrk_bind WRK_STR", String, "wrk: bind string" do |arg|
@wrk_bind_str = arg
end
o.on("-h", "--help", "Prints this help") do
puts o
exit
end
end.parse! ARGV
end
def close_clients
closed = 0
@ios_to_close.each do |socket|
if socket && socket.to_io.is_a?(IO) && !socket.closed?
begin
if @bind_type == :ssl
socket.sysclose
else
socket.close
end
closed += 1
rescue Errno::EBADF
end
end
end
puts "Closed #{closed} sockets" unless closed.zero?
end
# Runs wrk and returns data from its output.
# @param cmd [String] The wrk command string, with arguments
# @return [Hash] The wrk data
#
def run_wrk_parse(cmd, log: false)
STDOUT.syswrite cmd.ljust 55
if @dly_app
cmd.sub! ' -H ', " -H 'Dly: #{@dly_app.round 4}' -H "
end
wrk_output = %x[#{cmd}]
if log
puts '', wrk_output, ''
end
wrk_data = "#{wrk_output[/\A.+ connections/m]}\n#{wrk_output[/ Thread Stats.+\z/m]}"
ary = wrk_data[/^ +\d+ +requests.+/].strip.split ' '
fmt = " | %6s %s %s %7s %8s %s\n"
STDOUT.syswrite format(fmt, *ary)
hsh = {}
rps = wrk_data[/^Requests\/sec: +([\d.]+)/, 1].to_f
requests = wrk_data[/^ +(\d+) +requests/, 1].to_i
transfer = wrk_data[/^Transfer\/sec: +([\d.]+)/, 1].to_f
transfer_unit = wrk_data[/^Transfer\/sec: +[\d.]+(GB|KB|MB)/, 1]
transfer_mult = mult_for_unit transfer_unit
read = wrk_data[/ +([\d.]+)(GB|KB|MB) +read$/, 1].to_f
read_unit = wrk_data[/ +[\d.]+(GB|KB|MB) +read$/, 1]
read_mult = mult_for_unit read_unit
resp_transfer = (transfer * transfer_mult)/rps
resp_read = (read * read_mult)/requests.to_f
mult = transfer/read
hsh[:resp_size] = ((resp_transfer * mult + resp_read)/(mult + 1)).round
hsh[:resp_size] = hsh[:resp_size] - 1770 - hsh[:resp_size].to_s.length
hsh[:rps] = rps.round
hsh[:requests] = requests
if (t = wrk_data[/^ +Socket errors: +(.+)/, 1])
hsh[:errors] = t
end
read = wrk_data[/ +([\d.]+)(GB|KB|MB) +read$/, 1].to_f
unit = wrk_data[/ +[\d.]+(GB|KB|MB) +read$/, 1]
mult = mult_for_unit unit
hsh[:read] = (mult * read).round
if hsh[:errors]
t = hsh[:errors]
hsh[:errors] = t.sub('connect ', 'c').sub('read ', 'r')
.sub('write ', 'w').sub('timeout ', 't')
end
t_re = ' +([\d.ums]+)'
latency =
wrk_data.match(/^ +50%#{t_re}\s+75%#{t_re}\s+90%#{t_re}\s+99%#{t_re}/).captures
# add up max time
latency.push wrk_data[/^ +Latency.+/].split(' ')[-2]
hsh[:times_summary] = WRK_PERCENTILE.zip(latency.map do |t|
if t.end_with?('ms')
t.to_f
elsif t.end_with?('us')
t.to_f/1000
elsif t.end_with?('s')
t.to_f * 1000
else
0
end
end).to_h
hsh
end
def mult_for_unit(unit)
case unit
when 'KB' then 1_024
when 'MB' then 1_024**2
when 'GB' then 1_024**3
end
end
# Outputs info about the run. Example output:
#
# benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6
# Server cluster mode -w2 -t5:5, bind: tcp6
# Puma repo branch 00-response-refactor
# ruby 3.2.0dev (2022-06-11T12:26:03Z master 28e27ee76e) +YJIT [x86_64-linux]
#
def env_log
puts "#{ENV['PUMA_BENCH_CMD']} #{ENV['PUMA_BENCH_ARGS']}"
puts @workers ?
"Server cluster mode -w#{@workers} -t#{@threads}, bind: #{@bind_type}" :
"Server single mode -t#{@threads}, bind: #{@bind_type}"
branch = %x[git branch][/^\* (.*)/, 1]
if branch
puts "Puma repo branch #{branch.strip}", RUBY_DESCRIPTION
else
const = File.read File.expand_path('../../lib/puma/const.rb', __dir__)
puma_version = const[/^ +PUMA_VERSION[^'"]+['"]([^\s'"]+)/, 1]
puts "Puma version #{puma_version}", RUBY_DESCRIPTION
end
end
# Parses data returned by `PumaInfo.run stats`
# @return [Hash] The data from Puma stats
#
def parse_stats
stats = {}
obj = @puma_info.run 'stats'
worker_status = obj[:worker_status]
worker_status.each do |w|
pid = w[:pid]
req_cnt = w[:last_status][:requests_count]
id = format 'worker-%01d-%02d', w[:phase], w[:index]
hsh = {
pid: pid,
requests: req_cnt - @worker_req_ttl[pid],
backlog: w[:last_status][:backlog]
}
@pids[pid] = id
@worker_req_ttl[pid] = req_cnt
stats[id] = hsh
end
stats
end
# Runs gc in the server, then parses data from
# `smem -c 'pid rss pss uss command'`
# @return [Hash] The data from smem
#
def parse_smem
@puma_info.run 'gc'
sleep 1
hsh_smem = Hash.new []
pids = @pids.keys
smem_info = %x[smem -c 'pid rss pss uss command']
smem_info.lines.each do |l|
ary = l.strip.split ' ', 5
if pids.include? ary[0].to_i
hsh_smem[@pids[ary[0].to_i]] = {
pid: ary[0].to_i,
rss: ary[1].to_i,
pss: ary[2].to_i,
uss: ary[3].to_i
}
end
end
hsh_smem.sort.to_h
end
end
class ResponseTimeBase < BenchBase
def run
@puma_info = PumaInfo.new ['-S', @state_file]
end
# Prints summarized data. Example:
# ```
# Body ────────── req/sec ────────── ─────── req 50% times ───────
# KB array chunk string io array chunk string io
# 1 13760 13492 13817 9610 0.744 0.759 0.740 1.160
# 10 13536 13077 13492 9269 0.759 0.785 0.760 1.190
# ```
#
# @param summaries [Hash] generated in subclasses
#
def overall_summary(summaries)
names = ''.dup
@body_types.each { |_, t_desc| names << t_desc.rjust(8) }
puts "\nBody ────────── req/sec ────────── ─────── req 50% times ───────" \
"\n KB #{names.ljust 32}#{names}"
len = @body_types.length
digits = [4 - Math.log10(@max_050_time).to_i, 3].min
fmt_rps = ('%6d ' * len).strip
fmt_times = (digits < 0 ? " %6d" : " %6.#{digits}f") * len
@body_sizes.each do |size|
line = format '%-5d ', size
resp = ''
line << format(fmt_rps , *@body_types.map { |_, t_desc| summaries[size][t_desc][:rps] }).ljust(30)
line << format(fmt_times, *@body_types.map { |_, t_desc| summaries[size][t_desc][:times_summary][0.5] })
puts line
end
puts '─' * 69
end
end
end

175
benchmarks/local/bench_base.sh Executable file
View File

@ -0,0 +1,175 @@
#!/bin/bash
# -T client threads (wrk -t)
# -c connections per client thread
# -R requests per client
#
# Total connections/requests = l * c * r
#
# -b response body size kB
# -d app delay
#
# -s Puma bind socket type, default ssl, also tcp or unix
# -t Puma threads
# -w Puma workers
# -r Puma rackup file
if [[ "$@" =~ ^[^-].* ]]; then
echo "Error: Invalid option was specified $1"
exit
fi
PUMA_BENCH_CMD=$0
PUMA_BENCH_ARGS=$@
export PUMA_BENCH_CMD
export PUMA_BENCH_ARGS
if [ -z "$PUMA_TEST_HOST4" ]; then export PUMA_TEST_HOST4=127.0.0.1; fi
if [ -z "$PUMA_TEST_HOST6" ]; then export PUMA_TEST_HOST6=::1; fi
if [ -z "$PUMA_TEST_PORT" ]; then export PUMA_TEST_PORT=40001; fi
if [ -z "$PUMA_TEST_CTRL" ]; then export PUMA_TEST_CTRL=40010; fi
if [ -z "$PUMA_TEST_STATE" ]; then export PUMA_TEST_STATE=tmp/bench_test_puma.state; fi
export PUMA_CTRL=$PUMA_TEST_HOST4:$PUMA_TEST_CTRL
while getopts :b:C:c:D:d:R:r:s:T:t:w:Y option
do
case "${option}" in
#———————————————————— RUBY options
Y) export RUBYOPT=--yjit;;
#———————————————————— Puma options
C) conf=${OPTARG};;
t) threads=${OPTARG};;
w) workers=${OPTARG};;
r) rackup_file=${OPTARG};;
#———————————————————— app/common options
b) body_conf=${OPTARG};;
s) skt_type=${OPTARG};;
d) dly_app=${OPTARG};;
#———————————————————— request_stream options
T) stream_threads=${OPTARG};;
D) duration=${OPTARG};;
R) req_per_socket=${OPTARG};;
#———————————————————— wrk options
c) connections=${OPTARG};;
# T) stream_threads=${OPTARG};;
# D) duration=${OPTARG};;
?) echo "Error: Invalid option was specified -$OPTARG"; exit;;
esac
done
# -n not empty, -z is empty
ruby_args="-S $PUMA_TEST_STATE"
if [ -n "$connections" ]; then
ruby_args="$ruby_args -c$connections"
fi
if [ -n "$stream_threads" ]; then
ruby_args="$ruby_args -T$stream_threads"
fi
if [ -n "$duration" ] ; then
ruby_args="$ruby_args -D$duration"
fi
if [ -n "$req_per_socket" ]; then
ruby_args="$ruby_args -R$req_per_socket"
fi
if [ -n "$dly_app" ]; then
ruby_args="$ruby_args -d$dly_app"
fi
if [ -n "$body_conf" ]; then
ruby_args="$ruby_args -b $body_conf"
export CI_BODY_CONF=$body_conf
fi
if [ -z "$skt_type" ]; then
skt_type=tcp
fi
ruby_args="$ruby_args -s $skt_type"
puma_args="-S $PUMA_TEST_STATE"
if [ -n "$workers" ]; then
puma_args="$puma_args -w$workers"
ruby_args="$ruby_args -w$workers"
fi
if [ -z "$threads" ]; then
threads=0:5
fi
puma_args="$puma_args -t$threads"
ruby_args="$ruby_args -t$threads"
if [ -n "$conf" ]; then
puma_args="$puma_args -C $conf"
fi
if [ -z "$rackup_file" ]; then
rackup_file="test/rackup/ci_select.ru"
fi
ip4=$PUMA_TEST_HOST4:$PUMA_TEST_PORT
ip6=[$PUMA_TEST_HOST6]:$PUMA_TEST_PORT
case $skt_type in
ssl4)
bind="ssl://$PUMA_TEST_HOST4:$PUMA_TEST_PORT?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none"
curl_str=https://$ip4
wrk_str=https://$ip4
;;
ssl)
bind="ssl://$ip4?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none"
curl_str=https://$ip4
wrk_str=https://$ip4
;;
ssl6)
bind="ssl://$ip6?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none"
curl_str=https://$ip6
wrk_str=https://$ip6
;;
tcp4)
bind=tcp://$ip4
curl_str=http://$ip4
wrk_str=http://$ip4
;;
tcp)
bind=tcp://$ip4
curl_str=http://$ip4
wrk_str=http://$ip4
;;
tcp6)
bind=tcp://$ip6
curl_str=http://$ip6
wrk_str=http://$ip6
;;
unix)
bind=unix://tmp/benchmark_skt.unix
curl_str="--unix-socket tmp/benchmark_skt.unix http:/n"
;;
aunix)
bind=unix://@benchmark_skt.aunix
curl_str="--abstract-unix-socket benchmark_skt.aunix http:/n"
;;
*)
echo "Error: Invalid socket type option was specified '$skt_type'"
exit
;;
esac
StartPuma()
{
if [ -n "$1" ]; then
rackup_file=$1
fi
printf "\nbundle exec bin/puma -q -b $bind $puma_args --control-url=tcp://$PUMA_CTRL --control-token=test $rackup_file\n\n"
bundle exec bin/puma -q -b $bind $puma_args --control-url=tcp://$PUMA_CTRL --control-token=test $rackup_file &
sleep 6s
}

View File

@ -0,0 +1,201 @@
# frozen_string_literal: true
require 'optparse'
require_relative '../../lib/puma/state_file'
require_relative '../../lib/puma/const'
require_relative '../../lib/puma/detect'
require_relative '../../lib/puma/configuration'
require 'uri'
require 'socket'
require 'json'
module TestPuma
# Similar to puma_ctl.rb, but returns objects. Command list is minimal.
#
class PumaInfo
# @version 5.0.0
PRINTABLE_COMMANDS = %w{gc-stats stats stop thread-backtraces}.freeze
COMMANDS = (PRINTABLE_COMMANDS + %w{gc}).freeze
attr_reader :master_pid
def initialize(argv, stdout=STDOUT, stderr=STDERR)
@state = nil
@quiet = false
@pidfile = nil
@pid = nil
@control_url = nil
@control_auth_token = nil
@config_file = nil
@command = nil
@environment = ENV['RACK_ENV'] || ENV['RAILS_ENV']
@argv = argv
@stdout = stdout
@stderr = stderr
@cli_options = {}
opts = OptionParser.new do |o|
o.banner = "Usage: pumactl (-p PID | -P pidfile | -S status_file | -C url -T token | -F config.rb) (#{PRINTABLE_COMMANDS.join("|")})"
o.on "-S", "--state PATH", "Where the state file to use is" do |arg|
@state = arg
end
o.on "-Q", "--quiet", "Not display messages" do |arg|
@quiet = true
end
o.on "-C", "--control-url URL", "The bind url to use for the control server" do |arg|
@control_url = arg
end
o.on "-T", "--control-token TOKEN", "The token to use as authentication for the control server" do |arg|
@control_auth_token = arg
end
o.on "-F", "--config-file PATH", "Puma config script" do |arg|
@config_file = arg
end
o.on "-e", "--environment ENVIRONMENT",
"The environment to run the Rack app on (default development)" do |arg|
@environment = arg
end
o.on_tail("-H", "--help", "Show this message") do
@stdout.puts o
exit
end
o.on_tail("-V", "--version", "Show version") do
@stdout.puts Const::PUMA_VERSION
exit
end
end
opts.order!(argv) { |a| opts.terminate a }
opts.parse!
unless @config_file == '-'
environment = @environment || 'development'
if @config_file.nil?
@config_file = %W(config/puma/#{environment}.rb config/puma.rb).find do |f|
File.exist?(f)
end
end
if @config_file
config = Puma::Configuration.new({ config_files: [@config_file] }, {})
config.load
@state ||= config.options[:state]
@control_url ||= config.options[:control_url]
@control_auth_token ||= config.options[:control_auth_token]
@pidfile ||= config.options[:pidfile]
end
end
@master_pid = File.binread(@state)[/^pid: +(\d+)/, 1].to_i
rescue => e
@stdout.puts e.message
exit 1
end
def message(msg)
@stdout.puts msg unless @quiet
end
def prepare_configuration
if @state
unless File.exist? @state
raise "State file not found: #{@state}"
end
sf = Puma::StateFile.new
sf.load @state
@control_url = sf.control_url
@control_auth_token = sf.control_auth_token
@pid = sf.pid
end
end
def send_request
uri = URI.parse @control_url
# create server object by scheme
server =
case uri.scheme
when 'ssl'
require 'openssl'
OpenSSL::SSL::SSLSocket.new(
TCPSocket.new(uri.host, uri.port),
OpenSSL::SSL::SSLContext.new)
.tap { |ssl| ssl.sync_close = true } # default is false
.tap(&:connect)
when 'tcp'
TCPSocket.new uri.host, uri.port
when 'unix'
# check for abstract UNIXSocket
UNIXSocket.new(@control_url.start_with?('unix://@') ?
"\0#{uri.host}#{uri.path}" : "#{uri.host}#{uri.path}")
else
raise "Invalid scheme: #{uri.scheme}"
end
url = "/#{@command}"
if @control_auth_token
url = url + "?token=#{@control_auth_token}"
end
server.syswrite "GET #{url} HTTP/1.0\r\n\r\n"
unless data = server.read
raise 'Server closed connection before responding'
end
response = data.split("\r\n")
if response.empty?
raise "Server sent empty response"
end
@http, @code, @message = response.first.split(' ',3)
if @code == '403'
raise 'Unauthorized access to server (wrong auth token)'
elsif @code == '404'
raise "Command error: #{response.last}"
elsif @code == '500' && @command == 'stop-sigterm'
# expected with stop-sigterm
elsif @code != '200'
raise "Bad response from server: #{@code}"
end
return unless PRINTABLE_COMMANDS.include? @command
JSON.parse response.last, {symbolize_names: true}
ensure
if server
if uri.scheme == 'ssl'
server.sysclose
else
server.close unless server.closed?
end
end
end
def run(cmd)
return unless COMMANDS.include?(cmd)
@command = cmd
prepare_configuration
send_request
rescue => e
message e.message
exit 1
end
end
end

View File

@ -0,0 +1,240 @@
# frozen_string_literal: true
require_relative 'bench_base'
require_relative 'puma_info'
module TestPuma
# This file is called from `response_time_wrk.sh`. It requires `wrk`.
# We suggest using https://github.com/ioquatix/wrk
#
# It starts a `Puma` server, then collects data from one or more runs of wrk.
# It logs the wrk data as each wrk runs is done, then summarizes
# the data in two tables.
#
# The default runs a matrix of the following, and takes a bit over 5 minutes,
# with 28 (4x7) wrk runs:
#
# bodies - array, chunk, string, io<br/>
#
# sizes - 1k, 10k, 100k, 256k, 512k, 1024k, 2048k
#
# See the file 'Testing - benchmark/local files' for sample output and information
# on arguments for the shell script.
#
# Examples:
#
# * `benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6 -Y`<br/>
# 2 Puma workers, Puma threads 5:5, IPv6 http, 28 wrk runs with matrix above
#
# * `benchmarks/local/response_time_wrk.sh -t6:6 -s tcp -Y -b ac10,50,100`<br/>
# Puma single mode (0 workers), Puma threads 6:6, IPv4 http, six wrk runs,
# [array, chunk] * [10kb, 50kb, 100kb]
#
class ResponseTimeWrk < ResponseTimeBase
def run
time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
super
# default values
@duration ||= 10
max_threads = (@threads[/\d+\z/] || 5).to_i
@stream_threads ||= (0.8 * (@workers || 1) * max_threads).to_i
connections = @stream_threads * (@wrk_connections || 2)
warm_up
@max_100_time = 0
@max_050_time = 0
@errors = false
summaries = Hash.new { |h,k| h[k] = {} }
@single_size = @body_sizes.length == 1
@single_type = @body_types.length == 1
@body_sizes.each do |size|
@body_types.each do |pre, desc|
header = @single_size ? "-H '#{HDR_BODY_CONF}#{pre}#{size}'" :
"-H '#{HDR_BODY_CONF}#{pre}#{size}'".ljust(21)
# warmup?
if pre == :i
wrk_cmd = %Q[wrk -t#{@stream_threads} -c#{connections} -d1s --latency #{header} #{@wrk_bind_str}]
%x[#{wrk_cmd}]
end
wrk_cmd = %Q[wrk -t#{@stream_threads} -c#{connections} -d#{@duration}s --latency #{header} #{@wrk_bind_str}]
hsh = run_wrk_parse wrk_cmd
@errors ||= hsh.key? :errors
times = hsh[:times_summary]
@max_100_time = times[1.0] if times[1.0] > @max_100_time
@max_050_time = times[0.5] if times[0.5] > @max_050_time
summaries[size][desc] = hsh
end
sleep 0.5
@puma_info.run 'gc'
sleep 2.0
end
run_summaries summaries
if @single_size || @single_type
puts ''
else
overall_summary(summaries) unless @single_size || @single_type
end
puts "wrk -t#{@stream_threads} -c#{connections} -d#{@duration}s"
env_log
rescue => e
puts e.class, e.message, e.backtrace
ensure
puts ''
@puma_info.run 'stop'
sleep 2
running_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - time_start
puts format("\n%2d:%d Total Time", (running_time/60).to_i, running_time % 60)
end
# Prints parsed data of each wrk run. Similar to:
# ```
# Type req/sec 50% 75% 90% 99% 100% Resp Size
# ───────────────────────────────────────────────────────────────── 1kB
# array 13760 0.74 2.51 5.22 7.76 11.18 2797
# ```
#
# @param summaries [Hash]
#
def run_summaries(summaries)
digits = [4 - Math.log10(@max_100_time).to_i, 3].min
fmt_vals = "%-6s %6d".dup
fmt_vals << (digits < 0 ? " %6d" : " %6.#{digits}f")*5
fmt_vals << ' %8d'
label = @single_type ? 'Size' : 'Type'
if @errors
puts "\n#{label} req/sec 50% 75% 90% 99% 100% Resp Size Errors"
desc_width = 83
else
puts "\n#{label} req/sec 50% 75% 90% 99% 100% Resp Size"
desc_width = 65
end
puts format("#{'─' * desc_width} %s", @body_types[0][1]) if @single_type
@body_sizes.each do |size|
puts format("#{'─' * desc_width}%5dkB", size) unless @single_type
@body_types.each do |_, t_desc|
hsh = summaries[size][t_desc]
times = hsh[:times_summary].values
desc = @single_type ? size : t_desc
# puts format(fmt_vals, desc, hsh[:rps], *times, hsh[:read]/hsh[:requests])
puts format(fmt_vals, desc, hsh[:rps], *times, hsh[:resp_size])
end
end
end
# Checks if any body files need to be created, reads all the body files,
# then runs a quick 'wrk warmup' command for each body type
#
def warm_up
puts "\nwarm-up"
if @body_types.map(&:first).include? :i
TestPuma.create_io_files @body_sizes
# get size files cached
if @body_types.include? :i
2.times do
@body_sizes.each do |size|
fn = format "#{Dir.tmpdir}/.puma_response_body_io/body_io_%04d.txt", size
t = File.read fn, mode: 'rb'
end
end
end
end
size = @body_sizes.length == 1 ? @body_sizes.first : 10
@body_types.each do |pre, _|
header = "-H '#{HDR_BODY_CONF}#{pre}#{size}'".ljust(21)
warm_up_cmd = %Q[wrk -t2 -c4 -d1s --latency #{header} #{@wrk_bind_str}]
run_wrk_parse warm_up_cmd
end
puts ''
end
# Experimental - try to see how busy a CI system is.
def ci_test_rps
host = ENV['HOST']
port = ENV['PORT'].to_i
str = 'a' * 65_500
server = TCPServer.new host, port
svr_th = Thread.new do
loop do
begin
Thread.new(server.accept) do |client|
client.sysread 65_536
client.syswrite str
client.close
end
rescue => e
break
end
end
end
threads = []
t_st = Process.clock_gettime(Process::CLOCK_MONOTONIC)
100.times do
threads << Thread.new do
100.times {
s = TCPSocket.new host, port
s.syswrite str
s.sysread 65_536
s = nil
}
end
end
threads.each(&:join)
loops_time = (1_000*(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_st)).to_i
threads.clear
threads = nil
server.close
svr_th.join
req_limit =
if loops_time > 3_050 then 13_000
elsif loops_time > 2_900 then 13_500
elsif loops_time > 2_500 then 14_000
elsif loops_time > 2_200 then 18_000
elsif loops_time > 2_100 then 19_000
elsif loops_time > 1_900 then 20_000
elsif loops_time > 1_800 then 21_000
elsif loops_time > 1_600 then 22_500
else 23_000
end
[req_limit, loops_time]
end
def puts(*ary)
ary.each { |s| STDOUT.syswrite "#{s}\n" }
end
end
end
TestPuma::ResponseTimeWrk.new.run

View File

@ -0,0 +1,18 @@
#!/bin/bash
# see comments in response_time_wrk.rb
source benchmarks/local/bench_base.sh
if [ "$skt_type" == "unix" ] || [ "$skt_type" == "aunix" ]; then
printf "\nwrk doesn't support UNIXSockets...\n\n"
exit
fi
StartPuma
ruby -I./lib benchmarks/local/response_time_wrk.rb $ruby_args -W $wrk_str
wrk_exit=$?
printf "\n"
exit $wrk_exit

View File

@ -0,0 +1,150 @@
# Testing - benchmark/local files
These files generate data that shows request-per-second (RPS), etc. Typically, files are in
pairs, a shell script and a Ruby script. The shell script starts the server, then runs the
Ruby file, which starts client request stream(s), then collects and logs metrics.
## response_time_wrk.sh
This uses [wrk] for generating data. One or more wrk runs are performed. Summarizes RPS and
wrk latency times. The default for the `-b` argument runs 28 different client request streams,
and takes a bit over 5 minutes. See 'Request Stream Configuration' below for `-b` argument
description.
<details>
<summary>Summary output for<br/><code>benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6</code>:</summary>
```
Type req/sec 50% 75% 90% 99% 100% Resp Size
───────────────────────────────────────────────────────────────── 1kB
array 13710 0.74 2.52 5.23 7.76 37.45 1024
chunk 13502 0.76 2.55 5.28 7.84 11.23 1042
string 13794 0.74 2.51 5.20 7.75 14.07 1024
io 9615 1.16 3.45 7.13 10.57 15.75 1024
───────────────────────────────────────────────────────────────── 10kB
array 13458 0.76 2.57 5.31 7.93 13.94 10239
chunk 13066 0.78 2.64 5.46 8.18 38.48 10320
string 13500 0.76 2.55 5.29 7.88 11.42 10240
io 9293 1.18 3.59 7.39 10.94 16.99 10240
───────────────────────────────────────────────────────────────── 100kB
array 11315 0.96 3.06 6.33 9.49 17.69 102424
chunk 9916 1.10 3.48 7.20 10.73 15.14 103075
string 10948 1.00 3.17 6.57 9.83 17.88 102378
io 8901 1.21 3.72 7.48 11.27 59.98 102407
───────────────────────────────────────────────────────────────── 256kB
array 9217 1.15 3.82 7.88 11.74 17.12 262212
chunk 7339 1.45 4.76 9.81 14.63 22.70 264007
string 8574 1.19 3.81 7.73 11.21 15.80 262147
io 8911 1.19 3.80 7.55 15.25 60.01 262183
───────────────────────────────────────────────────────────────── 512kB
array 6951 1.49 5.03 10.28 15.90 25.08 524378
chunk 5234 2.03 6.56 13.57 20.46 32.15 527862
string 6438 1.55 5.04 10.12 16.28 72.87 524275
io 8533 1.15 4.62 8.79 48.15 70.51 524327
───────────────────────────────────────────────────────────────── 1024kB
array 4122 1.80 15.59 41.87 67.79 121.00 1048565
chunk 3158 2.82 15.22 31.00 71.39 99.90 1055654
string 4710 2.24 6.66 13.65 20.38 70.44 1048575
io 8355 1.23 3.95 7.94 14.08 68.54 1048498
───────────────────────────────────────────────────────────────── 2048kB
array 2454 4.12 14.02 27.70 43.48 88.89 2097415
chunk 1743 6.26 17.65 36.98 55.78 92.10 2111358
string 2479 4.38 12.52 25.65 38.44 95.62 2097502
io 8264 1.25 3.83 7.76 11.73 65.69 2097090
Body ────────── req/sec ────────── ─────── req 50% times ───────
KB array chunk string io array chunk string io
1 13710 13502 13794 9615 0.745 0.757 0.741 1.160
10 13458 13066 13500 9293 0.760 0.784 0.759 1.180
100 11315 9916 10948 8901 0.960 1.100 1.000 1.210
256 9217 7339 8574 8911 1.150 1.450 1.190 1.190
512 6951 5234 6438 8533 1.490 2.030 1.550 1.150
1024 4122 3158 4710 8355 1.800 2.820 2.240 1.230
2048 2454 1743 2479 8264 4.120 6.260 4.380 1.250
─────────────────────────────────────────────────────────────────────
wrk -t8 -c16 -d10s
benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6 -Y
Server cluster mode -w2 -t5:5, bind: tcp6
Puma repo branch 00-response-refactor
ruby 3.2.0dev (2022-06-14T01:21:55Z master 048f14221c) +YJIT [x86_64-linux]
[2136] - Gracefully shutting down workers...
[2136] === puma shutdown: 2022-06-13 21:16:13 -0500 ===
[2136] - Goodbye!
5:15 Total Time
```
</details><br/>
## bench_base.sh, bench_base.rb
These two files setup parameters for the Puma server, which is normally started in a shell
script. It then starts a Ruby file (a subclass of BenchBase), passing arguments to it. The
Ruby file is normally used to generate a client request stream(s).
### Puma Configuration
The following arguments are used for the Puma server:
* **`-C`** - configuration file
* **`-d`** - app delay
* **`-r`** - rackup file, often defaults to test/rackup/ci_select.ru
* **`-s`** - bind socket type, default is tcp/tcp4, also tcp6, ssl/ssl4, ssl6, unix, or aunix
(unix & abstract unix are not available with wrk).
* **`-t`** - threads, expressed as '5:5', same as Puma --thread
* **`-w`** - workers, same as Puma --worker
* **`-Y`** - enable Ruby YJIT
### Request Stream Configuration
The following arguments are used for request streams:
* **`-b`** - response body configuration. Body type options are a array, c chunked, s string,
and i for File/IO. None or any combination can be specified, they should start the option.
Then, any combination of comma separated integers can be used for the response body size
in kB. The string 'ac50,100' would create four runs, 50kb array, 50kB chunked, 100kB array,
and 100kB chunked. See 'Testing - test/rackup/ci-*.ru files' for more info.
* **`-c`** - connections per client request stream thread, defaults to 2 for wrk.
* **`-D`** - duration of client request stream in seconds.
* **`-T`** - number of threads in the client request stream. For wrk, this defaults to
80% of Puma workers * max_threads.
### Notes - Configuration
The above lists script arguments.
`bench_base.sh` contains most server defaults. Many can be set via ENV variables.
`bench_base.rb` contains the client request stream defaults. The default value for
`-b` is `acsi1,10,100,256,512,1024,2048`, which is a 4 x 7 matrix, and hence, runs
28 jobs. Also, the i body type (File/IO) generates files, they are placed in the
`"#{Dir.tmpdir}/.puma_response_body_io"` directory, which is created.
### Notes - wrk
The shell scripts use `-T` for wrk's thread count, since `-t` is used for Puma
server threads. Regarding the `-c` argument, wrk has an interesting behavior.
The total number of connections is set by `(connections/threads).to_i`. The scripts
here use `-c` as connections per thread. Hence, using `-T4 -c2` will yield a total
of eight wrk connections, two per thread. The equivalent wrk arguments would be `-t4 -c8`.
Puma can only process so many requests, and requests will queue in the backlog
until Puma can respond to them. With wrk, if the number of total connections is
too high, one will see the upper latency times increase, pushing into the lower
latency times as the connections are increased. The default values for wrk's
threads and connections were chosen to minimize requests' time in the backlog.
An example with four wrk runs using `-b s10`. Notice that `req/sec` varies by
less than 1%, but the `75%` times increase by an order of magnitude:
```
req/sec 50% 75% 90% 99% 100% Resp Size wrk cmd line
─────────────────────────────────────────────────────────────────────────────
13597 0.755 2.550 5.260 7.800 13.310 12040 wrk -t8 -c16 -d10
13549 0.793 4.430 8.140 11.220 16.600 12002 wrk -t10 -c20 -d10
13570 1.040 25.790 40.010 49.070 58.300 11982 wrk -t8 -c64 -d10
13684 1.050 25.820 40.080 49.160 66.190 12033 wrk -t16 -c64 -d10
```
Finally, wrk's output may cause rounding errors, so the response body size calculation is
imprecise.
[wrk]: <https://github.com/ioquatix/wrk>

View File

@ -0,0 +1,36 @@
# Testing - test/rackup/ci-*.ru files
## Overview
Puma should efficiently handle a variety of response bodies, varying both by size
and by the type of object used for the body.
Five rackup files are located in 'test/rackup' that can be used. All have their
request body size (in kB) set via `Body-Conf` header or with `ENV['CI_BODY_CONF']`.
Additionally, the ci_select.ru file can have it's body type set via a starting
character.
* **ci_array.ru** - body is an `Array` of 1kB strings. `Content-Length` is not set.
* **ci_chunked.ru** - body is an `Enumerator` of 1kB strings. `Content-Length` is not set.
* **ci_io.ru** - body is a File/IO object. `Content-Length` is set.
* **ci_string.ru** - body is a single string. `Content-Length` is set.
* **ci_select.ru** - can be any of the above.
All responses have 25 headers, total length approx 1kB. ci_array.ru and ci_chunked.ru
contain 1kB items.
All can be delayed by a float value (seconds) specified by the `Dly` header
Note that rhe `Body-Conf` header takes precedence, and `ENV['CI_BODY_CONF']` is
only read on load.
## ci_select.ru
The ci_select.ru file allows a starting character to specify the body type in the
`Body-Conf` header or with `ENV['CI_BODY_CONF']`.
* **a** - array of strings
* **c** - chunked (enum)
* **s** - single string
* **i** - File/IO
A value of `a100` would return a body as an array of 100 1kB strings.

36
test/rackup/ci_array.ru Normal file
View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
# Generates a response with array bodies, size set via ENV['CI_BODY_CONF'] or
# `Body-Conf` request header.
# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md
require 'securerandom'
headers = {}
headers['Content-Type'] = 'text/plain; charset=utf-8'
25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) }
hdr_dly = 'HTTP_DLY'
hdr_body_conf = 'HTTP_BODY_CONF'
# length = 1018 bytesize = 1024
str_1kb = "──#{SecureRandom.hex 507}─\n".freeze
env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10
run lambda { |env|
info = if (dly = env[hdr_dly])
sleep dly.to_f
"#{Process.pid}\nHello World\nSlept #{dly}\n"
else
"#{Process.pid}\nHello World\n"
end
info_len_adj = 1023 - info.bytesize
len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len
body = Array.new len, str_1kb
body[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n"
headers[hdr_content_length] = (1_024 * len).to_s
[200, headers, body]
}

36
test/rackup/ci_chunked.ru Normal file
View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
# Generates a response with chunked bodies, size set via ENV['CI_BODY_CONF'] or
# `Body-Conf` request header.
# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md
require 'securerandom'
headers = {}
headers['Content-Type'] = 'text/plain; charset=utf-8'
25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) }
hdr_dly = 'HTTP_DLY'
hdr_body_conf = 'HTTP_BODY_CONF'
# length = 1018 bytesize = 1024
str_1kb = "──#{SecureRandom.hex 507}─\n".freeze
env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10
run lambda { |env|
info = if (dly = env[hdr_dly])
sleep dly.to_f
"#{Process.pid}\nHello World\nSlept #{dly}\n"
else
"#{Process.pid}\nHello World\n"
end
info_len_adj = 1023 - info.bytesize
len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len
temp = Array.new len, str_1kb
temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n"
body = temp.to_enum
[200, headers, body]
}

39
test/rackup/ci_io.ru Normal file
View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
# Generates a response with File/IO bodies, size set via ENV['CI_BODY_CONF'] or
# `Body-Conf` request header.
# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md
require 'securerandom'
require 'tmpdir'
headers = {}
headers['Content-Type'] = 'text/plain; charset=utf-8'
25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) }
hdr_dly = 'HTTP_DLY'
hdr_body_conf = 'HTTP_BODY_CONF'
hdr_content_length = 'Content-Length'
env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10
tmp_folder = "#{Dir.tmpdir}/.puma_response_body_io"
unless Dir.exist? tmp_folder
STDOUT.syswrite "\nNeeded files do not exist. Run `TestPuma.create_io_files" \
" contained in benchmarks/local/bench_base.rb\n"
exit 1
end
fn_format = "#{tmp_folder}/body_io_%04d.txt"
run lambda { |env|
if (dly = env[hdr_dly])
sleep dly.to_f
end
len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len
headers[hdr_content_length] = (1024*len).to_s
fn = format fn_format, len
body = File.open fn
[200, headers, body]
}

67
test/rackup/ci_select.ru Normal file
View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
# Generates a response with various body types and sizes, set via ENV['CI_BODY_CONF'] or
# `Body-Conf` request header.
# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md
require 'securerandom'
require 'tmpdir'
headers = {}
headers['Content-Type'] = 'text/plain; charset=utf-8'
25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) }
hdr_dly = 'HTTP_DLY'
hdr_body_conf = 'HTTP_BODY_CONF'
hdr_content_length = 'Content-Length'
# length = 1018 bytesize = 1024
str_1kb = "──#{SecureRandom.hex 507}─\n".freeze
fn_format = "#{Dir.tmpdir}/.puma_response_body_io/body_io_%04d.txt"
body_types = %w[a c i s].freeze
run lambda { |env|
info = if (dly = env[hdr_dly])
sleep dly.to_f
"#{Process.pid}\nHello World\nSlept #{dly}\n"
else
"#{Process.pid}\nHello World\n"
end
info_len_adj = 1023 - info.bytesize
body_conf = env[hdr_body_conf]
if body_conf && body_conf.start_with?(*body_types)
type = body_conf.slice!(0).to_sym
len = body_conf.to_i
elsif body_conf
type = :s
len = body_conf[/\d+\z/].to_i
else # default
type = :s
len = 1
end
case type
when :a # body is an array
headers[hdr_content_length] = (1_024 * len).to_s
body = Array.new len, str_1kb
body[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n"
when :c # body is chunked
headers.delete hdr_content_length
temp = Array.new len, str_1kb
temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n"
body = temp.to_enum
when :i # body is an io
headers[hdr_content_length] = (1_024 * len).to_s
fn = format fn_format, len
body = File.open fn, 'rb'
when :s # body is a single string in an array
headers[hdr_content_length] = (1_024 * len).to_s
info << str_1kb.byteslice(0, info_len_adj) << "\n" << (str_1kb * (len-1))
body = [info]
end
[200, headers, body]
}

38
test/rackup/ci_string.ru Normal file
View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# Generates a response with single string bodies, size set via ENV['CI_BODY_CONF'] or
# `Body-Conf` request header.
# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md
require 'securerandom'
env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10
headers = {}
headers['Content-Type'] = 'text/plain; charset=utf-8'
25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) }
hdr_dly = 'HTTP_DLY'
hdr_body_conf = 'HTTP_BODY_CONF'
hdr_content_length = 'Content-Length'
# length = 1018 bytesize = 1024
str_1kb = "──#{SecureRandom.hex 507}─\n".freeze
env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10
run lambda { |env|
info = if (dly = env[hdr_dly])
sleep dly.to_f
"#{Process.pid}\nHello World\nSlept #{dly}\n"
else
"#{Process.pid}\nHello World\n"
end
info_len_adj = 1023 - info.bytesize
len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len
info << str_1kb.byteslice(0, info_len_adj) << "\n" << (str_1kb * (len-1))
headers[hdr_content_length] = (1_024 * len).to_s
[200, headers, [info]]
}

View File

@ -1 +1,3 @@
run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] }
hdrs = {'Content-Type'.freeze => 'text/plain'.freeze}.freeze
body = ['Hello World'.freeze].freeze
run lambda { |env| [200, hdrs, body] }