quicsilver 0.4.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 +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -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/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- 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 +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- 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 +41 -21
- 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 +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- 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 +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- 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 +24 -13
- 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 +655 -185
- 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 +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- 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 +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- 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/lib/quicsilver/quicsilver.bundle +0 -0
|
@@ -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
|
data/benchmarks/helpers.rb
CHANGED
|
@@ -1,88 +1,162 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require "
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "socket"
|
|
4
5
|
|
|
5
6
|
module Benchmarks
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
PATHS = {
|
|
8
|
+
"tiny" => "/",
|
|
9
|
+
"hello" => "/hello",
|
|
10
|
+
"small" => "/small",
|
|
11
|
+
"big" => "/big",
|
|
12
|
+
"sleep" => "/sleep",
|
|
13
|
+
"upload" => "/upload",
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Body a client POSTs to /upload. The server reads it to the end and answers
|
|
17
|
+
# OK, so this measures the receive path: native delivery, the copy into
|
|
18
|
+
# Ruby, and rack.input.
|
|
19
|
+
UPLOAD_BODY = ("u" * 1024 * 1024).b.freeze
|
|
20
|
+
|
|
21
|
+
TINY_RESPONSE = "OK"
|
|
22
|
+
HELLO_RESPONSE = "Hello World\n"
|
|
23
|
+
SMALL_RESPONSE = ("Hello World\n" * 10) * 10
|
|
24
|
+
BIG_RESPONSE = ("Hello World\n" * 1000) * 1000
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:requests, :connections, :streams, :times, :failed, :elapsed, keyword_init: true) do
|
|
27
|
+
def rps
|
|
28
|
+
times.length / elapsed
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
attr_accessor :sleep_seconds
|
|
34
|
+
|
|
35
|
+
def path_for(workload)
|
|
36
|
+
PATHS.fetch(workload) { abort "unknown WORKLOAD=#{workload.inspect}; use #{PATHS.keys.join(", ")}" }
|
|
37
|
+
end
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
def rails_app(sleep_seconds: 0.1, secret_key_base: "benchmark")
|
|
40
|
+
self.sleep_seconds = sleep_seconds
|
|
41
|
+
require "rails"
|
|
42
|
+
require "action_controller/railtie"
|
|
19
43
|
|
|
20
|
-
|
|
44
|
+
initialize_rails_app(secret_key_base)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def random_port
|
|
48
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
49
|
+
server.addr[1]
|
|
21
50
|
ensure
|
|
22
|
-
server&.
|
|
23
|
-
|
|
51
|
+
server&.close
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def now
|
|
55
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def distribute(total, buckets)
|
|
59
|
+
base, extra = total.divmod(buckets)
|
|
60
|
+
Array.new(buckets) { |index| base + (index < extra ? 1 : 0) }
|
|
24
61
|
end
|
|
25
62
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def self.stats(times)
|
|
29
|
-
return nil if times.empty?
|
|
63
|
+
def stats(times)
|
|
64
|
+
return empty_stats if times.empty?
|
|
30
65
|
|
|
31
66
|
sorted = times.sort
|
|
32
67
|
n = sorted.size
|
|
33
68
|
{
|
|
34
69
|
count: n,
|
|
35
|
-
avg:
|
|
36
|
-
min:
|
|
37
|
-
max:
|
|
38
|
-
p50:
|
|
39
|
-
p95:
|
|
40
|
-
p99:
|
|
70
|
+
avg: (times.sum / n * 1000).round(2),
|
|
71
|
+
min: (sorted.first * 1000).round(2),
|
|
72
|
+
max: (sorted.last * 1000).round(2),
|
|
73
|
+
p50: percentile(sorted, 0.50),
|
|
74
|
+
p95: percentile(sorted, 0.95),
|
|
75
|
+
p99: percentile(sorted, 0.99),
|
|
41
76
|
}
|
|
42
77
|
end
|
|
43
78
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
puts title
|
|
48
|
-
puts "
|
|
49
|
-
|
|
50
|
-
puts "-" *
|
|
79
|
+
def print_result(title, result, path:, workers:)
|
|
80
|
+
s = stats(result.times)
|
|
81
|
+
|
|
82
|
+
puts "\n#{title}"
|
|
83
|
+
puts "#{result.requests} requests, #{result.connections} connection(s), #{result.streams} stream(s) per connection, path #{path.inspect}"
|
|
84
|
+
puts "workers: #{workers}"
|
|
85
|
+
puts "-" * 76
|
|
86
|
+
puts format("%9s %8s %8s %8s %8s %7s", "Req/s", "p50", "p95", "p99", "max", "Failed")
|
|
87
|
+
puts "-" * 76
|
|
88
|
+
puts format(
|
|
89
|
+
"%9.0f %7.2fms %7.2fms %7.2fms %7.2fms %7d",
|
|
90
|
+
result.rps,
|
|
91
|
+
s[:p50],
|
|
92
|
+
s[:p95],
|
|
93
|
+
s[:p99],
|
|
94
|
+
s[:max],
|
|
95
|
+
result.failed,
|
|
96
|
+
)
|
|
51
97
|
end
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
puts " #{label}: no data"
|
|
57
|
-
return
|
|
99
|
+
private
|
|
100
|
+
def empty_stats
|
|
101
|
+
{ count: 0, avg: 0.0, min: 0.0, max: 0.0, p50: 0.0, p95: 0.0, p99: 0.0 }
|
|
58
102
|
end
|
|
59
|
-
puts " #{label}: #{s[:count]} reqs | avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms (min=#{s[:min]}ms max=#{s[:max]}ms)"
|
|
60
|
-
end
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
puts " Total time: #{total_time.round(3)}s"
|
|
66
|
-
puts " Requests: #{total_requests} (#{failed} failed)"
|
|
67
|
-
puts " Throughput: #{(total_requests / total_time).round(2)} req/s"
|
|
68
|
-
if latency && s
|
|
69
|
-
puts " Latency: avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms"
|
|
70
|
-
puts " min=#{s[:min]}ms max=#{s[:max]}ms"
|
|
104
|
+
def percentile(sorted_times, fraction)
|
|
105
|
+
index = (sorted_times.length * fraction).floor.clamp(0, sorted_times.length - 1)
|
|
106
|
+
(sorted_times[index] * 1000).round(2)
|
|
71
107
|
end
|
|
72
|
-
puts "=" * 60
|
|
73
|
-
end
|
|
74
108
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
server.addr[1]
|
|
78
|
-
ensure
|
|
79
|
-
server&.close
|
|
80
|
-
end
|
|
109
|
+
def initialize_rails_app(secret_key_base)
|
|
110
|
+
return Application if const_defined?(:Application, false)
|
|
81
111
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
controller = Class.new(ActionController::Base) do
|
|
113
|
+
def index
|
|
114
|
+
render plain: Benchmarks::TINY_RESPONSE
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def hello
|
|
118
|
+
render plain: Benchmarks::HELLO_RESPONSE
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def small
|
|
122
|
+
render plain: Benchmarks::SMALL_RESPONSE
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def big
|
|
126
|
+
render plain: Benchmarks::BIG_RESPONSE
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def sleep
|
|
130
|
+
Kernel.sleep(Benchmarks.sleep_seconds)
|
|
131
|
+
render plain: Benchmarks::TINY_RESPONSE
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
const_set(:BenchmarksController, controller)
|
|
135
|
+
|
|
136
|
+
app = Class.new(Rails::Application) do
|
|
137
|
+
config.root = __dir__
|
|
138
|
+
config.consider_all_requests_local = true
|
|
139
|
+
config.secret_key_base = secret_key_base
|
|
140
|
+
config.eager_load = false
|
|
141
|
+
config.enable_reloading = false
|
|
142
|
+
config.reload_classes_only_on_change = false
|
|
143
|
+
config.hosts.clear
|
|
144
|
+
config.logger = Logger.new(File::NULL)
|
|
145
|
+
config.public_file_server.enabled = false
|
|
146
|
+
config.active_support.to_time_preserves_timezone = :zone
|
|
147
|
+
|
|
148
|
+
routes.append do
|
|
149
|
+
root to: "benchmarks/benchmarks#index"
|
|
150
|
+
get "/hello", to: "benchmarks/benchmarks#hello"
|
|
151
|
+
get "/small", to: "benchmarks/benchmarks#small"
|
|
152
|
+
get "/big", to: "benchmarks/benchmarks#big"
|
|
153
|
+
get "/sleep", to: "benchmarks/benchmarks#sleep"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
const_set(:Application, app)
|
|
157
|
+
app.initialize!
|
|
158
|
+
|
|
159
|
+
app
|
|
160
|
+
end
|
|
87
161
|
end
|
|
88
162
|
end
|