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/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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Quicsilver, Falcon and Puma over a link with a real round trip.
|
|
5
|
+
#
|
|
6
|
+
# This is the comparison loopback cannot give you. On localhost every protocol
|
|
7
|
+
# looks the same because there is no round trip to spend, and the benchmark
|
|
8
|
+
# measures Ruby rather than HTTP. Add a delay and the protocols start to differ
|
|
9
|
+
# for the reasons they were designed to.
|
|
10
|
+
#
|
|
11
|
+
# Loss is in the same currency on both sides: one in N packets costs the
|
|
12
|
+
# affected stream one round trip. On UDP that is a dropped datagram. On TCP the
|
|
13
|
+
# bytes cannot be dropped, so the stream stalls for a round trip instead, which
|
|
14
|
+
# is what the receiving kernel does while it waits for the retransmission. Same
|
|
15
|
+
# probability, same cost, and what is left over is the protocol's own
|
|
16
|
+
# behaviour. See Impair::Tcp for what that does and does not reproduce.
|
|
17
|
+
#
|
|
18
|
+
# ruby benchmarks/latency.rb
|
|
19
|
+
# DELAY_MS=5,25,50 REQUESTS=200 CONCURRENCY=20 ruby benchmarks/latency.rb
|
|
20
|
+
#
|
|
21
|
+
# DELAY_MS is one-way, so the round trip is twice it. Every server runs through
|
|
22
|
+
# a relay, including at 0ms, so the relay is in the baseline rather than a
|
|
23
|
+
# handicap applied to one side.
|
|
24
|
+
|
|
25
|
+
ENV["RUNS"] ||= "1"
|
|
26
|
+
require_relative "compare"
|
|
27
|
+
require "impair"
|
|
28
|
+
|
|
29
|
+
DELAYS_MS = ENV.fetch("DELAY_MS", "0,25").split(",").map { |value| Float(value) }
|
|
30
|
+
LOSS = Integer(ENV.fetch("LOSS", "0"))
|
|
31
|
+
REPEATS = Integer(ENV.fetch("REPEATS", "3"))
|
|
32
|
+
SEED = Integer(ENV.fetch("SEED", "1234"))
|
|
33
|
+
|
|
34
|
+
def median_of(values)
|
|
35
|
+
sorted = values.sort
|
|
36
|
+
sorted[sorted.length / 2]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def with_udp_relay(target_port, delay_ms)
|
|
40
|
+
proxy = Impair::Udp.new(
|
|
41
|
+
host: "::1", target_host: "::1", target_port: target_port,
|
|
42
|
+
delay: delay_ms / 1000.0, loss: LOSS, seed: SEED
|
|
43
|
+
).start
|
|
44
|
+
yield proxy.port
|
|
45
|
+
ensure
|
|
46
|
+
proxy&.stop
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The TCP stall per lost segment is one round trip, and Impair derives that as
|
|
50
|
+
# 2 * delay (Impair::Config#rtt) rather than taking it separately: two knobs
|
|
51
|
+
# for one quantity is how the two arms drift apart. This passed rtt: as well,
|
|
52
|
+
# which Config now rejects outright, so this benchmark raised ArgumentError on
|
|
53
|
+
# every run. Dropping it preserves the behaviour, since the value passed was
|
|
54
|
+
# exactly 2 * delay.
|
|
55
|
+
def with_tcp_relay(target_port, delay_ms, host: "127.0.0.1")
|
|
56
|
+
proxy = Impair::Tcp.new(
|
|
57
|
+
host: host, target_host: host, target_port: target_port,
|
|
58
|
+
delay: delay_ms / 1000.0, loss: LOSS, seed: SEED
|
|
59
|
+
).start
|
|
60
|
+
yield proxy.port
|
|
61
|
+
ensure
|
|
62
|
+
proxy&.stop
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def measure_with_delay(name, delay_ms)
|
|
66
|
+
port = free_port
|
|
67
|
+
case name
|
|
68
|
+
when "quicsilver"
|
|
69
|
+
with_quicsilver(port) do
|
|
70
|
+
with_udp_relay(port, delay_ms) { |front| measure_quicsilver(front) }
|
|
71
|
+
end
|
|
72
|
+
when "falcon"
|
|
73
|
+
with_falcon(port) do
|
|
74
|
+
with_tcp_relay(port, delay_ms) { |front| measure_async("falcon", "HTTP/2", front) }
|
|
75
|
+
end
|
|
76
|
+
when "puma"
|
|
77
|
+
with_puma(port) do
|
|
78
|
+
with_tcp_relay(port, delay_ms) { |front| measure_async("puma", "HTTP/1.1", front, host: "127.0.0.1") }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts "Quicsilver, Falcon and Puma over a delayed link"
|
|
84
|
+
puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats, all over TLS"
|
|
85
|
+
puts "delay is one-way in milliseconds, so the round trip is twice it"
|
|
86
|
+
puts "every server runs through an impairment relay, including at 0ms"
|
|
87
|
+
puts "loss 1-in-#{LOSS} packets, costing the affected stream one round trip" if LOSS.positive?
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
rows = []
|
|
91
|
+
|
|
92
|
+
DELAYS_MS.each do |delay_ms|
|
|
93
|
+
%w[quicsilver falcon puma].each do |name|
|
|
94
|
+
attempts = REPEATS.times.filter_map do
|
|
95
|
+
result = measure_with_delay(name, delay_ms)
|
|
96
|
+
next unless result
|
|
97
|
+
|
|
98
|
+
stats = Benchmarks.stats(result.times)
|
|
99
|
+
{rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99], failed: result.failed}
|
|
100
|
+
rescue StandardError => error
|
|
101
|
+
warn "#{name} at #{delay_ms}ms failed: #{error.class}: #{error.message}"
|
|
102
|
+
warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
next if attempts.empty?
|
|
106
|
+
|
|
107
|
+
rows << [delay_ms, name, attempts]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
protocols = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}
|
|
112
|
+
|
|
113
|
+
puts format("%-8s %-12s %-9s %9s %9s %9s %9s %8s",
|
|
114
|
+
"delay", "server", "protocol", "Req/s", "p50", "p95", "p99", "Failed")
|
|
115
|
+
puts "-" * 84
|
|
116
|
+
rows.each do |(delay_ms, name, attempts)|
|
|
117
|
+
puts format("%6.0fms %-12s %-9s %9.0f %7.2fms %7.2fms %7.2fms %8d",
|
|
118
|
+
delay_ms, name, protocols.fetch(name),
|
|
119
|
+
median_of(attempts.map { |a| a[:rps] }),
|
|
120
|
+
median_of(attempts.map { |a| a[:p50] }),
|
|
121
|
+
median_of(attempts.map { |a| a[:p95] }),
|
|
122
|
+
median_of(attempts.map { |a| a[:p99] }),
|
|
123
|
+
attempts.sum { |a| a[:failed] })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
puts
|
|
127
|
+
puts "HTTP/1.1 opens a connection per concurrent request, so it pays the"
|
|
128
|
+
puts "handshake more often as delay rises. HTTP/2 and HTTP/3 multiplex over one."
|
data/benchmarks/loss.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Head-of-line blocking, measured on a damaged link.
|
|
5
|
+
#
|
|
6
|
+
# HTTP/2 multiplexes streams over one TCP connection, and TCP owes the
|
|
7
|
+
# application one ordered byte stream, so a lost packet stalls every stream
|
|
8
|
+
# sharing that connection. HTTP/3 scopes ordering to the stream, so a lost
|
|
9
|
+
# packet stalls only the streams whose data it carried. RFC 9114 1.1 puts it
|
|
10
|
+
# as: "because the parallel nature of HTTP/2's multiplexing is not visible to
|
|
11
|
+
# TCP's loss recovery mechanisms, a lost or reordered packet causes all active
|
|
12
|
+
# transactions to experience a stall regardless of whether that transaction was
|
|
13
|
+
# directly impacted by the lost packet."
|
|
14
|
+
#
|
|
15
|
+
# That is the central claim of HTTP/3, and loopback cannot show it, because
|
|
16
|
+
# loopback never drops a packet. So the packets are dropped in userspace:
|
|
17
|
+
#
|
|
18
|
+
# client ──▶ Impair::Udp (drops 1 in N) ──▶ Quicsilver HTTP/3
|
|
19
|
+
# client ──▶ Impair::Tcp (stalls + halves cwnd 1 in N) ──▶ Falcon HTTP/2
|
|
20
|
+
# client ──▶ Impair::Tcp (stalls + halves cwnd 1 in N) ──▶ Puma HTTP/1.1
|
|
21
|
+
#
|
|
22
|
+
# The three relays take the same link config. The TCP relay cannot drop bytes,
|
|
23
|
+
# so it charges what a drop costs instead: a one-RTT stall of the connection
|
|
24
|
+
# and a halved sender window for every segment the dice say was lost, an RTO
|
|
25
|
+
# and window collapse for a burst. Same loss probability per packet, same
|
|
26
|
+
# sender reaction; see Impair::Tcp for what that does and does not reproduce.
|
|
27
|
+
#
|
|
28
|
+
# Kernel shaping was tried first and abandoned. macOS dummynet does not shape
|
|
29
|
+
# the loopback interface: pings through a 3% pipe reported 0.0% loss, so the
|
|
30
|
+
# experiment was reporting "loss changes nothing" while dropping nothing. The
|
|
31
|
+
# relay counts what it drops, and a row is only reported when the relay
|
|
32
|
+
# confirms it saw every packet and the measured loss is close to the request.
|
|
33
|
+
#
|
|
34
|
+
# ruby benchmarks/loss.rb
|
|
35
|
+
# LOSS=0,50,20 DELAY_MS=15 BURST=5 REQUESTS=1500 ruby benchmarks/loss.rb
|
|
36
|
+
# SERVERS=quicsilver,puma ruby benchmarks/loss.rb
|
|
37
|
+
#
|
|
38
|
+
# LOSS is 1-in-N, the way MsQuic's emulated-performance runs express it, with 0
|
|
39
|
+
# meaning no loss. 10 is a brutal 10%; real networks are 1-in-100 or worse.
|
|
40
|
+
#
|
|
41
|
+
# With REPLAY=1 the HTTP/3 arm records its loss decisions and the TCP arms
|
|
42
|
+
# replay them, so all three see the identical pattern rather than the same
|
|
43
|
+
# seed. Whatever difference remains is the protocol.
|
|
44
|
+
|
|
45
|
+
ENV["RUNS"] ||= "1"
|
|
46
|
+
# Every arm binds and dials one literal so the resolver order of "localhost"
|
|
47
|
+
# cannot pick a family the relay is not listening on. Puma only binds v4.
|
|
48
|
+
ENV["QUIC_ADDRESS"] ||= "127.0.0.1"
|
|
49
|
+
require_relative "compare"
|
|
50
|
+
require "impair"
|
|
51
|
+
|
|
52
|
+
HOST = "127.0.0.1"
|
|
53
|
+
SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",")
|
|
54
|
+
LOSS_RATES = ENV.fetch("LOSS", "0,100,50,20,10").split(",").map { |value| Integer(value) }
|
|
55
|
+
REORDER = Integer(ENV.fetch("REORDER", "0"))
|
|
56
|
+
# Mean length of a loss burst. Real links lose packets in runs, not
|
|
57
|
+
# independently, and i.i.d. loss understates HTTP/3: a burst confined to one
|
|
58
|
+
# stream's packets stalls that stream, where TCP would stall all of them. The
|
|
59
|
+
# overall rate stays 1-in-LOSS; only the clustering changes. 0 is i.i.d.
|
|
60
|
+
BURST = Integer(ENV.fetch("BURST", "0"))
|
|
61
|
+
# One-way delay in milliseconds, so RTT is twice this. Loss without a round
|
|
62
|
+
# trip understates itself: loopback retransmits in microseconds, and the cost
|
|
63
|
+
# of a retransmission is one RTT. This is the axis that makes loss hurt.
|
|
64
|
+
DELAY_MS = Float(ENV.fetch("DELAY_MS", "0"))
|
|
65
|
+
SEED = Integer(ENV.fetch("SEED", "1234"))
|
|
66
|
+
REPLAY = ENV["REPLAY"] == "1"
|
|
67
|
+
# Repeats per row, because this server's own throughput varies enough between
|
|
68
|
+
# runs to swallow the effect being measured. A single run once showed higher
|
|
69
|
+
# throughput at 9% loss than at none, which is not a finding about loss.
|
|
70
|
+
REPEATS = Integer(ENV.fetch("REPEATS", "3"))
|
|
71
|
+
|
|
72
|
+
PROTOCOLS = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}.freeze
|
|
73
|
+
|
|
74
|
+
def link_for(loss)
|
|
75
|
+
{
|
|
76
|
+
loss: loss, reorder: REORDER, delay: DELAY_MS / 1000.0, seed: SEED,
|
|
77
|
+
# burst is a clustering of loss, so it is meaningless without any, and
|
|
78
|
+
# Impair rejects the pair rather than silently ignoring it.
|
|
79
|
+
**(BURST.positive? && loss.positive? ? {burst: BURST} : {}),
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Runs +name+ behind the matching relay and yields the relay's port. Returns
|
|
84
|
+
# [result, relay] with the relay stopped.
|
|
85
|
+
def with_impaired(name, loss, replay: nil)
|
|
86
|
+
relay_class = (name == "quicsilver") ? Impair::Udp : Impair::Tcp
|
|
87
|
+
port = free_port
|
|
88
|
+
server = case name
|
|
89
|
+
when "quicsilver" then method(:with_quicsilver)
|
|
90
|
+
when "falcon" then method(:with_falcon)
|
|
91
|
+
when "puma" then method(:with_puma)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
server.call(port) do
|
|
95
|
+
relay = relay_class.start(
|
|
96
|
+
host: HOST, target_host: HOST, target_port: port,
|
|
97
|
+
trace: REPLAY && replay.nil?, replay: replay, **link_for(loss)
|
|
98
|
+
)
|
|
99
|
+
begin
|
|
100
|
+
result = case name
|
|
101
|
+
when "quicsilver" then measure_quicsilver(relay.port)
|
|
102
|
+
else measure_async(name, PROTOCOLS.fetch(name), relay.port, host: HOST)
|
|
103
|
+
end
|
|
104
|
+
[result, relay]
|
|
105
|
+
ensure
|
|
106
|
+
relay.stop
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def median_of(values)
|
|
112
|
+
sorted = values.sort
|
|
113
|
+
sorted[sorted.length / 2]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
puts "Head-of-line blocking under induced packet loss"
|
|
117
|
+
puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats, through a userspace impairment relay"
|
|
118
|
+
puts "connections: " + CONNECTIONS.map { |proto, n| "#{proto} on #{n}" }.join(", ")
|
|
119
|
+
puts "loss is 1-in-N applied in both directions, seed #{SEED}"
|
|
120
|
+
puts "bursts averaging #{BURST} packets, overall rate unchanged" if BURST.positive?
|
|
121
|
+
puts "one-way delay #{DELAY_MS}ms, so about #{(DELAY_MS * 2).round}ms round trip" if DELAY_MS.positive?
|
|
122
|
+
puts "TCP arms replay the HTTP/3 arm's loss decisions" if REPLAY
|
|
123
|
+
puts
|
|
124
|
+
|
|
125
|
+
rows = []
|
|
126
|
+
|
|
127
|
+
LOSS_RATES.each do |loss|
|
|
128
|
+
SERVERS.each do |name|
|
|
129
|
+
attempts = REPEATS.times.filter_map do |repeat|
|
|
130
|
+
# With REPLAY, the HTTP/3 run of this row and repeat is the script.
|
|
131
|
+
replay = REPLAY && name != "quicsilver" ? rows.dig(-1, :traces, repeat) : nil
|
|
132
|
+
result, relay = with_impaired(name, loss, replay: replay)
|
|
133
|
+
counts = relay.counts
|
|
134
|
+
|
|
135
|
+
# Two things the relay must confirm before the row counts. That it saw
|
|
136
|
+
# every packet -- kernel drops before the relay are invisible to
|
|
137
|
+
# loss_rate -- and that the loss it applied is close to what was asked.
|
|
138
|
+
# The relay cannot know how many packets the client sent, so the first
|
|
139
|
+
# check is on its own queue only; the second catches the rest.
|
|
140
|
+
if counts.overrun.positive?
|
|
141
|
+
warn format(" %s 1-in-%d: relay overran its queue %d times, discarding", name, loss, counts.overrun)
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
expected = loss.zero? ? 0.0 : 1.0 / loss
|
|
145
|
+
if loss.positive? && counts.loss_rate < expected / 2
|
|
146
|
+
warn format(" %s 1-in-%d: relay dropped only %.2f%%, discarding", name, loss, counts.loss_rate * 100)
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
stats = Benchmarks.stats(result.times)
|
|
151
|
+
{rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99],
|
|
152
|
+
failed: result.failed, measured: counts.loss_rate,
|
|
153
|
+
longest_burst: counts.longest_burst, trace: relay.trace}
|
|
154
|
+
rescue StandardError => error
|
|
155
|
+
warn "#{name} 1-in-#{loss} failed: #{error.class}: #{error.message}"
|
|
156
|
+
warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
next if attempts.empty?
|
|
160
|
+
|
|
161
|
+
rows << {loss: loss, name: name, attempts: attempts, traces: attempts.map { |a| a[:trace] }}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
puts format("%-9s %-10s %-8s %8s %5s %7s %9s %9s %9s %6s %13s",
|
|
166
|
+
"loss", "server", "protocol", "measured", "burst", "Req/s", "p50", "p95", "p99", "Failed", "Req/s range")
|
|
167
|
+
puts "-" * 104
|
|
168
|
+
last_loss = nil
|
|
169
|
+
rows.each do |row|
|
|
170
|
+
puts if last_loss && row[:loss] != last_loss
|
|
171
|
+
last_loss = row[:loss]
|
|
172
|
+
attempts = row[:attempts]
|
|
173
|
+
rates = attempts.map { |a| a[:rps] }
|
|
174
|
+
puts format("%-9s %-10s %-8s %7.2f%% %5d %7.0f %7.2fms %7.2fms %7.2fms %6d %13s",
|
|
175
|
+
row[:loss].zero? ? "none" : "1-in-#{row[:loss]}",
|
|
176
|
+
row[:name], PROTOCOLS.fetch(row[:name]),
|
|
177
|
+
median_of(attempts.map { |a| a[:measured] }) * 100,
|
|
178
|
+
attempts.map { |a| a[:longest_burst] }.max,
|
|
179
|
+
median_of(rates),
|
|
180
|
+
median_of(attempts.map { |a| a[:p50] }),
|
|
181
|
+
median_of(attempts.map { |a| a[:p95] }),
|
|
182
|
+
median_of(attempts.map { |a| a[:p99] }),
|
|
183
|
+
attempts.sum { |a| a[:failed] },
|
|
184
|
+
(rates.length > 1 ? format("%.0f-%.0f", rates.min, rates.max) : "single"))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
puts
|
|
188
|
+
puts "The relay is in the path for every row, including the first, so the"
|
|
189
|
+
puts "no-loss row is the baseline rather than a direct connection."
|
|
190
|
+
puts "Loss is applied to both directions, so a request and its response each"
|
|
191
|
+
puts "face the dice. On TCP a lost segment stalls the whole connection one RTT"
|
|
192
|
+
puts "and halves the sender's window (RFC 5681); a burst waits out the RTO and"
|
|
193
|
+
puts "collapses it. No SACK, so the TCP rows are still conservative in TCP's favour."
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A phone's network, for a few seconds at a time.
|
|
5
|
+
#
|
|
6
|
+
# The link's RTT swings, loss comes in bursts, and every couple of seconds
|
|
7
|
+
# the NAT forgets the client's mapping. QUIC was built for the last one:
|
|
8
|
+
# the client's source port changes, the server validates the new path, and
|
|
9
|
+
# the connection carries on (RFC 9000 §9). A TCP connection is identified by
|
|
10
|
+
# that port, so when it changes the connection is gone and every stream on
|
|
11
|
+
# it dies with it; the client starts over with a new handshake.
|
|
12
|
+
#
|
|
13
|
+
# client ──▶ Impair::Udp (rebind every 2s) ──▶ Quicsilver HTTP/3
|
|
14
|
+
# client ──▶ Impair::Tcp (reset every 2s) ──▶ Falcon HTTP/2
|
|
15
|
+
# client ──▶ Impair::Tcp (reset every 2s) ──▶ Puma HTTP/1.1
|
|
16
|
+
#
|
|
17
|
+
# rebind and reset are the same event seen by the two transports, which is
|
|
18
|
+
# why the schedule is one Scenario driving all three arms. RTT and loss ride
|
|
19
|
+
# along so the rebind lands on a busy link rather than a quiet one.
|
|
20
|
+
#
|
|
21
|
+
# ruby benchmarks/mobile.rb
|
|
22
|
+
# REBIND_EVERY=1 REQUESTS=2000 ruby benchmarks/mobile.rb
|
|
23
|
+
#
|
|
24
|
+
# Failed counts requests that got no 200, which for TCP includes every
|
|
25
|
+
# request in flight when the reset landed. That is the finding, not noise.
|
|
26
|
+
|
|
27
|
+
ENV["RUNS"] ||= "1"
|
|
28
|
+
ENV["QUIC_ADDRESS"] ||= "127.0.0.1"
|
|
29
|
+
require_relative "compare"
|
|
30
|
+
require "impair"
|
|
31
|
+
|
|
32
|
+
HOST = "127.0.0.1"
|
|
33
|
+
SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",")
|
|
34
|
+
REBIND_EVERY = Float(ENV.fetch("REBIND_EVERY", "2"))
|
|
35
|
+
DELAY_MS = Float(ENV.fetch("DELAY_MS", "20"))
|
|
36
|
+
LOSS = Integer(ENV.fetch("LOSS", "50"))
|
|
37
|
+
BURST = Integer(ENV.fetch("BURST", "5"))
|
|
38
|
+
REPEATS = Integer(ENV.fetch("REPEATS", "3"))
|
|
39
|
+
SEED = Integer(ENV.fetch("SEED", "1234"))
|
|
40
|
+
PROTOCOLS = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}.freeze
|
|
41
|
+
|
|
42
|
+
# The one schedule. The RTT swings ±10ms around the base on a 6s period;
|
|
43
|
+
# the NAT rebinds on the interval. A TCP relay has no rebind, so it resets:
|
|
44
|
+
# that is what a rebind does to TCP.
|
|
45
|
+
SCENARIO = Impair::Scenario.new do
|
|
46
|
+
every(0.1) { |relay, t| relay.update(delay: DELAY_MS / 1000.0 + wave(t, amplitude: 0.010, period: 6)) }
|
|
47
|
+
every(REBIND_EVERY) do |relay, t|
|
|
48
|
+
next if t.zero? # the first tick is t=0; nothing to rebind yet
|
|
49
|
+
|
|
50
|
+
relay.respond_to?(:rebind) ? relay.rebind : relay.reset
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def link
|
|
55
|
+
{loss: LOSS, burst: BURST, delay: DELAY_MS / 1000.0, seed: SEED}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def with_impaired(name)
|
|
59
|
+
relay_class = (name == "quicsilver") ? Impair::Udp : Impair::Tcp
|
|
60
|
+
port = free_port
|
|
61
|
+
server = case name
|
|
62
|
+
when "quicsilver" then method(:with_quicsilver)
|
|
63
|
+
when "falcon" then method(:with_falcon)
|
|
64
|
+
when "puma" then method(:with_puma)
|
|
65
|
+
end
|
|
66
|
+
server.call(port) do
|
|
67
|
+
relay = relay_class.new(host: HOST, target_host: HOST, target_port: port, **link).start
|
|
68
|
+
relay.run(SCENARIO)
|
|
69
|
+
begin
|
|
70
|
+
result = yield relay.port
|
|
71
|
+
[result, relay.stop]
|
|
72
|
+
ensure
|
|
73
|
+
relay.stop
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def median_of(values)
|
|
79
|
+
sorted = values.sort
|
|
80
|
+
sorted[sorted.length / 2]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts "A mobile link: RTT swinging, bursty loss, NAT rebinding every #{REBIND_EVERY}s"
|
|
84
|
+
puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats"
|
|
85
|
+
puts "connections: " + CONNECTIONS.map { |proto, n| "#{proto} on #{n}" }.join(", ")
|
|
86
|
+
puts format("base RTT %dms ±20ms, loss 1-in-%d in bursts of ~%d, seed %d", DELAY_MS * 2, LOSS, BURST, SEED)
|
|
87
|
+
puts "QUIC rebinds; TCP has no such thing, so it is reset on the same schedule"
|
|
88
|
+
puts "client retries: #{RETRIES}"
|
|
89
|
+
puts
|
|
90
|
+
|
|
91
|
+
rows = SERVERS.filter_map do |name|
|
|
92
|
+
attempts = REPEATS.times.filter_map do
|
|
93
|
+
result, counts = with_impaired(name) do |port|
|
|
94
|
+
(name == "quicsilver") ? measure_quicsilver(port) : measure_async(name, PROTOCOLS[name], port, host: HOST)
|
|
95
|
+
end
|
|
96
|
+
stats = Benchmarks.stats(result.times)
|
|
97
|
+
{rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99], failed: result.failed,
|
|
98
|
+
completed: result.times.size, events: counts.scenario_events, measured: counts.loss_rate}
|
|
99
|
+
rescue StandardError => error
|
|
100
|
+
warn "#{name} failed: #{error.class}: #{error.message}"
|
|
101
|
+
warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
next if attempts.empty?
|
|
105
|
+
|
|
106
|
+
[name, attempts]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
puts format("%-10s %-8s %8s %7s %9s %9s %9s %9s %7s", "server", "protocol", "measured", "Req/s", "p50", "p95", "p99", "completed", "Failed")
|
|
110
|
+
puts "-" * 86
|
|
111
|
+
rows.each do |(name, attempts)|
|
|
112
|
+
puts format("%-10s %-8s %7.2f%% %7.0f %7.2fms %7.2fms %7.2fms %9d %7d",
|
|
113
|
+
name, PROTOCOLS[name],
|
|
114
|
+
median_of(attempts.map { |a| a[:measured] }) * 100,
|
|
115
|
+
median_of(attempts.map { |a| a[:rps] }),
|
|
116
|
+
median_of(attempts.map { |a| a[:p50] }),
|
|
117
|
+
median_of(attempts.map { |a| a[:p95] }),
|
|
118
|
+
median_of(attempts.map { |a| a[:p99] }),
|
|
119
|
+
attempts.sum { |a| a[:completed] },
|
|
120
|
+
attempts.sum { |a| a[:failed] })
|
|
121
|
+
end
|
|
122
|
+
puts
|
|
123
|
+
puts "Failed is requests that got no 200, with RETRIES=#{RETRIES}. At 0 that is every"
|
|
124
|
+
puts "TCP request in flight when the reset landed; a browser would retry them,"
|
|
125
|
+
puts "so RETRIES=3 shows the user's view and 0 shows the transport's."
|