quicsilver 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +8 -1
- data/CHANGELOG.md +82 -0
- data/Gemfile.lock +27 -2
- data/README.md +205 -29
- data/Rakefile +75 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +5 -5
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +111 -12
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1646 -189
- data/lib/quicsilver/client/client.rb +546 -88
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +137 -10
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +43 -141
- data/lib/quicsilver/protocol/response_encoder.rb +40 -5
- data/lib/quicsilver/protocol/response_parser.rb +26 -128
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +111 -48
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +848 -104
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +113 -11
- data/lib/quicsilver/transport/connection.rb +376 -132
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +45 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +17 -2
- metadata +120 -10
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/examples/setup_certs.sh +0 -57
data/Rakefile
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
1
2
|
require "bundler/gem_tasks"
|
|
2
3
|
require "rake/testtask"
|
|
3
4
|
require "rake/extensiontask"
|
|
5
|
+
require_relative "ext/quicsilver/msquic_patch"
|
|
4
6
|
|
|
5
7
|
Rake::ExtensionTask.new('quicsilver') do |ext|
|
|
6
8
|
ext.lib_dir = 'lib/quicsilver'
|
|
7
9
|
end
|
|
8
10
|
|
|
11
|
+
# Keep `rake compile` useful in source checkouts, but let cibuildgem own
|
|
12
|
+
# extension compilation after the workflow has built and bundled MsQuic.
|
|
13
|
+
task :compile => [:build_msquic, :bundle_msquic] unless ENV["CIBUILDGEM"]
|
|
14
|
+
|
|
15
|
+
# Copy MsQuic dylib next to the compiled extension for gem packaging
|
|
16
|
+
task :bundle_msquic do
|
|
17
|
+
lib_dir = 'lib/quicsilver'
|
|
18
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
19
|
+
dylib = 'vendor/msquic/build/bin/Release/libmsquic.2.dylib'
|
|
20
|
+
if File.exist?(dylib)
|
|
21
|
+
cp dylib, "#{lib_dir}/libmsquic.2.dylib"
|
|
22
|
+
# Update rpath so the bundle finds the dylib in the same directory
|
|
23
|
+
sh "install_name_tool -add_rpath @loader_path #{lib_dir}/quicsilver.bundle 2>/dev/null || true"
|
|
24
|
+
end
|
|
25
|
+
elsif RUBY_PLATFORM =~ /linux/
|
|
26
|
+
so = 'vendor/msquic/build/bin/Release/libmsquic.so.2'
|
|
27
|
+
cp so, "#{lib_dir}/libmsquic.so.2" if File.exist?(so)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
task :build => :bundle_msquic
|
|
32
|
+
|
|
9
33
|
task :setup do
|
|
10
34
|
# Initialize git submodule if it doesn't exist
|
|
11
35
|
unless File.exist?('vendor/msquic')
|
|
@@ -15,13 +39,20 @@ task :setup do
|
|
|
15
39
|
end
|
|
16
40
|
|
|
17
41
|
task :build_msquic => :setup do
|
|
18
|
-
|
|
42
|
+
MsquicPatch.apply!(File.expand_path("vendor/msquic", __dir__))
|
|
43
|
+
cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release', '-DQUIC_TLS_LIB=quictls', '-DQUIC_LINUX_IOURING_ENABLED=OFF']
|
|
19
44
|
if RUBY_PLATFORM =~ /darwin/
|
|
20
45
|
cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
|
|
21
46
|
cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
|
|
47
|
+
# Ensure QuicTLS uses Xcode SDK, not Homebrew OpenSSL
|
|
48
|
+
sdk_path = `xcrun --show-sdk-path 2>/dev/null`.strip
|
|
49
|
+
cmake_args << "-DCMAKE_OSX_SYSROOT=#{sdk_path}" unless sdk_path.empty?
|
|
22
50
|
end
|
|
23
|
-
|
|
24
|
-
|
|
51
|
+
# Override PATH so QuicTLS openssldir detection finds system openssl, not Homebrew
|
|
52
|
+
env = { 'PATH' => "/usr/bin:#{ENV['PATH']}" }
|
|
53
|
+
sh env, "cd vendor/msquic && cmake #{cmake_args.join(' ')}"
|
|
54
|
+
sh env, 'cd vendor/msquic && cmake --build build --config Release'
|
|
55
|
+
MsquicPatch.record_build!(File.expand_path("vendor/msquic", __dir__))
|
|
25
56
|
end
|
|
26
57
|
|
|
27
58
|
task :build => [:build_msquic, :compile]
|
|
@@ -29,9 +60,50 @@ task :build => [:build_msquic, :compile]
|
|
|
29
60
|
Rake::TestTask.new(:test) do |t|
|
|
30
61
|
t.libs << "test"
|
|
31
62
|
t.libs << "lib"
|
|
63
|
+
t.ruby_opts << "-W0"
|
|
32
64
|
t.test_files = FileList["test/**/*_test.rb"]
|
|
33
65
|
end
|
|
34
66
|
|
|
67
|
+
Rake::TestTask.new(:test_unit) do |t|
|
|
68
|
+
t.libs << "test"
|
|
69
|
+
t.libs << "lib"
|
|
70
|
+
t.ruby_opts << "-W0"
|
|
71
|
+
t.test_files = FileList["test/**/*_test.rb"].reject { |f|
|
|
72
|
+
f.include?("integration") || f.include?("stream_control") ||
|
|
73
|
+
f =~ /quicsilver_test|event_loop_test/
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Rake::TestTask.new(:test_integration) do |t|
|
|
78
|
+
t.libs << "test"
|
|
79
|
+
t.libs << "lib"
|
|
80
|
+
t.ruby_opts << "-W0"
|
|
81
|
+
t.test_files = FileList[
|
|
82
|
+
"test/stream_control_integration_test.rb",
|
|
83
|
+
"test/integration/**/*_test.rb",
|
|
84
|
+
"test/quicsilver_test.rb",
|
|
85
|
+
"test/event_loop_test.rb"
|
|
86
|
+
]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
desc "Run unit and integration tests in parallel"
|
|
90
|
+
task :test_parallel do
|
|
91
|
+
threads = []
|
|
92
|
+
results = {}
|
|
93
|
+
|
|
94
|
+
threads << Thread.new {
|
|
95
|
+
results[:unit] = system("bundle exec rake test_unit 2>&1 > /dev/null")
|
|
96
|
+
}
|
|
97
|
+
threads << Thread.new {
|
|
98
|
+
results[:integration] = system("bundle exec rake test_integration 2>&1 > /dev/null")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
threads.each(&:join)
|
|
102
|
+
unless results.values.all?
|
|
103
|
+
abort "Tests failed: #{results.inspect}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
35
107
|
namespace :benchmark do
|
|
36
108
|
desc "Run throughput benchmark"
|
|
37
109
|
task :throughput do
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Quicsilver baseline benchmark with a single-file Rails app.
|
|
5
|
+
#
|
|
6
|
+
# HTTP/3 is always TLS, so the client connects by the name on the certificate
|
|
7
|
+
# and the server binds an address that name resolves to. Dialling 127.0.0.1
|
|
8
|
+
# against a certificate issued for localhost fails verification, and on macOS
|
|
9
|
+
# localhost resolves to ::1 first, so binding 127.0.0.1 is unreachable. Run
|
|
10
|
+
# `bake localhost:install` once so the localhost CA is trusted.
|
|
11
|
+
#
|
|
12
|
+
# Read Failed before Req/s. A server that sheds reports better latency than one
|
|
13
|
+
# that serves, because rejecting is quicker than answering.
|
|
14
|
+
#
|
|
15
|
+
# STREAMS is the only knob that loads the server. With one connection and one
|
|
16
|
+
# stream nothing is ever concurrent, so Req/s is just 1000 / p50.
|
|
17
|
+
#
|
|
18
|
+
# REQUESTS=2000 latency, one request at a time
|
|
19
|
+
# STREAMS=10 REQUESTS=2000 concurrent, inside capacity
|
|
20
|
+
# STREAMS=50 REQUESTS=5000 concurrent, above the advertised
|
|
21
|
+
# stream limit: QUIC makes the
|
|
22
|
+
# extra streams wait
|
|
23
|
+
# WORKERS=50 STREAMS=50 REQUESTS=5000 capacity matched to the load
|
|
24
|
+
# WORKLOAD=big REQUESTS=50 large bodies
|
|
25
|
+
# WORKLOAD=sleep SLEEP_SECONDS=0.1 REQUESTS=100 slow handler
|
|
26
|
+
#
|
|
27
|
+
# Capacity is WORKERS threads plus a queue of WORKERS * 4, and that total is
|
|
28
|
+
# what the server advertises as its stream limit. Ten requests is a warm-up,
|
|
29
|
+
# not a result: two runs of ten differed by 48% here.
|
|
30
|
+
#
|
|
31
|
+
# HOST name to connect to, must match the certificate (default localhost)
|
|
32
|
+
# ADDRESS address to bind (default ::1, what localhost resolves to first)
|
|
33
|
+
|
|
34
|
+
require 'bundler/inline'
|
|
35
|
+
|
|
36
|
+
gemfile do
|
|
37
|
+
source 'https://rubygems.org'
|
|
38
|
+
gem "rails"
|
|
39
|
+
gem "localhost"
|
|
40
|
+
gem "falcon"
|
|
41
|
+
gem "puma"
|
|
42
|
+
gem "protocol-http"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
46
|
+
|
|
47
|
+
require "benchmark"
|
|
48
|
+
require "localhost/authority"
|
|
49
|
+
require "quicsilver"
|
|
50
|
+
require_relative "helpers"
|
|
51
|
+
|
|
52
|
+
REQUESTS = Integer(ENV.fetch("REQUESTS", "10"))
|
|
53
|
+
CONNECTIONS = Integer(ENV.fetch("CONNECTIONS", "1"))
|
|
54
|
+
STREAMS = Integer(ENV.fetch("STREAMS", "1"))
|
|
55
|
+
WORKERS = Integer(ENV.fetch("WORKERS", "5"))
|
|
56
|
+
WORKLOAD = ENV.fetch("WORKLOAD", "tiny")
|
|
57
|
+
SLEEP_SECONDS = Float(ENV.fetch("SLEEP_SECONDS", "0.1"))
|
|
58
|
+
HOST = ENV.fetch("HOST", "localhost")
|
|
59
|
+
PORT = Integer(ENV.fetch("PORT", Benchmarks.random_port.to_s))
|
|
60
|
+
PATH = Benchmarks.path_for(WORKLOAD)
|
|
61
|
+
APP = Benchmarks.rails_app(sleep_seconds: SLEEP_SECONDS, secret_key_base: "quicsilver-benchmark")
|
|
62
|
+
|
|
63
|
+
def start_server
|
|
64
|
+
authority = Localhost::Authority.fetch
|
|
65
|
+
config = Quicsilver::Transport::Configuration.new(authority.certificate_path, authority.key_path)
|
|
66
|
+
server = Quicsilver::Server.new(
|
|
67
|
+
PORT,
|
|
68
|
+
address: ENV.fetch("ADDRESS", "::1"),
|
|
69
|
+
app: APP,
|
|
70
|
+
server_configuration: config,
|
|
71
|
+
threads: WORKERS,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
thread = Thread.new { server.start }
|
|
75
|
+
thread.abort_on_exception = true
|
|
76
|
+
wait_for_server(thread)
|
|
77
|
+
[server, thread]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def wait_for_server(thread)
|
|
81
|
+
deadline = Time.now + 10
|
|
82
|
+
|
|
83
|
+
while Time.now < deadline
|
|
84
|
+
abort "server exited while booting" unless thread.alive?
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
client = Quicsilver::Client.new(HOST, PORT, connection_timeout: 500, request_timeout: 1)
|
|
88
|
+
client.open_connection
|
|
89
|
+
response = client.get("/")
|
|
90
|
+
return if response&.status == 200
|
|
91
|
+
warn "readiness check returned HTTP #{response&.status}: #{response&.body&.byteslice(0, 120).inspect}" if ENV["DEBUG"]
|
|
92
|
+
rescue StandardError => error
|
|
93
|
+
warn "readiness check failed: #{error.class}: #{error.message}" if ENV["DEBUG"]
|
|
94
|
+
sleep 0.1
|
|
95
|
+
ensure
|
|
96
|
+
client&.disconnect rescue nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
abort "server failed to boot: quicsilver://127.0.0.1:#{PORT}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stop_server(server, thread)
|
|
104
|
+
server&.stop rescue nil
|
|
105
|
+
thread&.join(5)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def start_request(connection, path)
|
|
109
|
+
connection.get(path) { |request| request }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def finish_request(request)
|
|
113
|
+
request.response
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def request(connection, path)
|
|
117
|
+
finish_request(start_request(connection, path))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def open_connections
|
|
121
|
+
Array.new(CONNECTIONS) do
|
|
122
|
+
Quicsilver::Client.new(HOST, PORT, connection_timeout: 5000, request_timeout: 10).tap(&:open_connection)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def measure
|
|
127
|
+
connections = open_connections
|
|
128
|
+
# Untimed request per connection to warm Rails and establish steady connection state.
|
|
129
|
+
connections.each { |connection| request(connection, PATH) }
|
|
130
|
+
|
|
131
|
+
times = []
|
|
132
|
+
failed = 0
|
|
133
|
+
|
|
134
|
+
elapsed = Benchmark.realtime do
|
|
135
|
+
remaining = REQUESTS
|
|
136
|
+
|
|
137
|
+
while remaining.positive?
|
|
138
|
+
batch = []
|
|
139
|
+
|
|
140
|
+
connections.each do |connection|
|
|
141
|
+
STREAMS.times do
|
|
142
|
+
break if batch.length >= remaining
|
|
143
|
+
|
|
144
|
+
batch << [Benchmarks.now, start_request(connection, PATH)]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
batch.each do |started_at, pending_request|
|
|
149
|
+
finish_request(pending_request)&.status == 200 ? times << (Benchmarks.now - started_at) : failed += 1
|
|
150
|
+
rescue StandardError
|
|
151
|
+
failed += 1
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
remaining -= batch.length
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Benchmarks::Result.new(
|
|
159
|
+
requests: REQUESTS,
|
|
160
|
+
connections: CONNECTIONS,
|
|
161
|
+
streams: STREAMS,
|
|
162
|
+
times: times,
|
|
163
|
+
failed: failed,
|
|
164
|
+
elapsed: elapsed,
|
|
165
|
+
)
|
|
166
|
+
ensure
|
|
167
|
+
connections&.each { |connection| connection.disconnect rescue nil }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
begin
|
|
171
|
+
server, thread = start_server
|
|
172
|
+
Benchmarks.print_result("Quicsilver Rails baseline", measure, path: PATH, workers: WORKERS)
|
|
173
|
+
ensure
|
|
174
|
+
stop_server(server, thread)
|
|
175
|
+
end
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Quicsilver vs Puma vs Falcon, same Rack app, default configuration.
|
|
5
|
+
#
|
|
6
|
+
# This is the local-development number and nothing more. Every advantage HTTP/3
|
|
7
|
+
# has needs a condition localhost does not provide: 0-RTT needs a round trip to
|
|
8
|
+
# save, and no head-of-line blocking needs a lost or reordered packet. Loopback
|
|
9
|
+
# has neither. Expect HTTP/1.1 to win here, and read the caveats printed with
|
|
10
|
+
# the results before quoting any of it.
|
|
11
|
+
#
|
|
12
|
+
# ruby benchmarks/compare.rb
|
|
13
|
+
# REQUESTS=2000 CONCURRENCY=10 ruby benchmarks/compare.rb
|
|
14
|
+
# SERVERS=quicsilver,falcon ruby benchmarks/compare.rb
|
|
15
|
+
#
|
|
16
|
+
# Known unexplained variance: Quicsilver's throughput has measured anywhere
|
|
17
|
+
# from 2700 to 8900 req/s for the same code, same machine, isolated processes,
|
|
18
|
+
# while Falcon and Puma stayed within a few percent across the same shells. Do
|
|
19
|
+
# not quote a single figure from here until that is understood. Falcon and Puma
|
|
20
|
+
# comparisons are stable; the HTTP/3 number is not yet.
|
|
21
|
+
#
|
|
22
|
+
# CONCURRENCY is requests in flight, which each protocol reaches differently:
|
|
23
|
+
# HTTP/3 and HTTP/2 open that many streams on one connection, HTTP/1.1 opens
|
|
24
|
+
# that many connections, because it has no streams. That is the comparison, not
|
|
25
|
+
# a flaw in it.
|
|
26
|
+
#
|
|
27
|
+
# Defaults are what each server ships with, not matched capacity. Quicsilver
|
|
28
|
+
# advertises a stream limit of threads + queue and makes extra streams wait;
|
|
29
|
+
# Puma queues in the kernel backlog, default 1024; Falcon spawns a fiber per
|
|
30
|
+
# request. Those are three different answers to overload, and at high
|
|
31
|
+
# concurrency they matter more than the protocol.
|
|
32
|
+
|
|
33
|
+
require "bundler/inline"
|
|
34
|
+
|
|
35
|
+
gemfile do
|
|
36
|
+
source "https://rubygems.org"
|
|
37
|
+
gem "rails"
|
|
38
|
+
gem "localhost"
|
|
39
|
+
gem "falcon"
|
|
40
|
+
gem "puma"
|
|
41
|
+
gem "protocol-http"
|
|
42
|
+
gem "async-http"
|
|
43
|
+
# The impairment relay used by latency.rb and loss.rb. Benchmark-only: it is
|
|
44
|
+
# not a dependency of the library or of the app, so it lives here rather than
|
|
45
|
+
# in the Gemfile.
|
|
46
|
+
# IMPAIR_PATH points at a checkout, for running the benchmark against a
|
|
47
|
+
# relay change before it is pushed.
|
|
48
|
+
if (impair_path = ENV["IMPAIR_PATH"])
|
|
49
|
+
gem "impair", path: impair_path
|
|
50
|
+
else
|
|
51
|
+
gem "impair", github: "hahmed/impair"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
56
|
+
|
|
57
|
+
require "async"
|
|
58
|
+
require "async/http"
|
|
59
|
+
require "async/http/endpoint"
|
|
60
|
+
require "falcon"
|
|
61
|
+
require "falcon/endpoint"
|
|
62
|
+
require "localhost/authority"
|
|
63
|
+
require "puma"
|
|
64
|
+
require "puma/configuration"
|
|
65
|
+
require "puma/launcher"
|
|
66
|
+
require "quicsilver"
|
|
67
|
+
require_relative "helpers"
|
|
68
|
+
|
|
69
|
+
REQUESTS = Integer(ENV.fetch("REQUESTS", "2000"))
|
|
70
|
+
CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "10"))
|
|
71
|
+
# How many transport connections each arm spreads its CONCURRENCY requests
|
|
72
|
+
# over. Browsers open six TCP connections to an origin and one QUIC
|
|
73
|
+
# connection, so the defaults are the browser shape: HTTP/1.1 on 6, HTTP/2
|
|
74
|
+
# and HTTP/3 on 1. Setting CONNECTIONS applies the same count to every arm,
|
|
75
|
+
# which is the other fair comparison.
|
|
76
|
+
RETRIES = Integer(ENV.fetch("RETRIES", "0"))
|
|
77
|
+
CONNECTIONS = {
|
|
78
|
+
"HTTP/1.1" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H1_CONNECTIONS", "6"))),
|
|
79
|
+
"HTTP/2" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H2_CONNECTIONS", "1"))),
|
|
80
|
+
"HTTP/3" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H3_CONNECTIONS", "1"))),
|
|
81
|
+
}.freeze
|
|
82
|
+
WORKLOAD = ENV.fetch("WORKLOAD", "tiny")
|
|
83
|
+
PATH = Benchmarks.path_for(WORKLOAD)
|
|
84
|
+
SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",").map(&:strip)
|
|
85
|
+
RUNS = Integer(ENV.fetch("RUNS", "3"))
|
|
86
|
+
AUTHORITY = Localhost::Authority.fetch
|
|
87
|
+
|
|
88
|
+
# One plain Rack app. No framework, so the app contributes as little as
|
|
89
|
+
# possible: a framework would be measured three times and tell us nothing about
|
|
90
|
+
# the servers.
|
|
91
|
+
BODIES = {
|
|
92
|
+
"/" => Benchmarks::TINY_RESPONSE,
|
|
93
|
+
"/hello" => Benchmarks::HELLO_RESPONSE,
|
|
94
|
+
"/small" => Benchmarks::SMALL_RESPONSE,
|
|
95
|
+
"/big" => Benchmarks::BIG_RESPONSE
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
APP = lambda do |env|
|
|
99
|
+
body = BODIES.fetch(env["PATH_INFO"], Benchmarks::TINY_RESPONSE)
|
|
100
|
+
[200, {"content-type" => "text/plain", "content-length" => body.bytesize.to_s}, [body]]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Result = Struct.new(:server, :protocol, :times, :failed, :elapsed, keyword_init: true) do
|
|
104
|
+
def rps = times.length / elapsed
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def free_port
|
|
108
|
+
socket = UDPSocket.new
|
|
109
|
+
socket.bind("127.0.0.1", 0)
|
|
110
|
+
socket.addr[1]
|
|
111
|
+
ensure
|
|
112
|
+
socket&.close
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# === Quicsilver, HTTP/3 ===
|
|
116
|
+
|
|
117
|
+
# "localhost" resolves to ["::1", "127.0.0.1"] on macOS and the other order
|
|
118
|
+
# elsewhere, so a server bound to one family and a client dialling the name is
|
|
119
|
+
# a coin toss. Bind and dial the same literal. ::1 by default because the
|
|
120
|
+
# client prefers it; loss.rb overrides both so Puma, which binds v4 only, is
|
|
121
|
+
# on the same path as the other two.
|
|
122
|
+
QUIC_ADDRESS = ENV.fetch("QUIC_ADDRESS", "::1")
|
|
123
|
+
|
|
124
|
+
def with_quicsilver(port)
|
|
125
|
+
config = Quicsilver::Transport::Configuration.new(
|
|
126
|
+
AUTHORITY.certificate_path, AUTHORITY.key_path
|
|
127
|
+
)
|
|
128
|
+
server = Quicsilver::Server.new(port, address: QUIC_ADDRESS, app: APP, server_configuration: config)
|
|
129
|
+
Quicsilver::Server.instance = server
|
|
130
|
+
thread = Thread.new { server.start }
|
|
131
|
+
sleep 0.05 until server.running?
|
|
132
|
+
yield
|
|
133
|
+
ensure
|
|
134
|
+
server&.stop
|
|
135
|
+
thread&.join(5)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def measure_quicsilver(port)
|
|
139
|
+
times = []
|
|
140
|
+
failed = 0
|
|
141
|
+
mutex = Mutex.new
|
|
142
|
+
# CONNECTIONS["HTTP/3"] QUIC connections, each carrying a share of the
|
|
143
|
+
# CONCURRENCY streams. Dialling a literal means the certificate's
|
|
144
|
+
# "localhost" no longer matches the name, so the name check goes off. The
|
|
145
|
+
# TCP arms already run VERIFY_NONE in client_context, so this matches them
|
|
146
|
+
# rather than conceding anything.
|
|
147
|
+
clients = Array.new(CONNECTIONS["HTTP/3"]) do
|
|
148
|
+
Quicsilver::Client.new(QUIC_ADDRESS, port, request_timeout: 30, unsecure: true).tap { |c| c.get(PATH) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
started = Benchmarks.now
|
|
152
|
+
Benchmarks.distribute(REQUESTS, CONCURRENCY).each_with_index.map do |count, index|
|
|
153
|
+
client = clients[index % clients.length]
|
|
154
|
+
Thread.new do
|
|
155
|
+
count.times do
|
|
156
|
+
at = Benchmarks.now
|
|
157
|
+
response = client.get(PATH)
|
|
158
|
+
elapsed = Benchmarks.now - at
|
|
159
|
+
mutex.synchronize do
|
|
160
|
+
response&.status == 200 ? times << elapsed : failed += 1
|
|
161
|
+
end
|
|
162
|
+
rescue StandardError
|
|
163
|
+
mutex.synchronize { failed += 1 }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end.each(&:join)
|
|
167
|
+
elapsed = Benchmarks.now - started
|
|
168
|
+
|
|
169
|
+
clients.each(&:disconnect)
|
|
170
|
+
Result.new(server: "quicsilver", protocol: "HTTP/3", times: times, failed: failed, elapsed: elapsed)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# === Falcon, HTTP/2, and Puma, HTTP/1.1 ===
|
|
174
|
+
#
|
|
175
|
+
# Both over TLS with the same certificate, so the comparison is not encrypted
|
|
176
|
+
# against cleartext. async-http negotiates h2 with Falcon and http/1.1 with
|
|
177
|
+
# Puma via ALPN, so the protocol difference comes from the server.
|
|
178
|
+
|
|
179
|
+
def with_falcon(port)
|
|
180
|
+
# Falcon::Endpoint, not Async::HTTP::Endpoint with the bare localhost
|
|
181
|
+
# context: the bare context has no alpn_select_cb, so the server never
|
|
182
|
+
# agreed to h2 even when offered it, and every "HTTP/2" row before this
|
|
183
|
+
# was HTTP/1.1. Falcon's own endpoint installs the callback.
|
|
184
|
+
endpoint = Falcon::Endpoint.parse("https://localhost:#{port}")
|
|
185
|
+
container = nil
|
|
186
|
+
thread = Thread.new do
|
|
187
|
+
Async do
|
|
188
|
+
server = Falcon::Server.new(Falcon::Server.middleware(APP), endpoint)
|
|
189
|
+
container = server
|
|
190
|
+
server.run.wait
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
sleep 0.3
|
|
194
|
+
yield
|
|
195
|
+
ensure
|
|
196
|
+
thread&.kill
|
|
197
|
+
thread&.join(2)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def with_puma(port)
|
|
201
|
+
config = Puma::Configuration.new do |c|
|
|
202
|
+
c.ssl_bind "127.0.0.1", port, {
|
|
203
|
+
cert: AUTHORITY.certificate_path, key: AUTHORITY.key_path, verify_mode: "none"
|
|
204
|
+
}
|
|
205
|
+
c.app APP
|
|
206
|
+
c.log_requests false
|
|
207
|
+
c.silence_single_worker_warning
|
|
208
|
+
end
|
|
209
|
+
launcher = Puma::Launcher.new(config, events: Puma::Events.new)
|
|
210
|
+
thread = Thread.new { launcher.run }
|
|
211
|
+
sleep 0.6
|
|
212
|
+
yield
|
|
213
|
+
ensure
|
|
214
|
+
launcher&.stop
|
|
215
|
+
thread&.join(3)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def measure_async(name, protocol, port, host: "localhost")
|
|
219
|
+
times = []
|
|
220
|
+
failed = 0
|
|
221
|
+
elapsed = nil
|
|
222
|
+
|
|
223
|
+
Async do
|
|
224
|
+
wire = wire_protocol_for(protocol)
|
|
225
|
+
endpoint = Async::HTTP::Endpoint.parse("https://#{host}:#{port}#{PATH}",
|
|
226
|
+
protocol: wire, ssl_context: client_context(wire))
|
|
227
|
+
# Each Async::HTTP::Client is a connection pool, and the pool grows to
|
|
228
|
+
# meet demand: an HTTP/1.1 client with two requests in flight opens two
|
|
229
|
+
# connections, and "six connections" would quietly become ten. limit: 1
|
|
230
|
+
# makes a client one connection, so CONNECTIONS is what it says. HTTP/2
|
|
231
|
+
# multiplexes its share of the streams over that one.
|
|
232
|
+
# retries: 0, because async-http retries idempotent requests three times
|
|
233
|
+
# by default, and a benchmark that silently re-sends a request after the
|
|
234
|
+
# connection died reports the death as latency rather than as a failure.
|
|
235
|
+
# A browser does retry, so RETRIES=3 is the realistic setting; 0 is the
|
|
236
|
+
# one that shows what the transport did.
|
|
237
|
+
clients = Array.new(CONNECTIONS[protocol]) { Async::HTTP::Client.new(endpoint, limit: 1, retries: RETRIES) }
|
|
238
|
+
clients.first.get(PATH).finish
|
|
239
|
+
assert_negotiated!(clients.first, protocol)
|
|
240
|
+
|
|
241
|
+
started = Benchmarks.now
|
|
242
|
+
Benchmarks.distribute(REQUESTS, CONCURRENCY).each_with_index.map do |count, index|
|
|
243
|
+
client = clients[index % clients.length]
|
|
244
|
+
Async do
|
|
245
|
+
count.times do
|
|
246
|
+
at = Benchmarks.now
|
|
247
|
+
response = client.get(PATH)
|
|
248
|
+
response.finish
|
|
249
|
+
took = Benchmarks.now - at
|
|
250
|
+
response.status == 200 ? times << took : failed += 1
|
|
251
|
+
rescue StandardError
|
|
252
|
+
failed += 1
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end.each(&:wait)
|
|
256
|
+
elapsed = Benchmarks.now - started
|
|
257
|
+
clients.each(&:close)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
Result.new(server: name, protocol: protocol, times: times, failed: failed, elapsed: elapsed)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# A client context has to do two things, and the previous one did only the
|
|
264
|
+
# first. VERIFY_NONE, because the localhost certificate is self-signed and
|
|
265
|
+
# async-http's own default only skips verification when the host is spelled
|
|
266
|
+
# "localhost", not 127.0.0.1. And ALPN: passing any ssl_context replaces
|
|
267
|
+
# async-http's default, which is where alpn_protocols lived, so a context
|
|
268
|
+
# without it never offers h2 and the server falls back to HTTP/1.1. The falcon
|
|
269
|
+
# arm ran that way for every table before this was noticed, labelled HTTP/2
|
|
270
|
+
# the whole time.
|
|
271
|
+
def client_context(wire)
|
|
272
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
273
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
274
|
+
context.alpn_protocols = wire.names
|
|
275
|
+
context
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Pin the wire protocol to the arm's label rather than letting ALPN pick, so
|
|
279
|
+
# a server that only offers one cannot be measured under the other's name.
|
|
280
|
+
def wire_protocol_for(protocol)
|
|
281
|
+
case protocol
|
|
282
|
+
when "HTTP/2" then Async::HTTP::Protocol::HTTP2
|
|
283
|
+
when "HTTP/1.1" then Async::HTTP::Protocol::HTTP11
|
|
284
|
+
else raise ArgumentError, "unknown protocol #{protocol.inspect}"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The protocol column is a measurement, not a label. Looks at the connection
|
|
289
|
+
# the warm-up request actually used and refuses to continue under the wrong
|
|
290
|
+
# name. Reaches into the pool because async-http has no public accessor for
|
|
291
|
+
# it; if that breaks on an upgrade, this is the one place to fix.
|
|
292
|
+
def assert_negotiated!(client, protocol)
|
|
293
|
+
pool = client.instance_variable_get(:@pool)
|
|
294
|
+
connection = pool.instance_variable_get(:@resources).keys.first
|
|
295
|
+
expected = (protocol == "HTTP/2") ? "HTTP2" : "HTTP1"
|
|
296
|
+
actual = connection.class.name[/HTTP\d/]
|
|
297
|
+
return if actual == expected
|
|
298
|
+
|
|
299
|
+
raise "#{protocol} arm negotiated #{connection.class}: ALPN did not select the protocol being measured"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# === Run ===
|
|
303
|
+
|
|
304
|
+
# Loadable as a harness: loss.rb reuses the server lifecycle and the
|
|
305
|
+
# measurement functions above.
|
|
306
|
+
# Each measurement runs in its own child process. Sharing one process makes
|
|
307
|
+
# the results depend on which server ran first: measured 8000 req/s for
|
|
308
|
+
# Quicsilver alone and 2000 for the same code after an earlier run in the same
|
|
309
|
+
# process, while Falcon and Puma were unaffected. A benchmark that can be
|
|
310
|
+
# contaminated by a previous measurement is not measuring the server.
|
|
311
|
+
#
|
|
312
|
+
# Spawned rather than forked, because macOS refuses to fork once threads have
|
|
313
|
+
# touched the Objective-C runtime.
|
|
314
|
+
def measure(name)
|
|
315
|
+
port = free_port
|
|
316
|
+
case name
|
|
317
|
+
when "quicsilver" then with_quicsilver(port) { measure_quicsilver(port) }
|
|
318
|
+
when "falcon" then with_falcon(port) { measure_async("falcon", "HTTP/2", port) }
|
|
319
|
+
when "puma" then with_puma(port) { measure_async("puma", "HTTP/1.1", port, host: "127.0.0.1") }
|
|
320
|
+
else warn "unknown server #{name.inspect}"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# IO.popen merges into the current environment rather than replacing it, and
|
|
325
|
+
# Bundler.original_env only reports what the shell already had. Variables
|
|
326
|
+
# Bundler *added* — BUNDLE_GEMFILE above all — would survive the merge, so map
|
|
327
|
+
# those to nil, which removes them.
|
|
328
|
+
CHILD_ENV = begin
|
|
329
|
+
original = defined?(Bundler) ? Bundler.original_env.to_h : {}
|
|
330
|
+
added = ENV.keys - original.keys
|
|
331
|
+
original.merge(added.to_h { |key| [key, nil] }).freeze
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def run_child(name)
|
|
335
|
+
output = IO.popen(
|
|
336
|
+
# The child re-runs this file and re-evaluates the inline gemfile, which
|
|
337
|
+
# cannot activate falcon or puma while it inherits the parent's bundler
|
|
338
|
+
# environment: GEM_HOME, GEM_PATH and RUBYOPT all point at the repo's own
|
|
339
|
+
# gemspec-only Gemfile. Bundler.original_env is what the shell had before
|
|
340
|
+
# setup, so the child resolves the inline gemfile the way a bare ruby would.
|
|
341
|
+
CHILD_ENV.merge(
|
|
342
|
+
"BENCH_ONE" => name, "REQUESTS" => REQUESTS.to_s, "CONCURRENCY" => CONCURRENCY.to_s,
|
|
343
|
+
"WORKLOAD" => WORKLOAD, "RUNS" => "1"
|
|
344
|
+
),
|
|
345
|
+
[RbConfig.ruby, __FILE__], err: (ENV["DEBUG"] ? :out : File::NULL), &:read
|
|
346
|
+
)
|
|
347
|
+
warn output if ENV["DEBUG"] && !output.include?("RESULT\t")
|
|
348
|
+
line = output.lines.find { |l| l.start_with?("RESULT\t") }
|
|
349
|
+
return nil unless line
|
|
350
|
+
|
|
351
|
+
_, server, protocol, rps, p50, p95, p99, failed = line.chomp.split("\t")
|
|
352
|
+
{server: server, protocol: protocol, rps: rps.to_f, p50: p50.to_f,
|
|
353
|
+
p95: p95.to_f, p99: p99.to_f, failed: failed.to_i}
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Child mode: measure one server and print a parseable line.
|
|
357
|
+
if (only = ENV["BENCH_ONE"])
|
|
358
|
+
result = measure(only)
|
|
359
|
+
if result
|
|
360
|
+
s = Benchmarks.stats(result.times)
|
|
361
|
+
puts format("RESULT\t%s\t%s\t%f\t%f\t%f\t%f\t%d",
|
|
362
|
+
result.server, result.protocol, result.rps, s[:p50], s[:p95], s[:p99], result.failed)
|
|
363
|
+
end
|
|
364
|
+
exit 0
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if __FILE__ == $PROGRAM_NAME
|
|
368
|
+
|
|
369
|
+
puts "Quicsilver vs Puma vs Falcon — local development, default configuration"
|
|
370
|
+
puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, path #{PATH.inspect}, all over TLS"
|
|
371
|
+
puts
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
results = []
|
|
375
|
+
|
|
376
|
+
SERVERS.each do |name|
|
|
377
|
+
runs = RUNS.times.filter_map { run_child(name) }
|
|
378
|
+
if runs.empty?
|
|
379
|
+
warn "#{name}: no runs completed"
|
|
380
|
+
next
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
median = runs.sort_by { |r| r[:rps] }[runs.length / 2]
|
|
384
|
+
results << [median, runs.map { |r| r[:rps] }]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
puts format("%-12s %-9s %9s %9s %9s %9s %8s %16s",
|
|
388
|
+
"server", "protocol", "Req/s", "p50", "p95", "p99", "Failed", "Req/s range")
|
|
389
|
+
puts "-" * 94
|
|
390
|
+
results.each do |(result, rates)|
|
|
391
|
+
puts format(
|
|
392
|
+
"%-12s %-9s %9.0f %7.2fms %7.2fms %7.2fms %8d %16s",
|
|
393
|
+
result[:server], result[:protocol], result[:rps], result[:p50], result[:p95], result[:p99],
|
|
394
|
+
result[:failed],
|
|
395
|
+
(rates.length > 1 ? format("%.0f-%.0f", rates.min, rates.max) : "single run")
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
end
|