diff --git a/Gemfile b/Gemfile index 8ffbab8c..1ada508d 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,8 @@ end group :benchmark do gem "puma" + gem "stackprof" + gem "benchmark-ips" end gemspec diff --git a/benchmark/README.md b/benchmark/README.md index b766cff4..ae2d5e1c 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -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 +``` diff --git a/benchmark/request_benchmark.rb b/benchmark/request_benchmark.rb new file mode 100755 index 00000000..73a3795d --- /dev/null +++ b/benchmark/request_benchmark.rb @@ -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 diff --git a/lib/pitchfork/http_response.rb b/lib/pitchfork/http_response.rb index d274b0cb..4808020e 100644 --- a/lib/pitchfork/http_response.rb +++ b/lib/pitchfork/http_response.rb @@ -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" @@ -54,10 +61,12 @@ 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 @@ -65,15 +74,37 @@ def http_response_write(socket, status, headers, body, 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) + 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