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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. 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
@@ -1,88 +1,162 @@
1
- require "bundler/setup"
2
- require "quicsilver"
3
- require "localhost/authority"
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "socket"
4
5
 
5
6
  module Benchmarks
6
- module Helpers
7
- # Boot a Quicsilver server on a random port, yield the port, then tear down.
8
- def self.with_server(app, &block)
9
- authority = Localhost::Authority.fetch
10
- config = Quicsilver::Transport::Configuration.new(
11
- authority.certificate_path,
12
- authority.key_path
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
- port = random_port
16
- server = Quicsilver::Server.new(port, app: app, server_configuration: config)
17
- server_thread = Thread.new { server.start }
18
- sleep 0.5 # let listener settle
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
- yield port
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&.stop rescue nil
23
- server_thread&.join(2)
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
- # Compute latency stats from an array of float durations (in seconds).
27
- # Returns a Hash with :avg, :min, :max, :p50, :p95, :p99 (all in ms).
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: (times.sum / n * 1000).round(2),
36
- min: (sorted.first * 1000).round(2),
37
- max: (sorted.last * 1000).round(2),
38
- p50: (sorted[n / 2] * 1000).round(2),
39
- p95: (sorted[(n * 0.95).to_i] * 1000).round(2),
40
- p99: (sorted[(n * 0.99).to_i] * 1000).round(2)
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 self.print_header(title, **opts)
45
- puts
46
- puts "=" * 60
47
- puts title
48
- puts "=" * 60
49
- opts.each { |k, v| puts " #{k}: #{v}" }
50
- puts "-" * 60
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
- def self.print_stats(label, times)
54
- s = stats(times)
55
- unless s
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
- def self.print_results(total_time:, total_requests:, times:, failed: 0, latency: false)
63
- s = stats(times)
64
- puts "-" * 60
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
- def self.random_port
76
- server = TCPServer.new("127.0.0.1", 0)
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
- def self.benchmark_app
83
- ->(env) {
84
- [200, { "content-type" => "text/plain" }, ["OK"]]
85
- }
86
- end
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