quicsilver 0.2.0 → 0.4.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 +4 -5
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +81 -0
- data/Gemfile.lock +26 -4
- data/README.md +95 -31
- data/Rakefile +95 -3
- data/benchmarks/components.rb +191 -0
- data/benchmarks/concurrent.rb +110 -0
- data/benchmarks/helpers.rb +88 -0
- data/benchmarks/quicsilver_server.rb +1 -1
- data/benchmarks/rails.rb +170 -0
- data/benchmarks/throughput.rb +113 -0
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +111 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +18 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -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 +260 -0
- data/examples/simple_client_test.rb +2 -2
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/ext/quicsilver/extconf.rb +14 -0
- data/ext/quicsilver/quicsilver.c +568 -181
- data/lib/quicsilver/client/client.rb +349 -0
- data/lib/quicsilver/client/connection_pool.rb +106 -0
- data/lib/quicsilver/client/request.rb +98 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +176 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
- data/lib/quicsilver/protocol/frame_parser.rb +142 -0
- data/lib/quicsilver/protocol/frame_reader.rb +55 -0
- data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +47 -0
- data/lib/quicsilver/protocol/request_parser.rb +275 -0
- data/lib/quicsilver/protocol/response_encoder.rb +97 -0
- data/lib/quicsilver/protocol/response_parser.rb +141 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/quicsilver.bundle +0 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/request_handler.rb +138 -0
- data/lib/quicsilver/server/request_registry.rb +50 -0
- data/lib/quicsilver/server/server.rb +610 -0
- data/lib/quicsilver/transport/configuration.rb +141 -0
- data/lib/quicsilver/transport/connection.rb +379 -0
- data/lib/quicsilver/transport/event_loop.rb +38 -0
- data/lib/quicsilver/transport/inbound_stream.rb +33 -0
- data/lib/quicsilver/transport/stream.rb +28 -0
- data/lib/quicsilver/transport/stream_event.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +55 -14
- data/lib/rackup/handler/quicsilver.rb +1 -2
- data/quicsilver.gemspec +13 -3
- metadata +125 -21
- data/benchmarks/benchmark.rb +0 -68
- data/examples/setup_certs.sh +0 -57
- data/lib/quicsilver/client.rb +0 -261
- data/lib/quicsilver/connection.rb +0 -42
- data/lib/quicsilver/event_loop.rb +0 -38
- data/lib/quicsilver/http3/request_encoder.rb +0 -133
- data/lib/quicsilver/http3/request_parser.rb +0 -176
- data/lib/quicsilver/http3/response_encoder.rb +0 -186
- data/lib/quicsilver/http3/response_parser.rb +0 -160
- data/lib/quicsilver/listener_data.rb +0 -29
- data/lib/quicsilver/quic_stream.rb +0 -36
- data/lib/quicsilver/request_registry.rb +0 -48
- data/lib/quicsilver/server.rb +0 -355
- data/lib/quicsilver/server_configuration.rb +0 -78
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Component micro-benchmarks using benchmark-ips.
|
|
3
|
+
# No server needed — pure in-process measurements.
|
|
4
|
+
#
|
|
5
|
+
# Usage: ruby benchmarks/components.rb
|
|
6
|
+
|
|
7
|
+
require "bundler/setup"
|
|
8
|
+
require "quicsilver"
|
|
9
|
+
require "benchmark/ips"
|
|
10
|
+
|
|
11
|
+
# --- Varint ---
|
|
12
|
+
|
|
13
|
+
puts "=" * 60
|
|
14
|
+
puts "Varint encode/decode"
|
|
15
|
+
puts "=" * 60
|
|
16
|
+
|
|
17
|
+
small = 6
|
|
18
|
+
medium = 1_000
|
|
19
|
+
large = 1_000_000
|
|
20
|
+
|
|
21
|
+
encoded_small = Quicsilver::Protocol.encode_varint(small)
|
|
22
|
+
encoded_medium = Quicsilver::Protocol.encode_varint(medium)
|
|
23
|
+
encoded_large = Quicsilver::Protocol.encode_varint(large)
|
|
24
|
+
|
|
25
|
+
Benchmark.ips do |x|
|
|
26
|
+
x.config(warmup: 1, time: 3)
|
|
27
|
+
|
|
28
|
+
x.report("encode small (#{small})") { Quicsilver::Protocol.encode_varint(small) }
|
|
29
|
+
x.report("encode medium (#{medium})") { Quicsilver::Protocol.encode_varint(medium) }
|
|
30
|
+
x.report("encode large (#{large})") { Quicsilver::Protocol.encode_varint(large) }
|
|
31
|
+
x.report("decode small") { Quicsilver::Protocol.decode_varint(encoded_small.bytes, 0) }
|
|
32
|
+
x.report("decode medium") { Quicsilver::Protocol.decode_varint(encoded_medium.bytes, 0) }
|
|
33
|
+
x.report("decode large") { Quicsilver::Protocol.decode_varint(encoded_large.bytes, 0) }
|
|
34
|
+
|
|
35
|
+
x.compare!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- Huffman ---
|
|
39
|
+
|
|
40
|
+
puts
|
|
41
|
+
puts "=" * 60
|
|
42
|
+
puts "Huffman encode/decode"
|
|
43
|
+
puts "=" * 60
|
|
44
|
+
|
|
45
|
+
huffman_inputs = [
|
|
46
|
+
"www.example.com",
|
|
47
|
+
"application/json",
|
|
48
|
+
"text/html; charset=utf-8",
|
|
49
|
+
"GET",
|
|
50
|
+
"/api/v1/users?page=1&limit=50"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
encoded_huffman = huffman_inputs.map { |s| Quicsilver::Protocol::Qpack::Huffman.encode(s) }
|
|
54
|
+
|
|
55
|
+
Benchmark.ips do |x|
|
|
56
|
+
x.config(warmup: 1, time: 3)
|
|
57
|
+
|
|
58
|
+
huffman_inputs.each do |input|
|
|
59
|
+
x.report("encode #{input[0..20]}") { Quicsilver::Protocol::Qpack::Huffman.encode(input) }
|
|
60
|
+
end
|
|
61
|
+
encoded_huffman.each_with_index do |enc, i|
|
|
62
|
+
x.report("decode #{huffman_inputs[i][0..20]}") { Quicsilver::Protocol::Qpack::Huffman.decode(enc) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
x.compare!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- QPACK Encoder ---
|
|
69
|
+
|
|
70
|
+
puts
|
|
71
|
+
puts "=" * 60
|
|
72
|
+
puts "QPACK Encoder"
|
|
73
|
+
puts "=" * 60
|
|
74
|
+
|
|
75
|
+
headers = [
|
|
76
|
+
[":method", "GET"],
|
|
77
|
+
[":path", "/api/v1/users"],
|
|
78
|
+
[":scheme", "https"],
|
|
79
|
+
[":authority", "example.com"],
|
|
80
|
+
["accept", "application/json"],
|
|
81
|
+
["user-agent", "quicsilver-bench/1.0"],
|
|
82
|
+
["accept-encoding", "gzip, deflate"]
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
encoder_huffman = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
|
|
86
|
+
encoder_raw = Quicsilver::Protocol::Qpack::Encoder.new(huffman: false)
|
|
87
|
+
|
|
88
|
+
Benchmark.ips do |x|
|
|
89
|
+
x.config(warmup: 1, time: 3)
|
|
90
|
+
|
|
91
|
+
x.report("encode (huffman on)") { encoder_huffman.encode(headers) }
|
|
92
|
+
x.report("encode (huffman off)") { encoder_raw.encode(headers) }
|
|
93
|
+
|
|
94
|
+
x.compare!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# --- QPACK Decoder ---
|
|
98
|
+
|
|
99
|
+
puts
|
|
100
|
+
puts "=" * 60
|
|
101
|
+
puts "QPACK Decoder (string decoding)"
|
|
102
|
+
puts "=" * 60
|
|
103
|
+
|
|
104
|
+
# Build payloads for decode_qpack_string
|
|
105
|
+
huffman_payload = Quicsilver::Protocol::Qpack::Huffman.encode("application/json")
|
|
106
|
+
huffman_bytes = [0x80 | huffman_payload.bytesize] + huffman_payload.bytes # Huffman flag set
|
|
107
|
+
|
|
108
|
+
raw_string = "application/json"
|
|
109
|
+
raw_bytes = [raw_string.bytesize] + raw_string.bytes # No Huffman flag
|
|
110
|
+
|
|
111
|
+
# Include the decoder module in a throwaway object
|
|
112
|
+
decoder = Object.new
|
|
113
|
+
decoder.extend(Quicsilver::Protocol::Qpack::Decoder)
|
|
114
|
+
|
|
115
|
+
Benchmark.ips do |x|
|
|
116
|
+
x.config(warmup: 1, time: 3)
|
|
117
|
+
|
|
118
|
+
x.report("decode huffman string") { decoder.decode_qpack_string(huffman_bytes, 0) }
|
|
119
|
+
x.report("decode raw string") { decoder.decode_qpack_string(raw_bytes, 0) }
|
|
120
|
+
|
|
121
|
+
x.compare!
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# --- Request Parser ---
|
|
125
|
+
|
|
126
|
+
puts
|
|
127
|
+
puts "=" * 60
|
|
128
|
+
puts "Request Parser"
|
|
129
|
+
puts "=" * 60
|
|
130
|
+
|
|
131
|
+
# Build a realistic GET request frame
|
|
132
|
+
request_encoder = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
|
|
133
|
+
request_headers_payload = request_encoder.encode([
|
|
134
|
+
[":method", "GET"],
|
|
135
|
+
[":path", "/api/v1/users?page=1"],
|
|
136
|
+
[":scheme", "https"],
|
|
137
|
+
[":authority", "example.com"],
|
|
138
|
+
["accept", "application/json"],
|
|
139
|
+
["user-agent", "quicsilver-bench/1.0"]
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
# HEADERS frame: type=0x01, varint length, payload
|
|
143
|
+
request_frame = Quicsilver::Protocol.encode_varint(0x01) +
|
|
144
|
+
Quicsilver::Protocol.encode_varint(request_headers_payload.bytesize) +
|
|
145
|
+
request_headers_payload
|
|
146
|
+
|
|
147
|
+
Benchmark.ips do |x|
|
|
148
|
+
x.config(warmup: 1, time: 3)
|
|
149
|
+
|
|
150
|
+
x.report("parse GET request") do
|
|
151
|
+
parser = Quicsilver::Protocol::RequestParser.new(request_frame)
|
|
152
|
+
parser.parse
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
x.compare!
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- Response Parser ---
|
|
159
|
+
|
|
160
|
+
puts
|
|
161
|
+
puts "=" * 60
|
|
162
|
+
puts "Response Parser"
|
|
163
|
+
puts "=" * 60
|
|
164
|
+
|
|
165
|
+
# Build a realistic 200 response with body
|
|
166
|
+
response_encoder = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
|
|
167
|
+
response_headers_payload = response_encoder.encode([
|
|
168
|
+
[":status", "200"],
|
|
169
|
+
["content-type", "application/json"],
|
|
170
|
+
["server", "quicsilver"]
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
body = '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}'
|
|
174
|
+
|
|
175
|
+
response_frame = Quicsilver::Protocol.encode_varint(0x01) +
|
|
176
|
+
Quicsilver::Protocol.encode_varint(response_headers_payload.bytesize) +
|
|
177
|
+
response_headers_payload +
|
|
178
|
+
Quicsilver::Protocol.encode_varint(0x00) +
|
|
179
|
+
Quicsilver::Protocol.encode_varint(body.bytesize) +
|
|
180
|
+
body
|
|
181
|
+
|
|
182
|
+
Benchmark.ips do |x|
|
|
183
|
+
x.config(warmup: 1, time: 3)
|
|
184
|
+
|
|
185
|
+
x.report("parse 200 response + body") do
|
|
186
|
+
parser = Quicsilver::Protocol::ResponseParser.new(response_frame)
|
|
187
|
+
parser.parse
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
x.compare!
|
|
191
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Concurrency benchmark: tests HTTP/3 multiplexing and multi-connection overhead.
|
|
3
|
+
#
|
|
4
|
+
# Self-contained:
|
|
5
|
+
# ruby benchmarks/concurrent.rb
|
|
6
|
+
#
|
|
7
|
+
# External server:
|
|
8
|
+
# HOST=127.0.0.1 PORT=4433 ruby benchmarks/concurrent.rb
|
|
9
|
+
|
|
10
|
+
require_relative "helpers"
|
|
11
|
+
require "benchmark"
|
|
12
|
+
|
|
13
|
+
MULTIPLEX_REQUESTS = ENV.fetch("MULTIPLEX_REQUESTS", "50").to_i
|
|
14
|
+
NUM_CLIENTS = ENV.fetch("NUM_CLIENTS", "20").to_i
|
|
15
|
+
REQUESTS_PER_CLIENT = ENV.fetch("REQUESTS_PER_CLIENT", "5").to_i
|
|
16
|
+
HOST = ENV["HOST"]
|
|
17
|
+
PORT = ENV["PORT"]&.to_i
|
|
18
|
+
|
|
19
|
+
def test_multiplexing(host, port)
|
|
20
|
+
Benchmarks::Helpers.print_header(
|
|
21
|
+
"Test 1: Single-connection multiplexing",
|
|
22
|
+
requests: MULTIPLEX_REQUESTS
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
request_times = []
|
|
26
|
+
mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
client = Quicsilver::Client.new(host, port, unsecure: true)
|
|
29
|
+
client.open_connection
|
|
30
|
+
|
|
31
|
+
elapsed = Benchmark.realtime do
|
|
32
|
+
threads = MULTIPLEX_REQUESTS.times.map do |i|
|
|
33
|
+
Thread.new do
|
|
34
|
+
start = Time.now
|
|
35
|
+
client.get("/multiplex/#{i}")
|
|
36
|
+
req_time = Time.now - start
|
|
37
|
+
mutex.synchronize { request_times << req_time }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
threads.each(&:join)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
client.disconnect
|
|
44
|
+
|
|
45
|
+
concurrency_factor = request_times.any? ? (request_times.sum / elapsed).round(2) : 0
|
|
46
|
+
puts " Wall clock: #{(elapsed * 1000).round(2)}ms"
|
|
47
|
+
puts " Avg request time: #{(request_times.sum / request_times.size * 1000).round(2)}ms"
|
|
48
|
+
puts " Throughput: #{(MULTIPLEX_REQUESTS / elapsed).round(2)} req/s"
|
|
49
|
+
puts " Concurrency factor: #{concurrency_factor}x"
|
|
50
|
+
Benchmarks::Helpers.print_stats("Latency", request_times)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_concurrent_clients(host, port)
|
|
54
|
+
Benchmarks::Helpers.print_header(
|
|
55
|
+
"Test 2: Concurrent clients",
|
|
56
|
+
clients: NUM_CLIENTS,
|
|
57
|
+
requests_per_client: REQUESTS_PER_CLIENT
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
request_times = []
|
|
61
|
+
mutex = Mutex.new
|
|
62
|
+
successful = 0
|
|
63
|
+
|
|
64
|
+
elapsed = Benchmark.realtime do
|
|
65
|
+
threads = NUM_CLIENTS.times.map do |i|
|
|
66
|
+
Thread.new do
|
|
67
|
+
client = Quicsilver::Client.new(host, port, unsecure: true)
|
|
68
|
+
client.open_connection
|
|
69
|
+
|
|
70
|
+
REQUESTS_PER_CLIENT.times do |req|
|
|
71
|
+
start = Time.now
|
|
72
|
+
client.get("/client#{i}/request#{req}")
|
|
73
|
+
req_time = Time.now - start
|
|
74
|
+
mutex.synchronize { request_times << req_time }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
client.disconnect
|
|
78
|
+
mutex.synchronize { successful += 1 }
|
|
79
|
+
rescue => e
|
|
80
|
+
$stderr.puts " Client #{i} error: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
threads.each(&:join)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
total_requests = request_times.size
|
|
87
|
+
concurrency_factor = request_times.any? ? (request_times.sum / elapsed).round(2) : 0
|
|
88
|
+
|
|
89
|
+
puts " Successful clients: #{successful}/#{NUM_CLIENTS}"
|
|
90
|
+
puts " Total requests: #{total_requests}"
|
|
91
|
+
puts " Wall clock: #{(elapsed * 1000).round(2)}ms"
|
|
92
|
+
puts " Avg request time: #{request_times.any? ? (request_times.sum / request_times.size * 1000).round(2) : "N/A"}ms"
|
|
93
|
+
puts " Throughput: #{(total_requests / elapsed).round(2)} req/s"
|
|
94
|
+
puts " Concurrency factor: #{concurrency_factor}x"
|
|
95
|
+
Benchmarks::Helpers.print_stats("Latency", request_times)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run_all(host, port)
|
|
99
|
+
test_multiplexing(host, port)
|
|
100
|
+
test_concurrent_clients(host, port)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if HOST && PORT
|
|
104
|
+
run_all(HOST, PORT)
|
|
105
|
+
else
|
|
106
|
+
puts "Booting inline server..."
|
|
107
|
+
Benchmarks::Helpers.with_server(Benchmarks::Helpers.benchmark_app) do |port|
|
|
108
|
+
run_all("127.0.0.1", port)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
require "quicsilver"
|
|
3
|
+
require "localhost/authority"
|
|
4
|
+
|
|
5
|
+
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
|
+
)
|
|
14
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
yield port
|
|
21
|
+
ensure
|
|
22
|
+
server&.stop rescue nil
|
|
23
|
+
server_thread&.join(2)
|
|
24
|
+
end
|
|
25
|
+
|
|
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?
|
|
30
|
+
|
|
31
|
+
sorted = times.sort
|
|
32
|
+
n = sorted.size
|
|
33
|
+
{
|
|
34
|
+
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)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
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
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.print_stats(label, times)
|
|
54
|
+
s = stats(times)
|
|
55
|
+
unless s
|
|
56
|
+
puts " #{label}: no data"
|
|
57
|
+
return
|
|
58
|
+
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
|
+
|
|
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"
|
|
71
|
+
end
|
|
72
|
+
puts "=" * 60
|
|
73
|
+
end
|
|
74
|
+
|
|
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
|
|
81
|
+
|
|
82
|
+
def self.benchmark_app
|
|
83
|
+
->(env) {
|
|
84
|
+
[200, { "content-type" => "text/plain" }, ["OK"]]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -26,7 +26,7 @@ authority = Localhost::Authority.fetch
|
|
|
26
26
|
cert_file = authority.certificate_path
|
|
27
27
|
key_file = authority.key_path
|
|
28
28
|
|
|
29
|
-
config = ::Quicsilver::
|
|
29
|
+
config = ::Quicsilver::Transport::Configuration.new(cert_file, key_file)
|
|
30
30
|
|
|
31
31
|
server = ::Quicsilver::Server.new(
|
|
32
32
|
port.to_i,
|
data/benchmarks/rails.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Rails benchmark: concurrent POST, GET, DELETE against a Rails app.
|
|
3
|
+
# Multiplexes requests within each connection (HTTP/3 streams),
|
|
4
|
+
# capped by CONCURRENCY to stay within the server's stream limit.
|
|
5
|
+
#
|
|
6
|
+
# Start blogz first:
|
|
7
|
+
# cd ../blogz && bundle exec rackup -s quicsilver -p 4433
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# CONNECTIONS=5 ITERATIONS=100 ruby benchmarks/rails.rb
|
|
11
|
+
# CONCURRENCY=8 CONNECTIONS=3 ITERATIONS=200 ruby benchmarks/rails.rb
|
|
12
|
+
|
|
13
|
+
require "bundler/setup"
|
|
14
|
+
require "quicsilver"
|
|
15
|
+
require "json"
|
|
16
|
+
require "benchmark"
|
|
17
|
+
|
|
18
|
+
require_relative "helpers"
|
|
19
|
+
|
|
20
|
+
HOST = ENV.fetch("HOST", "127.0.0.1")
|
|
21
|
+
PORT = ENV.fetch("PORT", "4433").to_i
|
|
22
|
+
CONNECTIONS = ENV.fetch("CONNECTIONS", "5").to_i
|
|
23
|
+
ITERATIONS = ENV.fetch("ITERATIONS", "100").to_i
|
|
24
|
+
CONCURRENCY = ENV.fetch("CONCURRENCY", "8").to_i # max in-flight per connection
|
|
25
|
+
|
|
26
|
+
total_requests = CONNECTIONS * ITERATIONS
|
|
27
|
+
|
|
28
|
+
Benchmarks::Helpers.print_header(
|
|
29
|
+
"Rails Concurrent Benchmark (multiplexed)",
|
|
30
|
+
target: "#{HOST}:#{PORT}",
|
|
31
|
+
connections: CONNECTIONS,
|
|
32
|
+
"reqs/conn": ITERATIONS,
|
|
33
|
+
concurrency: "#{CONCURRENCY} streams/conn",
|
|
34
|
+
total: "#{total_requests * 3} (POST + GET + DELETE)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
mutex = Mutex.new
|
|
38
|
+
results = { post: [], get: [], delete: [] }
|
|
39
|
+
all_created_ids = []
|
|
40
|
+
|
|
41
|
+
# Fire N requests with at most `concurrency` in-flight at a time on a shared connection.
|
|
42
|
+
def multiplex(count, concurrency:)
|
|
43
|
+
queue = Queue.new
|
|
44
|
+
count.times { |i| queue << i }
|
|
45
|
+
|
|
46
|
+
threads = concurrency.times.map do
|
|
47
|
+
Thread.new do
|
|
48
|
+
while (i = queue.pop(true) rescue nil)
|
|
49
|
+
yield i
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
threads.each(&:join)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Phase 1: Concurrent multiplexed POSTs
|
|
57
|
+
puts "\nPhase 1: POST /posts.json (#{CONNECTIONS} conns x #{ITERATIONS}, #{CONCURRENCY} in-flight)..."
|
|
58
|
+
post_elapsed = Benchmark.realtime do
|
|
59
|
+
conn_threads = CONNECTIONS.times.map do |conn_id|
|
|
60
|
+
Thread.new do
|
|
61
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
62
|
+
client.open_connection
|
|
63
|
+
|
|
64
|
+
local_times = []
|
|
65
|
+
local_ids = []
|
|
66
|
+
|
|
67
|
+
multiplex(ITERATIONS, concurrency: CONCURRENCY) do |i|
|
|
68
|
+
start = Time.now
|
|
69
|
+
response = client.post(
|
|
70
|
+
"/posts.json",
|
|
71
|
+
headers: { "content-type" => "application/json" },
|
|
72
|
+
body: { post: { name: "Author #{conn_id}-#{i}", title: "Post #{conn_id}-#{i}" } }.to_json
|
|
73
|
+
)
|
|
74
|
+
elapsed = Time.now - start
|
|
75
|
+
|
|
76
|
+
if response && response[:status] == 201
|
|
77
|
+
body = JSON.parse(response[:body]) rescue {}
|
|
78
|
+
mutex.synchronize do
|
|
79
|
+
local_times << elapsed
|
|
80
|
+
local_ids << body["id"] if body["id"]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
client.disconnect
|
|
86
|
+
mutex.synchronize do
|
|
87
|
+
results[:post].concat(local_times)
|
|
88
|
+
all_created_ids.concat(local_ids)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
conn_threads.each(&:join)
|
|
93
|
+
end
|
|
94
|
+
puts " #{results[:post].size} created in #{post_elapsed.round(2)}s (#{(results[:post].size / post_elapsed).round(1)} req/s)"
|
|
95
|
+
|
|
96
|
+
# Phase 2: Concurrent multiplexed GETs
|
|
97
|
+
puts "\nPhase 2: GET /posts.json (#{CONNECTIONS} conns x #{ITERATIONS}, #{CONCURRENCY} in-flight)..."
|
|
98
|
+
get_elapsed = Benchmark.realtime do
|
|
99
|
+
conn_threads = CONNECTIONS.times.map do
|
|
100
|
+
Thread.new do
|
|
101
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
102
|
+
client.open_connection
|
|
103
|
+
|
|
104
|
+
local_times = []
|
|
105
|
+
|
|
106
|
+
multiplex(ITERATIONS, concurrency: CONCURRENCY) do |_i|
|
|
107
|
+
start = Time.now
|
|
108
|
+
response = client.get("/posts.json")
|
|
109
|
+
elapsed = Time.now - start
|
|
110
|
+
|
|
111
|
+
if response && response[:status] == 200
|
|
112
|
+
mutex.synchronize { local_times << elapsed }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
client.disconnect
|
|
117
|
+
mutex.synchronize { results[:get].concat(local_times) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
conn_threads.each(&:join)
|
|
121
|
+
end
|
|
122
|
+
puts " #{results[:get].size} fetched in #{get_elapsed.round(2)}s (#{(results[:get].size / get_elapsed).round(1)} req/s)"
|
|
123
|
+
|
|
124
|
+
# Phase 3: Concurrent multiplexed DELETEs
|
|
125
|
+
delete_count = all_created_ids.size
|
|
126
|
+
puts "\nPhase 3: DELETE /posts/:id (#{delete_count} across #{CONNECTIONS} conns, #{CONCURRENCY} in-flight)..."
|
|
127
|
+
delete_elapsed = Benchmark.realtime do
|
|
128
|
+
id_chunks = all_created_ids.each_slice((all_created_ids.size.to_f / CONNECTIONS).ceil).to_a
|
|
129
|
+
|
|
130
|
+
conn_threads = id_chunks.map do |ids|
|
|
131
|
+
Thread.new do
|
|
132
|
+
next if ids.empty?
|
|
133
|
+
|
|
134
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
135
|
+
client.open_connection
|
|
136
|
+
|
|
137
|
+
local_times = []
|
|
138
|
+
|
|
139
|
+
multiplex(ids.size, concurrency: CONCURRENCY) do |i|
|
|
140
|
+
start = Time.now
|
|
141
|
+
response = client.delete("/posts/#{ids[i]}.json")
|
|
142
|
+
elapsed = Time.now - start
|
|
143
|
+
|
|
144
|
+
if response && response[:status] == 204
|
|
145
|
+
mutex.synchronize { local_times << elapsed }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
client.disconnect
|
|
150
|
+
mutex.synchronize { results[:delete].concat(local_times) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
conn_threads.each(&:join)
|
|
154
|
+
end
|
|
155
|
+
puts " #{results[:delete].size} deleted in #{delete_elapsed.round(2)}s (#{(results[:delete].size / delete_elapsed).round(1)} req/s)"
|
|
156
|
+
|
|
157
|
+
# Summary
|
|
158
|
+
total_elapsed = post_elapsed + get_elapsed + delete_elapsed
|
|
159
|
+
total_completed = results.values.sum(&:size)
|
|
160
|
+
|
|
161
|
+
puts
|
|
162
|
+
puts "=" * 70
|
|
163
|
+
puts "RESULTS"
|
|
164
|
+
puts "=" * 70
|
|
165
|
+
Benchmarks::Helpers.print_stats("POST /posts.json", results[:post])
|
|
166
|
+
Benchmarks::Helpers.print_stats("GET /posts.json", results[:get])
|
|
167
|
+
Benchmarks::Helpers.print_stats("DELETE /posts/:id ", results[:delete])
|
|
168
|
+
puts "-" * 70
|
|
169
|
+
puts " Total: #{total_completed} requests in #{total_elapsed.round(2)}s (#{(total_completed / total_elapsed).round(2)} req/s)"
|
|
170
|
+
puts "=" * 70
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Throughput benchmark: measures req/sec and latency percentiles.
|
|
3
|
+
# Tests both sequential and concurrent (multiplexed) modes.
|
|
4
|
+
#
|
|
5
|
+
# Self-contained (boots inline server with trivial Rack app):
|
|
6
|
+
# ruby benchmarks/throughput.rb
|
|
7
|
+
#
|
|
8
|
+
# External server:
|
|
9
|
+
# HOST=127.0.0.1 PORT=4433 ruby benchmarks/throughput.rb
|
|
10
|
+
|
|
11
|
+
require_relative "helpers"
|
|
12
|
+
require "benchmark"
|
|
13
|
+
|
|
14
|
+
REQUESTS = ENV.fetch("REQUESTS", "500").to_i
|
|
15
|
+
CONNECTIONS = ENV.fetch("CONNECTIONS", "5").to_i
|
|
16
|
+
CONCURRENCY = ENV.fetch("CONCURRENCY", "8").to_i
|
|
17
|
+
HOST = ENV["HOST"]
|
|
18
|
+
PORT = ENV["PORT"]&.to_i
|
|
19
|
+
|
|
20
|
+
def run_benchmark(host, port)
|
|
21
|
+
# --- Sequential: 1 request at a time per connection ---
|
|
22
|
+
puts "\n--- Sequential (1 stream/conn, #{CONNECTIONS} conns) ---"
|
|
23
|
+
seq_times = []
|
|
24
|
+
mutex = Mutex.new
|
|
25
|
+
|
|
26
|
+
seq_elapsed = Benchmark.realtime do
|
|
27
|
+
per_conn = REQUESTS / CONNECTIONS
|
|
28
|
+
|
|
29
|
+
threads = CONNECTIONS.times.map do
|
|
30
|
+
Thread.new do
|
|
31
|
+
client = Quicsilver::Client.new(host, port, connection_timeout: 5000, request_timeout: 10)
|
|
32
|
+
client.open_connection
|
|
33
|
+
|
|
34
|
+
local = []
|
|
35
|
+
per_conn.times do
|
|
36
|
+
start = Time.now
|
|
37
|
+
response = client.get("/")
|
|
38
|
+
local << (Time.now - start) if response && response[:status] == 200
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
client.disconnect
|
|
42
|
+
mutex.synchronize { seq_times.concat(local) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
threads.each(&:join)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Benchmarks::Helpers.print_results(
|
|
49
|
+
total_time: seq_elapsed, total_requests: REQUESTS,
|
|
50
|
+
times: seq_times, failed: REQUESTS - seq_times.size, latency: true
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# --- Concurrent: CONCURRENCY streams per connection ---
|
|
54
|
+
puts "\n--- Concurrent (#{CONCURRENCY} streams/conn, #{CONNECTIONS} conns) ---"
|
|
55
|
+
con_times = []
|
|
56
|
+
con_failed = 0
|
|
57
|
+
|
|
58
|
+
con_elapsed = Benchmark.realtime do
|
|
59
|
+
per_conn = REQUESTS / CONNECTIONS
|
|
60
|
+
|
|
61
|
+
threads = CONNECTIONS.times.map do
|
|
62
|
+
Thread.new do
|
|
63
|
+
client = Quicsilver::Client.new(host, port, connection_timeout: 5000, request_timeout: 10)
|
|
64
|
+
client.open_connection
|
|
65
|
+
|
|
66
|
+
local = []
|
|
67
|
+
queue = Queue.new
|
|
68
|
+
per_conn.times { |i| queue << i }
|
|
69
|
+
|
|
70
|
+
workers = CONCURRENCY.times.map do
|
|
71
|
+
Thread.new do
|
|
72
|
+
while (queue.pop(true) rescue nil)
|
|
73
|
+
start = Time.now
|
|
74
|
+
response = client.get("/bench")
|
|
75
|
+
dur = Time.now - start
|
|
76
|
+
if response && response[:status] == 200
|
|
77
|
+
local << dur
|
|
78
|
+
else
|
|
79
|
+
mutex.synchronize { con_failed += 1 }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
workers.each(&:join)
|
|
85
|
+
client.disconnect
|
|
86
|
+
mutex.synchronize { con_times.concat(local) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
threads.each(&:join)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Benchmarks::Helpers.print_results(
|
|
93
|
+
total_time: con_elapsed, total_requests: REQUESTS,
|
|
94
|
+
times: con_times, failed: con_failed, latency: true
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Benchmarks::Helpers.print_header(
|
|
99
|
+
"Quicsilver Throughput (trivial Rack app, no DB)",
|
|
100
|
+
connections: CONNECTIONS,
|
|
101
|
+
"reqs/conn": REQUESTS / CONNECTIONS,
|
|
102
|
+
concurrency: "#{CONCURRENCY} streams/conn",
|
|
103
|
+
total: REQUESTS
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if HOST && PORT
|
|
107
|
+
run_benchmark(HOST, PORT)
|
|
108
|
+
else
|
|
109
|
+
puts "Booting inline server..."
|
|
110
|
+
Benchmarks::Helpers.with_server(Benchmarks::Helpers.benchmark_app) do |port|
|
|
111
|
+
run_benchmark("localhost", port)
|
|
112
|
+
end
|
|
113
|
+
end
|