Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ end

group :benchmark do
gem "puma"
gem "stackprof"
gem "benchmark-ips"
end

gemspec
17 changes: 17 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,20 @@ The `constant_caches.ru` application is specifically crafted to demonstrate how
get invalidated as applications execute more and more code.

It is an extreme example for benchmark purposes.

## Request processing throughput

`request_benchmark.rb` benchmarks the per-request Ruby-level hot path (HTTP parsing and
response writing) over a socket pair, without the noise of a real TCP stack or listener loop:

```bash
$ bundle exec benchmark/request_benchmark.rb
process request (parse+respond) 125.745k (± 2.9%) i/s (7.95 μs/i)
```

Pass `profile` (and optionally an iteration count) to capture a StackProf wall-clock profile instead:

```bash
$ bundle exec benchmark/request_benchmark.rb profile
$ bundle exec stackprof benchmark/stackprof-request_benchmark.dump --text
```
54 changes: 54 additions & 0 deletions benchmark/request_benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Benchmarks the per-request Ruby-level hot path: HTTP parsing
# (Pitchfork::HttpParser#read) and response writing (http_response_write).
# This isolates the part of a request pitchfork controls in Ruby, without
# the noise of a real TCP stack, listener accept loop, or a Rack app.
#
# Usage:
# bundle exec benchmark/request_benchmark.rb # benchmark-ips report
# bundle exec benchmark/request_benchmark.rb profile N # StackProf wall-clock profile, N iterations (default 200_000)
require "socket"
require "benchmark/ips"
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "pitchfork"

REQUEST = "GET /hello?foo=bar HTTP/1.1\r\nHost: example.com\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n".b

class RequestBench
include Pitchfork::HttpResponse

def initialize
@client, @server = Socket.pair(:UNIX, :STREAM, 0)
end

def run_once
@client.write(REQUEST)
req = Pitchfork::HttpParser.new
env = req.read(@server)
body = "Hello World!\n"
headers = { "content-type" => "text/plain", "content-length" => body.bytesize.to_s }
http_response_write(@server, 200, headers, [body], req)
@client.readpartial(65536)
env
end
end

bench = RequestBench.new
env = bench.run_once
raise "sanity check failed" unless env["PATH_INFO"] == "/hello"

if ARGV[0] == "profile"
require "stackprof"
n = (ARGV[1] || 200_000).to_i
out = File.join(__dir__, "stackprof-request_benchmark.dump")
StackProf.run(mode: :wall, out: out, raw: true) do
n.times { bench.run_once }
end
puts "wrote #{out}, inspect with: bundle exec stackprof #{out} --text"
else
Benchmark.ips do |x|
x.report("process request (parse+respond)") { bench.run_once }
end
end
53 changes: 42 additions & 11 deletions lib/pitchfork/http_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ def append_header(buf, key, value)
next if ILLEGAL_HEADER_VALUE.match?(v)
buf << "#{key}: #{v}\r\n"
end
when /\n/ # Rack 2
# avoiding blank, key-only cookies with /\n+/
value.split(/\n+/).each do |v|
next if ILLEGAL_HEADER_VALUE.match?(v)
buf << "#{key}: #{v}\r\n"
when String
if value.include?("\n") # Rack 2
# avoiding blank, key-only cookies with /\n+/
value.split(/\n+/).each do |v|
next if ILLEGAL_HEADER_VALUE.match?(v)
buf << key << ": " << v << "\r\n"
end
else
# Common case: a single-line string value, the vast majority of
# headers. Appending directly avoids the intermediate string
# allocation that string interpolation would create.
buf << key << ": " << value << "\r\n"
end
else
buf << "#{key}: #{value}\r\n"
Expand All @@ -54,26 +61,50 @@ def http_response_write(socket, status, headers, body,
"Date: #{httpdate}\r\n" \
"Connection: close\r\n".b
headers.each do |key, value|
case key
when %r{\A(?:Date|Connection)\z}i
next
when "rack.hijack"
# Fast path: skip the case-insensitive Date/Connection check without
# a regexp for every other header (the vast majority).
next if (key.length == 4 && key.casecmp?("date")) ||
(key.length == 10 && key.casecmp?("connection"))

if key == "rack.hijack"
# This should only be hit under Rack >= 1.5, as this was an illegal
# key in Rack < 1.5
hijack = value
else
append_header(buf, key, value)
end
end
socket.write(buf << "\r\n")
buf << "\r\n"
end

if hijack
socket.write(buf) if buf
req.hijacked!
hijack.call(socket)
elsif body.respond_to?(:each)
body.each { |chunk| socket.write(chunk) }
# Combine the header block with the first body chunk into a single
# write (backed by writev(2) when supported) to save a syscall on
# the common case of a response with a single body chunk.
#
# `buf` itself (rather than a separate flag) is the "already sent"
# sentinel: some bodies (e.g. ones that defer emitting via #close)
# capture the block passed to #each and invoke it again later, so
# the sentinel must be a value the closure observes being mutated,
# not a value captured at closure-creation time.
body.each do |chunk|
if buf
socket.write(buf, chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting optimization, but I wonder if you couldn't do both better and simpler by special casing when body is an Array:

if body.is_a?(Array)
  socket.write(buf, *body)
elsif body.respond_to?(:each)
  # ...

buf = nil
else
socket.write(chunk)
end
end
if buf
socket.write(buf)
buf = nil
end
else
socket.write(buf) if buf
body.call(socket)
end
end
Expand Down