hyperion-rb 2.11.0 → 2.13.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/CHANGELOG.md +1079 -0
- data/README.md +220 -5
- data/ext/hyperion_http/extconf.rb +41 -0
- data/ext/hyperion_http/io_uring_loop.c +710 -0
- data/ext/hyperion_http/page_cache.c +1032 -0
- data/ext/hyperion_http/page_cache_internal.h +132 -0
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +18 -4
- data/lib/hyperion/connection.rb +78 -3
- data/lib/hyperion/dispatch_mode.rb +19 -1
- data/lib/hyperion/http2_handler.rb +458 -13
- data/lib/hyperion/metrics.rb +212 -38
- data/lib/hyperion/prometheus_exporter.rb +76 -1
- data/lib/hyperion/server/connection_loop.rb +159 -0
- data/lib/hyperion/server.rb +183 -0
- data/lib/hyperion/thread_pool.rb +23 -7
- data/lib/hyperion/version.rb +1 -1
- metadata +4 -1
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1084 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.13.0 — 2026-05-01
|
|
4
|
+
|
|
5
|
+
### 2.13-E — io_uring soak signal + default-ON decision
|
|
6
|
+
|
|
7
|
+
**Background.** 2.12-D shipped the io_uring accept loop on Linux 5.x+
|
|
8
|
+
(opt-in via `HYPERION_IO_URING_ACCEPT=1`, bench delta:
|
|
9
|
+
15,685 → 134,084 r/s on `handle_static` hello — 8.6× over the 2.12-C
|
|
10
|
+
accept4 fallback, 7× over Agoo's 19,024). The CHANGELOG explicitly
|
|
11
|
+
deferred the default-ON decision to 2.13: "default off until 2.13
|
|
12
|
+
production soak". 2.13-E is that soak — and the harness operators
|
|
13
|
+
need to run their own soak in their own staging.
|
|
14
|
+
|
|
15
|
+
**What 2.13-E ships.**
|
|
16
|
+
|
|
17
|
+
1. **`bench/io_uring_soak.sh`** — bash-only soak harness.
|
|
18
|
+
- Boots Hyperion with `HYPERION_IO_URING_ACCEPT=1 -w 1 -t 32`
|
|
19
|
+
against `bench/hello_static.ru` (the 2.12-D fast path) on a
|
|
20
|
+
fixed port. `setsid nohup … & disown` so the master survives
|
|
21
|
+
SSH disconnect over a 24h run.
|
|
22
|
+
- 30s warm-up, then `wrk -t4 -c100 -d24h --latency` in the
|
|
23
|
+
foreground. `SOAK_DURATION` is operator-tunable (defaults to
|
|
24
|
+
`24h`; the bench-window proof-of-concept was `30m`).
|
|
25
|
+
- In parallel, every `SAMPLE_INTERVAL` (default 60s), samples
|
|
26
|
+
`/proc/$PID/status` (VmRSS, VmSize, Threads), `/proc/$PID/fd`
|
|
27
|
+
count, scrapes `hyperion_requests_dispatch_total` from
|
|
28
|
+
`/-/metrics`, and bucket-derives p50/p99 from
|
|
29
|
+
`hyperion_request_duration_seconds_bucket`. Appends one CSV
|
|
30
|
+
row per sample to `/tmp/io_uring_soak_<tag>_<ts>.csv`.
|
|
31
|
+
- On exit (24h elapsed OR Ctrl-C), prints summary:
|
|
32
|
+
min/max/mean/stddev RSS, fd_count peak, p99 stddev/mean, plus
|
|
33
|
+
wrk's HDR-precision p50/p99/p999 from `--latency`.
|
|
34
|
+
- **Verdict**:
|
|
35
|
+
- PASS if RSS variance < 10%, fd peak ≤ `WRK_CONNS + 50`, and
|
|
36
|
+
(when histogram has ≥ 3 distinct bucket values across the
|
|
37
|
+
window) p99 stddev/mean < 20%. Eligible for default-flip.
|
|
38
|
+
- SOAK FAIL on any breach. Defer the flip; the failed metric
|
|
39
|
+
is documented in the verdict notes.
|
|
40
|
+
- The histogram p99 check is bypassed when there are < 3
|
|
41
|
+
distinct bucket values across the soak window — Hyperion's
|
|
42
|
+
7-edge histogram (1 ms, 5 ms, 25 ms, …) quantizes a stable
|
|
43
|
+
hello-world p99 into bucket-boundary jumps, and the wrk
|
|
44
|
+
`--latency` p99 is the right tail-truth source there.
|
|
45
|
+
- `IO_URING=0` runs the same harness against the 2.12-C accept4
|
|
46
|
+
fallback so the operator can diff io_uring vs accept4 CSVs
|
|
47
|
+
apples-to-apples.
|
|
48
|
+
|
|
49
|
+
2. **`spec/hyperion/io_uring_soak_smoke_spec.rb`** — durable CI
|
|
50
|
+
coverage. A 1000-request mini-soak over the io_uring loop with
|
|
51
|
+
a 200-request warm-up, asserts: RSS delta < 20 MB,
|
|
52
|
+
fd_count back to baseline ± 5, threads back to baseline ± 4.
|
|
53
|
+
Skipped on macOS / non-liburing builds via the same
|
|
54
|
+
`Hyperion::Http::PageCache.io_uring_loop_compiled?` predicate the
|
|
55
|
+
2.12-D wire-shape spec uses. Lives in its own file so a 2.13-E
|
|
56
|
+
leak signal regression is diagnosable without re-reading the
|
|
57
|
+
2.12-D wire-shape spec.
|
|
58
|
+
|
|
59
|
+
*Calibration note*: the 2.13-E ticket header proposed a 5 MB
|
|
60
|
+
delta bound. Bench-host measurements (3-run baseline, IO_URING=1,
|
|
61
|
+
1000 sequential GETs) put the delta at 7-9 MB — but the
|
|
62
|
+
dominant allocator is the test process itself
|
|
63
|
+
(`::TCPSocket.new`, `Timeout` threads, response Strings), not the
|
|
64
|
+
Hyperion server. The 20 MB threshold catches a real Hyperion
|
|
65
|
+
leak (1 KB/req of leakage = +1 MB at the assertion site) without
|
|
66
|
+
false-positiving on the test driver's own arena cost.
|
|
67
|
+
|
|
68
|
+
3. **30-minute proof-of-concept soak** — see the companion
|
|
69
|
+
`[bench]` commit for the harness-vs-harness numbers across
|
|
70
|
+
IO_URING=1 / IO_URING=0 and the explicit default-ON decision.
|
|
71
|
+
|
|
72
|
+
**Constraints respected.** No regression in spec count. macOS-host
|
|
73
|
+
suite: 1124/0/14 → 1126/0/16 (+2 examples, +2 pending — the soak
|
|
74
|
+
smoke is pending on macOS, the documentation example is active). Linux
|
|
75
|
+
bench-host suite: 1124/0/14 → 1126/0/15 (+2 examples, +1 pending —
|
|
76
|
+
the soak smoke runs, the documentation example is pending). The
|
|
77
|
+
2.10-G TCP_NODELAY hunk, 2.10-E preload hooks, 2.10-F
|
|
78
|
+
`rb_pc_serve_request`, 2.11-A dispatch pool warmup, 2.11-B cglue HPACK
|
|
79
|
+
default, 2.12-C accept4 loop, 2.12-D io_uring loop, 2.12-E per-worker
|
|
80
|
+
counter, 2.12-F gRPC unary trailers, 2.13-A metric shards, 2.13-B
|
|
81
|
+
response head builder, 2.13-C flake fixes, 2.13-D gRPC streaming —
|
|
82
|
+
all on master and untouched.
|
|
83
|
+
|
|
84
|
+
### 2.13-D — gRPC streaming RPCs + ghz vs Falcon bench
|
|
85
|
+
|
|
86
|
+
**Background.** 2.12-F shipped gRPC unary on h2 — trailers
|
|
87
|
+
(`grpc-status` / `grpc-message` final HEADERS frame), `te: trailers`
|
|
88
|
+
handling, and h2 request half-close semantics. The three remaining
|
|
89
|
+
gRPC call shapes — server-streaming, client-streaming, and
|
|
90
|
+
bidirectional — were not yet wired. 2.13-D closes that gap.
|
|
91
|
+
|
|
92
|
+
**What was missing.** `dispatch_stream` gated on
|
|
93
|
+
`RequestStream#request_complete` (i.e., END_STREAM on the request),
|
|
94
|
+
which is correct for unary but blocks both streaming-input shapes:
|
|
95
|
+
the app cannot read DATA frames until END_STREAM has already arrived.
|
|
96
|
+
Likewise the response path materialised the full body into a single
|
|
97
|
+
String before splitting it across DATA frames, which folded
|
|
98
|
+
multi-message server-streaming responses into one logical write
|
|
99
|
+
(verified: a 5-message body produced one DATA frame, not five).
|
|
100
|
+
|
|
101
|
+
**What 2.13-D ships.**
|
|
102
|
+
|
|
103
|
+
1. **Server-streaming.** When the response body responds to
|
|
104
|
+
`:trailers`, `dispatch_stream` now iterates `body#each` lazily and
|
|
105
|
+
emits one DATA frame per yielded chunk (no inter-chunk coalescing),
|
|
106
|
+
followed by the trailer HEADERS frame carrying END_STREAM=1. A
|
|
107
|
+
single oversize chunk still gets max-frame-size split inside the
|
|
108
|
+
per-chunk send path, but small messages stay one DATA frame each.
|
|
109
|
+
Plain HTTP/2 traffic (no `:trailers` method on the body) keeps the
|
|
110
|
+
pre-2.13-D buffered shape — no behaviour change for non-gRPC apps.
|
|
111
|
+
|
|
112
|
+
2. **Streaming-input dispatch.** A new
|
|
113
|
+
`Hyperion::Http2Handler::StreamingInput` IO-shaped queue replaces
|
|
114
|
+
the buffered `@request_body` String for requests that look like
|
|
115
|
+
gRPC: `content-type: application/grpc*` AND `te: trailers` on a
|
|
116
|
+
POST. When promoted, `process_data` pushes each DATA frame's bytes
|
|
117
|
+
into the queue (and the END_STREAM frame closes the writer), and
|
|
118
|
+
the serve-loop dispatches the app on HEADERS arrival via a new
|
|
119
|
+
`RequestStream#dispatchable?` predicate. The Rack adapter detects
|
|
120
|
+
the non-String request body and sets `env['rack.input']` directly
|
|
121
|
+
to the queue (no StringIO wrap, so streaming-read semantics are
|
|
122
|
+
preserved). Reads block the calling fiber on `Async::Notification`
|
|
123
|
+
until either bytes arrive or the writer closes.
|
|
124
|
+
|
|
125
|
+
3. **Bidirectional.** Falls out for free once 1 + 2 are in place —
|
|
126
|
+
each h2 stream already runs on its own fiber, so concurrent
|
|
127
|
+
read+write on the same stream is supported by the Async scheduler.
|
|
128
|
+
|
|
129
|
+
**Tests.** `spec/hyperion/grpc_streaming_spec.rb` (5 examples):
|
|
130
|
+
server-streaming wire shape (5 yielded chunks → 5 DATA frames + trailer
|
|
131
|
+
HEADERS, END_STREAM ride placement asserted), client-streaming
|
|
132
|
+
(5 spaced DATA frames decoded by the app via `rack.input.read`),
|
|
133
|
+
bidirectional (5 round-trips with strict ordering), and 2 unit specs
|
|
134
|
+
on `StreamingInput` (blocking reads + EOF handling, partial-chunk
|
|
135
|
+
slicing). All run end-to-end via `Protocol::HTTP2::Client` over real
|
|
136
|
+
TLS — same harness shape as the 2.12-F unary specs.
|
|
137
|
+
|
|
138
|
+
**Constraints respected.** No regression in the 1176/0/15 baseline:
|
|
139
|
+
post-2.13-D suite is 1181/0/15 (5 new streaming examples + 0
|
|
140
|
+
regressions). The pre-existing
|
|
141
|
+
`http2_empty_body_short_circuit_spec`'s `FakeStream` test double
|
|
142
|
+
needed a `respond_to?(:streaming_input)` guard at the dispatch
|
|
143
|
+
read-site — added defensively (no protocol change). The 2.10-G
|
|
144
|
+
TCP_NODELAY hunk, 2.10-E preload hooks, 2.10-F `rb_pc_serve_request`,
|
|
145
|
+
2.11-A dispatch pool warmup, 2.11-B cglue HPACK default, 2.12-C accept4
|
|
146
|
+
loop, 2.12-D io_uring loop, 2.12-E per-worker counter, 2.12-F gRPC
|
|
147
|
+
unary trailers, 2.13-A metric shards, 2.13-B response head builder
|
|
148
|
+
C-rewrite, 2.13-C flake fixes are all on master and untouched by
|
|
149
|
+
this change.
|
|
150
|
+
|
|
151
|
+
**Bench.** See the companion `[bench]` commit for ghz numbers and
|
|
152
|
+
the documented limits of the Hyperion-vs-Falcon comparison.
|
|
153
|
+
|
|
154
|
+
### 2.13-C — Spec flake hunt
|
|
155
|
+
|
|
156
|
+
Two flakes carried over from the 2.11/2.12 release cuts. Both are
|
|
157
|
+
spec-side hermeticity issues — neither indicates a regression in
|
|
158
|
+
`lib/` or `ext/`.
|
|
159
|
+
|
|
160
|
+
**Flake 1 — `spec/hyperion/tls_ktls_spec.rb`, macOS, seed-dependent.**
|
|
161
|
+
The two examples in the `Linux-only: kTLS engages with default :auto policy`
|
|
162
|
+
describe block previously gated their bodies on
|
|
163
|
+
`unless Hyperion::TLS.ktls_supported?`. That probe consults
|
|
164
|
+
`Etc.uname[:sysname]` and `OpenSSL::OPENSSL_VERSION_NUMBER`. The same
|
|
165
|
+
`Etc.uname` is stubbed elsewhere in the suite (`io_uring_spec`)
|
|
166
|
+
to drive the io_uring platform matrix; under a particular full-
|
|
167
|
+
suite seed those stubs and this spec's `before { reset_ktls_probe! }`
|
|
168
|
+
overlapped in a way that let the probe report `true` on the actual
|
|
169
|
+
Darwin host. The body then ran the kTLS-supported branch and failed.
|
|
170
|
+
|
|
171
|
+
*Fix shape:* hard `RUBY_PLATFORM.include?('linux')` guard at the
|
|
172
|
+
example-body top BEFORE the existing probe-based guard. Runtime
|
|
173
|
+
platform is unstubbable from another spec and matches the
|
|
174
|
+
example title's intent. Linux runs unaffected (the second guard
|
|
175
|
+
still protects Linux + old-OpenSSL hosts).
|
|
176
|
+
|
|
177
|
+
*Verification:* 10/10 green on macOS arm64; 1/1 green on the
|
|
178
|
+
Linux x86_64 bench (openclaw-vm, kernel 6.8); full suite holds
|
|
179
|
+
the 1175 baseline on macOS / 1118 on bench.
|
|
180
|
+
|
|
181
|
+
**Flake 2 — `spec/hyperion/connection_loop_spec.rb:79`, Linux, deterministic.**
|
|
182
|
+
Misdiagnosed previously as "port-9292-busy". The actual root cause
|
|
183
|
+
is Linux ≥ 5.x's `close()`-doesn't-wake-other-thread-`accept(2)`
|
|
184
|
+
behaviour: when the spec calls `listener.close` from one thread,
|
|
185
|
+
the C accept loop parked in `accept(2)` on that fd in another thread
|
|
186
|
+
stays blocked until the next connection arrives. The `stop_accept_loop`
|
|
187
|
+
flag is checked BETWEEN accepts, not while parked, so flipping it
|
|
188
|
+
without a wake is a no-op. `thread.join(5)` then exhausts its
|
|
189
|
+
timeout and `result` (assigned inside the thread block) is still
|
|
190
|
+
`nil`, breaking the `expect(result).to be_a(Integer)` assertion.
|
|
191
|
+
Other examples in the file had the same teardown shape but happened
|
|
192
|
+
to assert on side-effects populated BEFORE the join, so they passed
|
|
193
|
+
despite the same 5 s thread leak — runtime was 46 s for 10 examples.
|
|
194
|
+
|
|
195
|
+
*Fix shape:* extract a `stop_loop_and_wake(listener, thread)`
|
|
196
|
+
helper that flips the stop flag, dials one throwaway TCP connection
|
|
197
|
+
at the listener so the parked `accept(2)` returns, then closes the
|
|
198
|
+
listener and joins. Replace the `stop_accept_loop` + `listener.close`
|
|
199
|
+
+ `thread.join(5)` pattern at every callsite (8 in-file plus the
|
|
200
|
+
Server-level engagement example, which goes through `server.stop`).
|
|
201
|
+
Add a regression block — "teardown is hermetic across repeated
|
|
202
|
+
bring-ups" — that runs the bring-up + serve + teardown cycle 3
|
|
203
|
+
times in one process and asserts each teardown is < 1 s.
|
|
204
|
+
|
|
205
|
+
*Verification:* 0/10 failures on the bench (was 5/5 deterministic
|
|
206
|
+
failure pre-fix); spec runtime 46 s → 1.3 s; macOS 11/11 green;
|
|
207
|
+
full suite 1119 on bench / 1176 on macOS (regression spec adds 1).
|
|
208
|
+
|
|
209
|
+
*Out of scope:* the same wake-shape affects `Hyperion::Server#stop`
|
|
210
|
+
in production — `close()` on the listener fd from the signal-
|
|
211
|
+
handling thread won't reliably wake the worker's parked accept.
|
|
212
|
+
Flagging this as a follow-up rather than fixing in 2.13-C scope:
|
|
213
|
+
briefing was explicit ("Don't touch lib/ext code unless the flake
|
|
214
|
+
is a real bug there"), and the production failure mode (worker
|
|
215
|
+
hangs on shutdown) is operationally distinct from the spec flake
|
|
216
|
+
(test-suite stalls).
|
|
217
|
+
|
|
218
|
+
### 2.13-B — CPU JSON gap
|
|
219
|
+
|
|
220
|
+
**Background.** The 2.12-B re-bench surfaced one row that got *worse*
|
|
221
|
+
relative to Agoo across the 2.10/2.11/2.12 streams: `bench/work.ru`
|
|
222
|
+
(50-key JSON serialised per-request, no `handle_static` because the
|
|
223
|
+
response varies per request). 2.10-B had Hyperion 3,450 / Agoo 6,374
|
|
224
|
+
(1.85× behind); the 2.12-B re-bench had Hyperion 3,659 / Agoo 7,489
|
|
225
|
+
(2.05× behind). Hyperion +6.0%, Agoo +17.5% over the same window —
|
|
226
|
+
the gap *widened*. None of the 2.10/2.11/2.12 work touched this row,
|
|
227
|
+
so it was the obvious 2.13 follow-on.
|
|
228
|
+
|
|
229
|
+
**Profile.** `perf record -F 199 -g` on the worker pid while
|
|
230
|
+
`wrk -t4 -c100 -d15s` ran (CPU-JSON workload, default config
|
|
231
|
+
`-t 5 -w 1` = bench harness canonical):
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
13.15% vm_exec_core (Ruby VM dispatch)
|
|
235
|
+
13.01% _raw_spin_unlock_irqrestore (kernel; ~6% inside TCP write softirq)
|
|
236
|
+
4.56% raw_generate_json_string (JSON.generate — app's own work)
|
|
237
|
+
2.28% generate_json_general (ditto)
|
|
238
|
+
1.75% vm_call_cfunc_with_frame
|
|
239
|
+
1.34% rb_class_of
|
|
240
|
+
1.24% json_object_i (ditto)
|
|
241
|
+
1.11% rb_vm_opt_getconstant_path
|
|
242
|
+
1.01% BSD_vfprintf (sprintf — content-length builder + JSON floats)
|
|
243
|
+
0.94% generate_json_float (ditto)
|
|
244
|
+
0.70% hash_foreach_call (header iteration)
|
|
245
|
+
0.57% llhttp__internal__run (Hyperion C ext request parser)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The honest read: ~10 % of CPU is `JSON.generate` (the app's per-request
|
|
249
|
+
work — not removable from inside Hyperion); ~13 % is kernel TCP write
|
|
250
|
+
softirq (one `write(2)` per response — already minimal); ~13 % is the
|
|
251
|
+
Ruby VM dispatch loop, of which the adapter + writer Ruby path is a
|
|
252
|
+
fraction. **The dominant tail is GVL serialisation under `-t 5 -w 1`**
|
|
253
|
+
— a concurrency sweep shows c=1 → 5,800 r/s, c=5 → 3,563 r/s, c=100 →
|
|
254
|
+
3,665 r/s. Hyperion's per-thread workload SCALES DOWN with concurrency
|
|
255
|
+
because every request holds the GVL through `app.call` + `JSON.generate`
|
|
256
|
+
(Ruby code, no I/O wait). Agoo (pure-C HTTP core) scales UP with
|
|
257
|
+
concurrency: c=1 → 4,384 → c=5 → 6,182 → c=100 → 6,519. That structural
|
|
258
|
+
gap cannot close inside Hyperion — `app.call(env)` IS Ruby. What 2.13-B
|
|
259
|
+
*can* do is shrink Hyperion's GVL hold time per request so the ratio
|
|
260
|
+
of "GVL held by Hyperion" to "GVL held by app.call" drops, leaving
|
|
261
|
+
more room for the worker pool to interleave.
|
|
262
|
+
|
|
263
|
+
### 2.13-B — CPU savings in `cbuild_response_head`
|
|
264
|
+
|
|
265
|
+
The C-side response-head builder (called once per Rack response) had
|
|
266
|
+
four removable per-request costs:
|
|
267
|
+
|
|
268
|
+
1. **Status line `snprintf`.** Every request ran
|
|
269
|
+
`snprintf("HTTP/1.1 %d ", status)` + `rb_str_cat(reason)` +
|
|
270
|
+
`rb_str_cat("\r\n", 2)` to build "HTTP/1.1 200 OK\r\n". The 23
|
|
271
|
+
status codes in `ResponseWriter::REASONS` are a fixed set with
|
|
272
|
+
fixed reason phrases — the entire status line is a constant per
|
|
273
|
+
`(status, reason)` pair. The 2.13-B builder switches on `status`
|
|
274
|
+
and emits the pre-baked line in ONE `rb_str_cat` when the reason
|
|
275
|
+
phrase matches; falls back to the snprintf path for unknown
|
|
276
|
+
statuses or operator-overridden reason phrases.
|
|
277
|
+
|
|
278
|
+
2. **Header iteration via `rb_funcall(:keys)`.** The legacy iterator
|
|
279
|
+
called `rb_funcall(rb_headers, :keys, 0)` to materialise a fresh
|
|
280
|
+
keys Array per request, then `rb_ary_entry(keys, i)` +
|
|
281
|
+
`rb_hash_aref(rb_headers, key)` per header. The 2.13-B builder
|
|
282
|
+
uses `rb_hash_foreach`, which walks the hash table directly with
|
|
283
|
+
no intermediate Array allocation and no per-key hash lookup.
|
|
284
|
+
|
|
285
|
+
3. **Per-key `String#downcase` allocation.** Header keys are nearly
|
|
286
|
+
always frozen-literal Strings in Rack apps (`'content-type'`,
|
|
287
|
+
`'cache-control'`, …) — same `VALUE` every request. The legacy
|
|
288
|
+
builder ran `rb_funcall(:downcase)` per key per call, allocating
|
|
289
|
+
a fresh lowercase String + crossing the FFI boundary. 2.13-B
|
|
290
|
+
keys an `st_table` on the input String's identity and stores
|
|
291
|
+
the cached lowercase form + the pre-built `"<lc>: "` prefix
|
|
292
|
+
buffer; the second-and-later requests for the same frozen-literal
|
|
293
|
+
key get one st-table hit. Capped at 64 entries — a misbehaving
|
|
294
|
+
app emitting `x-trace-<uuid>` per request can't grow the cache
|
|
295
|
+
without bound, just falls back to the per-call downcase.
|
|
296
|
+
|
|
297
|
+
4. **Per-(key, value) full-line cache.** When BOTH the key AND the
|
|
298
|
+
value are frozen-literal Strings (`'cache-control' => 'no-store'`
|
|
299
|
+
in `bench/work.ru`, `'content-type' => 'application/json'`),
|
|
300
|
+
the entire wire line `"<lc-key>: <value>\r\n"` is identical
|
|
301
|
+
every request. 2.13-B caches the prebuilt line keyed on
|
|
302
|
+
`(key.object_id, value.object_id)`; on hit the entire emit is
|
|
303
|
+
ONE `rb_str_cat`. Capped at 256 entries with the same fall-back
|
|
304
|
+
semantics. The CRLF-injection guard always re-runs (the cache
|
|
305
|
+
stores only validated lines; new (k, v) pairs go through the
|
|
306
|
+
full validator before the line populates).
|
|
307
|
+
|
|
308
|
+
5. **`itoa_positive_decimal` for content-length.** `snprintf("content-
|
|
309
|
+
length: %ld\r\n", body_size)` was 1 % of CPU on the profile.
|
|
310
|
+
`body_size` is always non-negative (bytesize of a buffered body)
|
|
311
|
+
so the sign branch + locale logic in `vfprintf` are pure
|
|
312
|
+
overhead. 2.13-B writes the digits backwards into a 24-byte
|
|
313
|
+
stack scratch then `rb_str_cat`s the populated suffix — no
|
|
314
|
+
heap, no locale, no format-string parser.
|
|
315
|
+
|
|
316
|
+
**Bench impact.** Same bench host (openclaw-vm, Linux 6.8.0, Ruby
|
|
317
|
+
3.3.3, loopback), each version compiled fresh from source on the
|
|
318
|
+
host before its run.
|
|
319
|
+
|
|
320
|
+
| Workload | Baseline (master adac63e) | 2.13-B | Δ |
|
|
321
|
+
|---|---:|---:|---|
|
|
322
|
+
| Single-thread synthetic (`Adapter::Rack.call → ResponseWriter#write` against a sink, 50,000 iters; 3-trial median r/s) | 18,018 | **19,404** | **+7.7%** |
|
|
323
|
+
| Multi-thread loopback `wrk -t4 -c100 -d20s --latency`, two batches of 3-trial median r/s | 3,427; 3,550 | 3,440; 3,528 | **−0.1%** |
|
|
324
|
+
| Multi-thread loopback p99 latency | 2.77ms; 2.64ms | 2.74ms; 2.67ms | tied |
|
|
325
|
+
|
|
326
|
+
The +7.7 % single-thread win is the per-request CPU savings inside
|
|
327
|
+
`cbuild_response_head`. The neutral multi-thread result is the
|
|
328
|
+
GVL-contention floor: at `-t 5 -w 1` Hyperion's worker threads
|
|
329
|
+
serialise on the GVL while running `JSON.generate` + `app.call`,
|
|
330
|
+
so shaving 2-3 µs off Hyperion's slice of the hot path leaves the
|
|
331
|
+
total throughput dominated by `JSON.generate` (~10 % CPU per the
|
|
332
|
+
profile) and the kernel TCP write softirq (~6 %). For comparison
|
|
333
|
+
the same bench host, same Ruby, with Hyperion `-w 4` SO_REUSEPORT
|
|
334
|
+
on `bench/work.ru`: **14,200 r/s** — 2× over Agoo's single-process
|
|
335
|
+
7,489 r/s baseline.
|
|
336
|
+
|
|
337
|
+
**Honest assessment of the residual gap.** The 2.05× gap to Agoo
|
|
338
|
+
on the canonical `-t 5 -w 1` row is a GVL-architecture gap, not a
|
|
339
|
+
per-request CPU gap. Agoo's pure-C HTTP core lets 5 worker threads
|
|
340
|
+
truly run in parallel; Hyperion's adapter + writer + `app.call`
|
|
341
|
+
hold the GVL together because every step except the `read(2)` /
|
|
342
|
+
`write(2)` syscalls is Ruby. Closing this row to ≥ Agoo would
|
|
343
|
+
require either (a) running `-w 4` SO_REUSEPORT (the 2.12-E
|
|
344
|
+
cluster work — Hyperion DOES exceed Agoo by 2× there), or (b) a
|
|
345
|
+
2.14+ track that moves more of the per-request lifecycle into C
|
|
346
|
+
(e.g. running `cbuild_response_head` from the C accept loop with
|
|
347
|
+
the writer fully C-side). 2.13-B closes Hyperion's portion of the
|
|
348
|
+
GVL hold; the rest is structural.
|
|
349
|
+
|
|
350
|
+
### 2.13-A — Extend C-side wins to generic Rack apps
|
|
351
|
+
|
|
352
|
+
**Background.** The 2.12 sprint shipped huge wins on the
|
|
353
|
+
`Server.handle_static`-routed traffic shape: 5,502 r/s → 134,084 r/s on
|
|
354
|
+
the static-route `hello` workload (24× over 2.11.0; 7× over Agoo). But
|
|
355
|
+
those wins are gated on the C accept-loop's `route_table.lookup`
|
|
356
|
+
returning a `RouteTable::StaticEntry`. Generic Rack apps — the vast
|
|
357
|
+
majority of real-world deployments (Rails, Sinatra, Roda, Hanami,
|
|
358
|
+
anything calling `body.each` to yield response chunks) — never engage
|
|
359
|
+
the C loop; they go through `Hyperion::Adapter::Rack` + the Ruby
|
|
360
|
+
accept loop + the thread pool. The 2.12-B re-bench confirmed a generic
|
|
361
|
+
Rack `bench/hello.ru` ran at 4,477 r/s — 4.25× behind Agoo, and 30×
|
|
362
|
+
behind the C-loop static path on the same machine. Most of the 2.12
|
|
363
|
+
wins were not available to operators running real apps.
|
|
364
|
+
|
|
365
|
+
**What 2.13-A targets.** Optimizations that *do* port to the generic
|
|
366
|
+
Rack dispatch path without breaking semantics. Per-request we don't
|
|
367
|
+
get to skip `app.call(env)` (that IS the dispatch) and we can't
|
|
368
|
+
prebuild the response body (it's dynamic), but we can attack:
|
|
369
|
+
syscall coalescing on accept+read, env hash + rack.input recycling,
|
|
370
|
+
metrics-mutex contention under multi-thread workloads, and the
|
|
371
|
+
keepalive-fast-path tail.
|
|
372
|
+
|
|
373
|
+
### 2.13-A — Per-thread shard for hot-path metrics
|
|
374
|
+
|
|
375
|
+
Pre-2.13-A, every `observe_histogram` and `increment_labeled_counter`
|
|
376
|
+
took `@hg_mutex.synchronize`. The original commit comment claimed
|
|
377
|
+
those paths were "low-rate", but that's no longer true:
|
|
378
|
+
|
|
379
|
+
* `tick_worker_request` is called once per dispatched request
|
|
380
|
+
(every `Connection#serve` iteration, every h2 stream, every
|
|
381
|
+
handed-off connection from the C loop).
|
|
382
|
+
* `observe_histogram` is called once per dispatched request via
|
|
383
|
+
the per-route request-duration histogram registered in
|
|
384
|
+
`Connection#register_request_duration_histogram!`.
|
|
385
|
+
|
|
386
|
+
Under `-t 32` that single mutex serialised 32 worker threads on the
|
|
387
|
+
request-completion tail — every `+= 1` waited behind the previous
|
|
388
|
+
thread's release. That contention was invisible on the C accept loop
|
|
389
|
+
(the loop bypasses Ruby metrics entirely and folds in its atomic
|
|
390
|
+
counter at scrape time), but it was the dominant tail-latency term
|
|
391
|
+
on the generic Rack workload.
|
|
392
|
+
|
|
393
|
+
The new path keeps per-thread shards (`Thread#thread_variable_set`,
|
|
394
|
+
true thread-local — NOT fiber-local, matching the unlabeled counter
|
|
395
|
+
convention from 2.0.0) for both `@histograms` and `@labeled_counters`.
|
|
396
|
+
Observations and increments hit the per-thread shard with zero
|
|
397
|
+
contention; `histogram_snapshot` and `labeled_counter_snapshot` merge
|
|
398
|
+
across shards under the mutex (a low-rate operation — once per
|
|
399
|
+
`/-/metrics` scrape).
|
|
400
|
+
|
|
401
|
+
**Public API stays identical.** `observe_histogram`, `register_histogram`,
|
|
402
|
+
`set_gauge`, `histogram_snapshot`, `labeled_counter_snapshot`,
|
|
403
|
+
`increment_labeled_counter` keep the same signatures and semantics.
|
|
404
|
+
Registered-but-never-observed families still surface in the snapshot
|
|
405
|
+
(pre-2.13-A behaviour). `reset!` now also clears the per-thread
|
|
406
|
+
shards so cross-spec leakage stays prevented.
|
|
407
|
+
|
|
408
|
+
**Edge cases covered by spec:**
|
|
409
|
+
|
|
410
|
+
* Multi-thread observe-then-snapshot: 8 threads × 1000 observations
|
|
411
|
+
on the same `(name, labels)` produce `count == 8000` and
|
|
412
|
+
cumulative bucket counts that match.
|
|
413
|
+
* 16 threads × 500 increments × distinct label values produce 16
|
|
414
|
+
series with count 500 each.
|
|
415
|
+
* Concurrent observe + 50 mid-run snapshots run without deadlock
|
|
416
|
+
or torn counts.
|
|
417
|
+
* Reset clears across threads.
|
|
418
|
+
* Unregistered observe is a silent no-op.
|
|
419
|
+
* Registered-but-never-observed families show up in the scrape
|
|
420
|
+
with an empty `:series` Hash.
|
|
421
|
+
|
|
422
|
+
### 2.13-A — Cached worker-id label tuple + Rack-3 keepalive fast path
|
|
423
|
+
|
|
424
|
+
Two micro-optimizations on the per-request hot path of
|
|
425
|
+
`Connection#serve`, both targeting the steady-state -c1 single-
|
|
426
|
+
keepalive profile (where 8000 r/s = the upper bound of single-thread
|
|
427
|
+
Ruby work and every saved allocation / iteration shows up).
|
|
428
|
+
|
|
429
|
+
**Cached worker-id label tuple.** `tick_worker_request(@worker_id)`
|
|
430
|
+
went through a wrapper that called `worker_id.to_s` (worker_id is
|
|
431
|
+
already a String) and built a fresh `[label]` Array per request. The
|
|
432
|
+
wrapper also re-checked `@worker_request_family_registered` on every
|
|
433
|
+
call. The new path pre-builds the frozen `[@worker_id]` tuple once in
|
|
434
|
+
the Connection constructor, registers the family once at construction
|
|
435
|
+
too, and the request loop calls `increment_labeled_counter` directly
|
|
436
|
+
with the cached tuple — saving one Array allocation + one method
|
|
437
|
+
dispatch + one early-return-checked branch per request.
|
|
438
|
+
|
|
439
|
+
**Rack-3 keepalive fast path.** `should_keep_alive?` used to scan the
|
|
440
|
+
entire response-headers Hash with `headers.find { |k,_| k.to_s.downcase
|
|
441
|
+
== 'connection' }`. That ran `to_s.downcase` (one transient String
|
|
442
|
+
allocation) PER iteration and walked to completion on every response
|
|
443
|
+
that didn't carry a `Connection` header (which is most of them — the
|
|
444
|
+
response writer adds its own). Rack 3 mandates lowercase Hash keys
|
|
445
|
+
(spec §6.4), so the new path is a single `headers['connection']`
|
|
446
|
+
lookup. Apps that violate Rack 3 by returning mixed-case keys lose
|
|
447
|
+
the Connection-close response signal and stay on keep-alive — a
|
|
448
|
+
benign degradation pinned by spec; the fix is to update the app to
|
|
449
|
+
spec. Non-Hash header containers (legacy Array-of-pairs) still flow
|
|
450
|
+
through a slow-scan fallback, also case-sensitive on the lowercase
|
|
451
|
+
key.
|
|
452
|
+
|
|
453
|
+
**Spec coverage:**
|
|
454
|
+
|
|
455
|
+
* Lowercase `connection: close` from app closes the connection.
|
|
456
|
+
* Lowercase `connection: keep-alive` keeps the conn alive across
|
|
457
|
+
pipelined requests.
|
|
458
|
+
* Mixed-case `Connection: close` (Rack-3 violation) is documented
|
|
459
|
+
as falling through to keep-alive — pinned so the behaviour is
|
|
460
|
+
stable.
|
|
461
|
+
* `@worker_id_label_tuple` is constructed once, frozen, and reused
|
|
462
|
+
by identity across requests on the same Connection.
|
|
463
|
+
|
|
464
|
+
### 2.13-A — Bench result
|
|
465
|
+
|
|
466
|
+
Bench host: openclaw-vm (Linux 6.8.0, x86_64). Hyperion `-t 32 -w 1`
|
|
467
|
+
on `bench/hello.ru` (generic Rack hello). 5-trial median, wrk 4.x.
|
|
468
|
+
|
|
469
|
+
**With access logging on (default — JSON access lines per request):**
|
|
470
|
+
|
|
471
|
+
| Workload | Master | 2.13-A | Δ |
|
|
472
|
+
|------------|-------:|-------:|---:|
|
|
473
|
+
| -c1 -t1 | 7,631 | 7,386 | -3.2% (within noise) |
|
|
474
|
+
| -c100 -t4 | 4,004 | 4,031 | +0.7% (within noise) |
|
|
475
|
+
|
|
476
|
+
**With `--no-log-requests` (logging disabled):**
|
|
477
|
+
|
|
478
|
+
| Workload | Master | 2.13-A | Δ |
|
|
479
|
+
|------------|-------:|-------:|---:|
|
|
480
|
+
| -c1 -t1 | 9,028 | 8,938 | -1.0% (within noise) |
|
|
481
|
+
| -c100 -t4 | 4,804 | 4,979 | **+3.6%** |
|
|
482
|
+
|
|
483
|
+
**Honest framing.** The +3.6% at `-c100 --no-log-requests` is the
|
|
484
|
+
clearest signal that the metrics-mutex contention removal lands. At
|
|
485
|
+
`-c1` the workload is single-thread CPU-bound (one wrk thread, one
|
|
486
|
+
keepalive connection, one Hyperion worker thread serving it); the
|
|
487
|
+
optimisations don't help and don't hurt. At `-c100` the
|
|
488
|
+
optimisations reach a reasonable +3.6% on the no-log path; with
|
|
489
|
+
logging on, the access-log path dominates the per-request CPU
|
|
490
|
+
budget so the metrics savings are masked.
|
|
491
|
+
|
|
492
|
+
**What the bench reveals.** The 4.25× gap to Agoo on the generic
|
|
493
|
+
Rack workload that 2.12-B identified is not a metrics-contention
|
|
494
|
+
problem and not an env-pool problem (env pooling was already in
|
|
495
|
+
place since 1.6.x). It is a **single-thread Ruby work per request**
|
|
496
|
+
ceiling — at `-c1` the bench tops out at ~9,000 r/s = 110 µs/req
|
|
497
|
+
of single-thread Ruby work, which Agoo (Rack-shape C server) does
|
|
498
|
+
in ~52 µs/req. Closing that gap requires moving meaningful chunks
|
|
499
|
+
of the per-request Ruby surface (parser, env build, headers, log
|
|
500
|
+
line) into C — work that's already 50% complete in
|
|
501
|
+
`Hyperion::CParser` (`build_env`, `build_response_head`,
|
|
502
|
+
`build_access_line`). The 2.13 sprint will continue moving the
|
|
503
|
+
remaining Ruby-side pieces (`Connection#serve` request loop,
|
|
504
|
+
ResponseWriter dispatch, `should_keep_alive?`) into C in subsequent
|
|
505
|
+
phases.
|
|
506
|
+
|
|
507
|
+
**Durable infrastructure.** The 2.13-A optimisations stay in the
|
|
508
|
+
tree even though the headline-bench delta is small. The
|
|
509
|
+
per-thread metrics shard removes a real mutex bottleneck that
|
|
510
|
+
*will* compound under future workloads — Ractor-based dispatch,
|
|
511
|
+
multi-process scrapers, observability-heavy apps that observe
|
|
512
|
+
custom histograms per-request. The Rack-3 keepalive fast path and
|
|
513
|
+
the cached worker-id tuple are pure-quality changes (less code,
|
|
514
|
+
fewer allocations, no API surface change).
|
|
515
|
+
|
|
516
|
+
## 2.12.0 — 2026-05-01
|
|
517
|
+
|
|
518
|
+
### 2.12-F — gRPC support on h2
|
|
519
|
+
|
|
520
|
+
**Background.** Hyperion has shipped a working HTTP/2 stack since 1.6
|
|
521
|
+
(per-stream fiber multiplexing, WINDOW_UPDATE-aware flow control, ALPN
|
|
522
|
+
auto-negotiation, native HPACK via the Rust v3/CGlue codec since 2.5-B).
|
|
523
|
+
What was missing for gRPC over Hyperion was three small things:
|
|
524
|
+
|
|
525
|
+
1. **Trailing headers.** gRPC carries its protocol-level status
|
|
526
|
+
(`grpc-status: 0` / `grpc-message: OK`) as **trailers** — a
|
|
527
|
+
final HEADERS frame sent AFTER the body's DATA frames, with
|
|
528
|
+
END_STREAM=1. Hyperion's pre-2.12-F dispatch path always
|
|
529
|
+
folded END_STREAM onto the LAST DATA frame, so there was no
|
|
530
|
+
hook for emitting trailers.
|
|
531
|
+
2. **Binary-clean request body.** The h2 RequestStream initialised
|
|
532
|
+
`@request_body = +''` (UTF-8). Valid gRPC request bodies
|
|
533
|
+
(`[1-byte compressed flag][4-byte length-prefix][protobuf bytes]`)
|
|
534
|
+
contain non-UTF-8 byte sequences, which broke `.bytesize` /
|
|
535
|
+
`valid_encoding?` checks and corrupted bodies that downstream
|
|
536
|
+
code interpolated into a UTF-8 String.
|
|
537
|
+
3. **TE: trailers preservation.** Hyperion's RFC 7540 §8.1.2.2
|
|
538
|
+
validator already accepted `te: trailers` (any other TE value
|
|
539
|
+
is rejected as a protocol error per the spec). What was needed
|
|
540
|
+
was a non-regression spec confirming the header makes it to
|
|
541
|
+
`env['HTTP_TE']` for the Rack app.
|
|
542
|
+
|
|
543
|
+
**What's new.** The h2 dispatch path (`Http2Handler#dispatch_stream`)
|
|
544
|
+
now checks if the Rack response body responds to `:trailers` AFTER
|
|
545
|
+
iterating the body. When it does, the wire shape becomes:
|
|
546
|
+
|
|
547
|
+
```
|
|
548
|
+
HEADERS (no END_STREAM)
|
|
549
|
+
DATA (no END_STREAM on last DATA)
|
|
550
|
+
HEADERS (END_STREAM=1) ← trailers, e.g. grpc-status: 0
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
The trailer Hash is filtered defensively before encoding: pseudo-headers
|
|
554
|
+
(`:status` / `:method` / etc.) and connection-specific names
|
|
555
|
+
(`connection`, `transfer-encoding`, …) are stripped — same allow-list
|
|
556
|
+
the regular response-header path uses. A misbehaving app that returns
|
|
557
|
+
non-Hash trailers (or whose `body.trailers` raises) gets a warn log and
|
|
558
|
+
falls back to the no-trailers path; the connection never crashes.
|
|
559
|
+
|
|
560
|
+
`Http2Handler::RequestStream#@request_body` is now an
|
|
561
|
+
`Encoding::ASCII_8BIT` String, so binary bytes survive verbatim through
|
|
562
|
+
`<<` accumulation and `env['rack.input'].read`.
|
|
563
|
+
|
|
564
|
+
**What's NOT changed.** The existing wire shape for non-gRPC h2 traffic
|
|
565
|
+
is identical to 2.11.x. A Rack body that doesn't define `:trailers` (or
|
|
566
|
+
where `body.trailers` is `nil` / empty) takes the pre-2.12-F path:
|
|
567
|
+
HEADERS → DATA-with-END_STREAM-on-last. Verified by three non-regression
|
|
568
|
+
specs in `spec/hyperion/grpc_trailers_spec.rb`.
|
|
569
|
+
|
|
570
|
+
The HTTP/1.1 dispatch path is untouched. gRPC is HTTP/2-only by spec
|
|
571
|
+
(the `Trailer:` mechanism on h1 has interop gaps with Go / Java clients;
|
|
572
|
+
gRPC mandates h2 since v1.0). Hyperion follows suit.
|
|
573
|
+
|
|
574
|
+
**What's NOT in scope for this stream.** gRPC server streaming /
|
|
575
|
+
client streaming / bidi streaming require Rack 3 streaming bodies AND
|
|
576
|
+
a way for the Rack app to read incoming DATA frames after sending
|
|
577
|
+
response HEADERS. That's a separate stream-control problem (`rack.hijack`
|
|
578
|
+
on h2 needs RFC 8441 Extended CONNECT, same blocker as WebSocket-over-h2).
|
|
579
|
+
2.12-F lands the **unary** (request-response) and **server-side trailers**
|
|
580
|
+
half — which covers ~80% of production gRPC traffic shapes by message
|
|
581
|
+
volume per the public Google / Netflix talks. Streaming is a 2.13
|
|
582
|
+
candidate.
|
|
583
|
+
|
|
584
|
+
**Opt-in via Rack 3 `body.trailers`.** Apps don't need to know about
|
|
585
|
+
Hyperion. Any Rack 3 body — hand-rolled, `grpc-server-rack`, a future
|
|
586
|
+
`Rack::Trailers` middleware — that exposes `body.trailers` returning
|
|
587
|
+
a Hash gets the trailing HEADERS frame on the wire. Bodies that don't
|
|
588
|
+
expose it (the overwhelming majority of HTTP/2 traffic — Rails,
|
|
589
|
+
Sinatra, asset servers, JSON APIs) take the legacy path with no
|
|
590
|
+
behaviour change.
|
|
591
|
+
|
|
592
|
+
**Spec coverage.** `spec/hyperion/grpc_trailers_spec.rb` — 11 specs
|
|
593
|
+
covering:
|
|
594
|
+
|
|
595
|
+
- End-to-end TLS+h2 client driving a real Hyperion server through
|
|
596
|
+
`Protocol::HTTP2::Client`, asserting the trailing HEADERS frame
|
|
597
|
+
decodes to the expected `grpc-status` / `grpc-message` map.
|
|
598
|
+
- `te: trailers` request header reaches `env['HTTP_TE']`.
|
|
599
|
+
- Binary request body bytes (`\xFF\x00\x01\x02\xC3\x28\x80`)
|
|
600
|
+
round-trip verbatim through `env['rack.input'].read`.
|
|
601
|
+
- Non-regression: bodies without `:trailers` keep the
|
|
602
|
+
DATA-with-END_STREAM-on-last shape (no extra HEADERS frame).
|
|
603
|
+
- Defensive: `body.trailers = nil` and `body.trailers = {}` both
|
|
604
|
+
take the no-trailers path.
|
|
605
|
+
- Unit tests for `collect_response_trailers` covering nil /
|
|
606
|
+
raises / Hash-coercible / non-responding bodies.
|
|
607
|
+
|
|
608
|
+
`spec/hyperion/grpc_smoke_spec.rb` — opt-in (`RUN_GRPC_SMOKE=1`)
|
|
609
|
+
end-to-end smoke against the real `grpc` Ruby gem. Skipped by default
|
|
610
|
+
because the gem pulls protobuf C bindings (~50 MB build); the unit
|
|
611
|
+
specs above are the durable coverage.
|
|
612
|
+
|
|
613
|
+
**Performance.** Zero hot-path cost when no app uses trailers — the
|
|
614
|
+
`body.respond_to?(:trailers)` probe is one method dispatch, the
|
|
615
|
+
trailers-Hash branch only runs when the probe returns truthy. The
|
|
616
|
+
trailers-emitting path costs one extra encoder-mutex acquisition + one
|
|
617
|
+
extra HEADERS frame per response — negligible against the existing
|
|
618
|
+
HEADERS+DATA sequence. No bench numbers here because gRPC throughput
|
|
619
|
+
on Hyperion is dominated by the application's protobuf marshalling,
|
|
620
|
+
not the framing layer; a meaningful gRPC bench is a 2.13 follow-up
|
|
621
|
+
once we have a streaming story to compare against Falcon's
|
|
622
|
+
`async-grpc`.
|
|
623
|
+
|
|
624
|
+
### 2.12-E — SO_REUSEPORT load balancing audit
|
|
625
|
+
|
|
626
|
+
**Background.** Hyperion's cluster mode (`-w N`) uses two listener-FD
|
|
627
|
+
models depending on platform:
|
|
628
|
+
|
|
629
|
+
- **Linux:** `SO_REUSEPORT`. Every worker calls `bind()` on the same
|
|
630
|
+
port; the kernel does in-kernel load balancing across connected
|
|
631
|
+
workers based on a 4-tuple hash. Documented as the
|
|
632
|
+
production-recommended setup since rc15.
|
|
633
|
+
- **Darwin / BSD:** master-bind + worker-fd-share. The master process
|
|
634
|
+
binds the listener and passes the fd to workers via `fork()`;
|
|
635
|
+
workers race to `accept()`. Darwin's `SO_REUSEPORT` doesn't
|
|
636
|
+
load-balance — it just lets multiple sockets bind without
|
|
637
|
+
`EADDRINUSE` — so we deliberately do NOT use it on Darwin.
|
|
638
|
+
|
|
639
|
+
The Linux path was built on the assumption that the kernel hash
|
|
640
|
+
distributes uniformly across workers under sustained load. We had no
|
|
641
|
+
**measurement** of that — only theory. 2.12-E fixes the gap.
|
|
642
|
+
|
|
643
|
+
**New per-worker request counter.** `Hyperion::Metrics#tick_worker_request`
|
|
644
|
+
ticks the labeled counter family
|
|
645
|
+
`hyperion_requests_dispatch_total{worker_id="<pid>"}` once per
|
|
646
|
+
dispatched request, regardless of which dispatch shape served it:
|
|
647
|
+
|
|
648
|
+
- Rack-via-`Connection#serve` (the threadpool / inline / async-io
|
|
649
|
+
paths).
|
|
650
|
+
- HTTP/2 stream dispatch in `Http2Handler`.
|
|
651
|
+
- The 2.12-C `accept4` C accept loop.
|
|
652
|
+
- The 2.12-D io_uring accept loop.
|
|
653
|
+
|
|
654
|
+
The C-loop variants tick a process-global atomic counter
|
|
655
|
+
(`Hyperion::Http::PageCache.c_loop_requests_total`) that the
|
|
656
|
+
`PrometheusExporter.render_full` pass folds into the
|
|
657
|
+
per-worker series at scrape time — so operators see one consistent
|
|
658
|
+
per-PID count even on the C-only fast path that bypasses
|
|
659
|
+
`Connection#serve`. Hot-path cost on the C loops is one
|
|
660
|
+
`__atomic_add_fetch` (relaxed) — negligible against the 134k r/s
|
|
661
|
+
2.12-D bench peak.
|
|
662
|
+
|
|
663
|
+
The label value is `Process.pid.to_s` — matches the 2.4-C
|
|
664
|
+
`hyperion_io_uring_workers_active` and
|
|
665
|
+
`hyperion_per_conn_rejections_total` labeling convention; lets
|
|
666
|
+
operators correlate cluster-mode rows with `ps`/`/proc` data without
|
|
667
|
+
a separate worker_id ↔ pid mapping table.
|
|
668
|
+
|
|
669
|
+
**New bench harness.** `bench/cluster_distribution.sh` boots
|
|
670
|
+
Hyperion `-w 4 -t 1 -p 9292 bench/hello_static.ru` (4 workers,
|
|
671
|
+
sharp imbalance signal), runs `wrk -t8 -c200 -d30s` against `/`,
|
|
672
|
+
then scrapes `/-/metrics` repeatedly until all 4 workers have
|
|
673
|
+
responded. Reports the per-worker request distribution
|
|
674
|
+
(pid + count + share-%), mean / stddev / max-vs-min ratio, and a
|
|
675
|
+
verdict:
|
|
676
|
+
|
|
677
|
+
- `balanced` (max/min ≤ 1.10): kernel hash is doing its job.
|
|
678
|
+
- `mild` (1.10 < max/min ≤ 1.50): note here, no fix.
|
|
679
|
+
- `severe` (max/min > 1.50): file follow-up; do not ship as-is.
|
|
680
|
+
|
|
681
|
+
Three runs back-to-back so noise is visible; aggregate verdict is
|
|
682
|
+
the worst per-run verdict (one severe run is enough to fail the
|
|
683
|
+
audit).
|
|
684
|
+
|
|
685
|
+
**Bench result.** See `docs/BENCH_HYPERION_2_11.md`'s "Cluster
|
|
686
|
+
distribution audit" section. Headline number lands in the
|
|
687
|
+
`[bench][2.12.0] 2.12-E — bench result: …` follow-up commit.
|
|
688
|
+
|
|
689
|
+
**Darwin caveat.** The Darwin master-bind/worker-fd-share path is
|
|
690
|
+
documented as known-imbalanced and is NOT covered by this audit
|
|
691
|
+
(no kernel SO_REUSEPORT distributor to measure). The metric
|
|
692
|
+
infrastructure is platform-independent; any operator running on
|
|
693
|
+
Darwin can read their own per-worker imbalance via `/-/metrics` and
|
|
694
|
+
the same `hyperion_requests_dispatch_total{worker_id}` series.
|
|
695
|
+
|
|
696
|
+
**Spec coverage.** `spec/hyperion/metrics_per_worker_request_count_spec.rb`
|
|
697
|
+
asserts:
|
|
698
|
+
|
|
699
|
+
- `Metrics#tick_worker_request` registers the labeled family with
|
|
700
|
+
`worker_id` label and increments the right series.
|
|
701
|
+
- `Connection#serve` ticks the counter once per request from any
|
|
702
|
+
dispatch shape (regular Rack, direct dispatch, StaticEntry fast
|
|
703
|
+
path).
|
|
704
|
+
- `PrometheusExporter.render_full` emits the merged
|
|
705
|
+
`hyperion_requests_dispatch_total{worker_id="<pid>"}` line.
|
|
706
|
+
- The C accept4 loop's atomic counter ticks per request and is
|
|
707
|
+
reset on `run_static_accept_loop` entry. (Gated on the C ext
|
|
708
|
+
being available; same gating pattern 2.12-D specs use.)
|
|
709
|
+
|
|
710
|
+
### 2.12-D — io_uring accept loop (Linux 5.x)
|
|
711
|
+
|
|
712
|
+
The 2.12-C `accept4` loop closed most of the gap to Agoo on the
|
|
713
|
+
`handle_static` hello row (5,502 → 15,685 r/s, 1.21× from parity).
|
|
714
|
+
Looking at the remaining cost on Linux: the worker thread does
|
|
715
|
+
**three** syscalls per request — `accept4` + `recv` + `write`. On a
|
|
716
|
+
host that delivers ~150 ns of syscall entry/exit overhead each, that's
|
|
717
|
+
~450 ns of pure kernel-mode bookkeeping per request, before any I/O
|
|
718
|
+
actually happens.
|
|
719
|
+
|
|
720
|
+
io_uring lets us collapse the train. We submit ACCEPT/RECV/WRITE/CLOSE
|
|
721
|
+
SQEs to a per-worker ring; the worker thread does **one**
|
|
722
|
+
`io_uring_enter` per cycle and reaps N completions in a single
|
|
723
|
+
syscall round-trip. Steady-state cost goes from N×3 syscalls to
|
|
724
|
+
~N÷K × 1 syscall (where K is the burst-batch size the kernel
|
|
725
|
+
naturally delivers).
|
|
726
|
+
|
|
727
|
+
**New C exports** on `Hyperion::Http::PageCache`:
|
|
728
|
+
|
|
729
|
+
- `run_static_io_uring_loop(listen_fd) -> Integer | :crashed | :unavailable`
|
|
730
|
+
drives the io_uring accept-and-serve loop. Same wire contract as
|
|
731
|
+
`run_static_accept_loop`: only `handle_static` routes, plain TCP,
|
|
732
|
+
GET/HEAD without a body, HTTP/1.1 only. Returns `:unavailable` if
|
|
733
|
+
the C ext was built without `liburing` OR the runtime
|
|
734
|
+
`io_uring_queue_init` probe failed (seccomp / locked-down
|
|
735
|
+
container / kernel < 5.5). The Ruby caller treats `:unavailable`
|
|
736
|
+
as "fall through to the 2.12-C `accept4` path" — operator gets a
|
|
737
|
+
one-line warn-level log, no surprise downtime.
|
|
738
|
+
- `io_uring_loop_compiled?` — boolean, true when the C ext was
|
|
739
|
+
built with `HAVE_LIBURING`. Cheap eligibility check that lets
|
|
740
|
+
`ConnectionLoop#io_uring_eligible?` skip the env-var read on
|
|
741
|
+
builds where the path can't engage anyway.
|
|
742
|
+
|
|
743
|
+
**Build.** `extconf.rb` probes for `liburing` in two passes:
|
|
744
|
+
|
|
745
|
+
1. `pkg-config --exists liburing` — picks up Debian/Ubuntu's
|
|
746
|
+
pkg-config metadata and adds the right `-L`/`-l` flags.
|
|
747
|
+
2. `have_header('liburing.h')` + `have_library('uring', ...)` —
|
|
748
|
+
covers the no-pkg-config path.
|
|
749
|
+
|
|
750
|
+
On success, `-DHAVE_LIBURING` lands and the io_uring loop compiles.
|
|
751
|
+
On failure, the same `io_uring_loop.c` translation unit compiles to
|
|
752
|
+
a thin stub that returns `:unavailable`. Soft-optional dependency:
|
|
753
|
+
hosts without liburing-dev still build cleanly and ship the 2.12-C
|
|
754
|
+
behaviour.
|
|
755
|
+
|
|
756
|
+
**Wiring.** `Hyperion::Server::ConnectionLoop.io_uring_eligible?`
|
|
757
|
+
returns true when ALL hold:
|
|
758
|
+
|
|
759
|
+
1. The C accept-loop path is available
|
|
760
|
+
(`ConnectionLoop.available?`).
|
|
761
|
+
2. The C ext was compiled with `HAVE_LIBURING`.
|
|
762
|
+
3. `HYPERION_IO_URING_ACCEPT=1` is set (default OFF in 2.12 — the
|
|
763
|
+
soak window before flipping the default to ON is 2.13 or later).
|
|
764
|
+
|
|
765
|
+
When eligible, `Server#run_c_accept_loop` calls
|
|
766
|
+
`run_static_io_uring_loop` first; on `:unavailable` (runtime probe
|
|
767
|
+
fail) it falls through to `run_static_accept_loop`. A new
|
|
768
|
+
`:c_accept_loop_io_uring_h1` `DispatchMode` symbol counts engagements
|
|
769
|
+
under `requests_dispatch_c_accept_loop_io_uring_h1`.
|
|
770
|
+
|
|
771
|
+
**Lifecycle hooks.** Same contract as 2.12-C: per-request
|
|
772
|
+
`Runtime#fire_request_start` / `#fire_request_end` fire on every
|
|
773
|
+
request the io_uring loop served. `env=nil`, minimal `Hyperion::Request`
|
|
774
|
+
passed. The C-side `lifecycle_active?` flag gates the GVL re-acquisition
|
|
775
|
+
(`rb_thread_call_with_gvl`); the no-hook hot path stays GVL-free for
|
|
776
|
+
the whole `submit_and_wait` cycle.
|
|
777
|
+
|
|
778
|
+
**Handoff.** Same set of "send to Ruby" triggers as 2.12-C: HTTP/1.0,
|
|
779
|
+
`Content-Length` / `Transfer-Encoding`, `Upgrade`, `HTTP2-Settings`,
|
|
780
|
+
`Connection: upgrade`, malformed framing, header section >64 KiB,
|
|
781
|
+
unknown method, path miss. Ruby resumes ownership via the same
|
|
782
|
+
`dispatch_handed_off` path the 2.12-C loop already uses.
|
|
783
|
+
|
|
784
|
+
**TCP_NODELAY.** Applied per-accept via the shared
|
|
785
|
+
`pc_internal_apply_tcp_nodelay` wrapper — preserves the 2.10-G hunk.
|
|
786
|
+
|
|
787
|
+
**Tests.** `spec/hyperion/io_uring_loop_spec.rb` covers the Ruby
|
|
788
|
+
surface (always exposed), the stub `:unavailable` return on
|
|
789
|
+
non-Linux / no-liburing builds, eligibility-gate semantics
|
|
790
|
+
(`HYPERION_IO_URING_ACCEPT` env var honoured, compile-time flag
|
|
791
|
+
honoured), and — gated on `HAVE_LIBURING` — a smoke test (5 GETs,
|
|
792
|
+
assert served count grows), lifecycle-hook firing parity with the
|
|
793
|
+
2.12-C contract, and Server-level engagement (the
|
|
794
|
+
`:c_accept_loop_io_uring_h1` dispatch mode is recorded).
|
|
795
|
+
|
|
796
|
+
Total: 1065 specs / 0 failures / 15 pending on macOS — +10 specs and
|
|
797
|
+
+4 pending over the 2.12-C macOS baseline (1055 / 0 / 11). Linux:
|
|
798
|
+
1065 / 1 / 14 (the one failure is a pre-existing flake on the 2.12-C
|
|
799
|
+
smoke spec — reproduces on unmodified master at e526ef3 on the bench
|
|
800
|
+
host, NOT a 2.12-D regression).
|
|
801
|
+
|
|
802
|
+
**Bench (3 trials, median, openclaw-vm 16 vCPU Ubuntu 24.04 Linux 6.8.0,
|
|
803
|
+
liburing 2.5, wrk -t4 -c100 -d20s, `bench/hello_static.ru`):**
|
|
804
|
+
|
|
805
|
+
| Variant | r/s (median) | p99 |
|
|
806
|
+
|---|---:|---:|
|
|
807
|
+
| 2.12-C `handle_static` Hyperion (C accept loop, accept4) | 15,532 | 101 µs |
|
|
808
|
+
| **2.12-D `handle_static` Hyperion (C accept loop, io_uring)** | **134,084** | **0.99 ms** |
|
|
809
|
+
| Agoo 2.15.14 (reference, prior bench) | 19,024 | 10.47 ms |
|
|
810
|
+
|
|
811
|
+
`handle_static` hello: **8.6× over the 2.12-C accept4 baseline**
|
|
812
|
+
(15,532 → 134,084 r/s) and **7.0× over Agoo** (134,084 vs. 19,024).
|
|
813
|
+
The accept4 path is unchanged and within bench noise of the prior
|
|
814
|
+
2.12-C 15,685 r/s baseline (15,532 r/s today is -1%, well inside the
|
|
815
|
+
±5% target).
|
|
816
|
+
|
|
817
|
+
The p99 climb (101 µs accept4 → 0.99 ms io_uring) is the cost of
|
|
818
|
+
batching: with one `io_uring_enter` reaping K completions, the
|
|
819
|
+
last-enqueued request waits for the kernel to produce all K CQEs
|
|
820
|
+
before our worker drains. At 134k r/s the per-request median is
|
|
821
|
+
~7.4 µs; the p99 of ~990 µs is roughly the steady-state batch-tail
|
|
822
|
+
(~130 requests' worth of completions). For the smoke-class workload
|
|
823
|
+
this is a clear win — sub-millisecond p99 is below most real-world
|
|
824
|
+
network jitter floors and well below Agoo's 10.47 ms p99.
|
|
825
|
+
|
|
826
|
+
The Rack-style fallback (`bench/hello.ru`, no `handle_static`) is
|
|
827
|
+
unaffected: io_uring engagement requires `Server.handle_static`
|
|
828
|
+
registration, the regular dynamic-Rack path is unchanged.
|
|
829
|
+
|
|
830
|
+
**Production rollout.** Default OFF for 2.12.0. Operators opt in
|
|
831
|
+
with `HYPERION_IO_URING_ACCEPT=1` per worker. Soak window for
|
|
832
|
+
flipping the default to ON is 2.13 — kernel io_uring has known
|
|
833
|
+
sharp edges around fork-shared rings and `seccomp` policies that
|
|
834
|
+
this default-off posture lets us discover under operator A/B
|
|
835
|
+
before forcing the path on every install.
|
|
836
|
+
|
|
837
|
+
### 2.12-C — Connection lifecycle in C
|
|
838
|
+
|
|
839
|
+
The 2.12-B re-bench made it clear that Hyperion's `handle_static`
|
|
840
|
+
hot path was already doing the **response side** in C
|
|
841
|
+
(`PageCache.serve_request`, 2.10-F), but the **connection
|
|
842
|
+
lifecycle around it** — accept loop, route lookup, per-`Connection`
|
|
843
|
+
ivar init — was still in Ruby. The 2.10-D / 2.10-F bench analysis
|
|
844
|
+
flagged that as the next dominant cost: `accept4` + `clone3`
|
|
845
|
+
(worker thread wakeup) + `futex` (GVL handoff) + Ruby ivar init
|
|
846
|
+
on every connection.
|
|
847
|
+
|
|
848
|
+
This stream pushes the entire accept→read→route-lookup→write
|
|
849
|
+
loop into C for `handle_static`-routed paths.
|
|
850
|
+
|
|
851
|
+
**New C exports** on `Hyperion::Http::PageCache`:
|
|
852
|
+
|
|
853
|
+
- `run_static_accept_loop(listen_fd) -> Integer | :crashed`
|
|
854
|
+
drives the accept-and-serve loop. Releases the GVL during
|
|
855
|
+
`accept(2)`, `recv(2)`, and `write(2)`; re-acquires only for
|
|
856
|
+
the registered lifecycle / handoff Ruby callbacks. Returns
|
|
857
|
+
the count of requests served when the listener closes
|
|
858
|
+
cleanly, or `:crashed` if an unrecoverable accept error
|
|
859
|
+
happened (Ruby falls back to its own accept loop on this
|
|
860
|
+
sentinel).
|
|
861
|
+
- `set_lifecycle_callback(callable)` and
|
|
862
|
+
`set_lifecycle_active(bool)` — register / gate the per-request
|
|
863
|
+
lifecycle hook. Decoupled so flipping the gate at runtime
|
|
864
|
+
doesn't re-allocate the callback. The hot path checks a
|
|
865
|
+
single `int` flag; the no-hook path stays one syscall (recv
|
|
866
|
+
or write) per request.
|
|
867
|
+
- `set_handoff_callback(callable)` — register the callback the
|
|
868
|
+
C loop invokes when a connection's first request can't be
|
|
869
|
+
served from the static cache (path miss, malformed request,
|
|
870
|
+
body present, h2 upgrade requested, HTTP/1.0). Receives
|
|
871
|
+
`(fd_int, partial_buffer_or_nil)`; Ruby owns the fd from
|
|
872
|
+
that point on and resumes ownership via the regular
|
|
873
|
+
`Connection` path.
|
|
874
|
+
- `stop_accept_loop` and `lifecycle_active?` — operator /
|
|
875
|
+
spec helpers.
|
|
876
|
+
- `handoff_to_ruby(client_fd, partial_buffer, partial_len)` —
|
|
877
|
+
echo helper exposing the 2.12-C contract for spec-time
|
|
878
|
+
introspection (the actual handoff happens inside
|
|
879
|
+
`run_static_accept_loop` via the registered callback).
|
|
880
|
+
|
|
881
|
+
**Wiring.** `Hyperion::Server::ConnectionLoop` (new module) holds
|
|
882
|
+
the engagement check + callback factories. `Server#start_raw_loop`
|
|
883
|
+
engages the C loop when:
|
|
884
|
+
|
|
885
|
+
1. The listener is plain TCP (no TLS, no h2 ALPN dance).
|
|
886
|
+
2. The route table has at least one
|
|
887
|
+
`RouteTable::StaticEntry` registration.
|
|
888
|
+
3. The route table has NO non-StaticEntry registrations
|
|
889
|
+
(any dynamic handler disables the C path; the C loop
|
|
890
|
+
only knows how to write prebuilt responses).
|
|
891
|
+
4. The C ext is available (the `Hyperion::Http::PageCache`
|
|
892
|
+
module responds to `:run_static_accept_loop`) and the
|
|
893
|
+
`HYPERION_C_ACCEPT_LOOP=0` escape hatch is not set.
|
|
894
|
+
|
|
895
|
+
A new `:c_accept_loop_h1` `DispatchMode` symbol ships under
|
|
896
|
+
`requests_dispatch_c_accept_loop_h1` (one bump per worker boot
|
|
897
|
+
that engages the path) plus `c_accept_loop_requests` (count of
|
|
898
|
+
requests the C loop served on this worker, bumped at loop exit).
|
|
899
|
+
|
|
900
|
+
**Lifecycle hooks.** The 2.10-D contract holds: per-request
|
|
901
|
+
`Runtime#fire_request_start` / `#fire_request_end` fire on every
|
|
902
|
+
request the C loop served. `env=nil`, `path=` the matched path
|
|
903
|
+
string — same surface as the Ruby direct-dispatch path. The
|
|
904
|
+
Ruby-side bridge in `ConnectionLoop.build_lifecycle_callback`
|
|
905
|
+
builds a minimal `Hyperion::Request` for the hooks to consume.
|
|
906
|
+
The C-side `lifecycle_active?` flag gates the `rb_funcall`;
|
|
907
|
+
when no hooks are registered, the no-hook hot path is one
|
|
908
|
+
syscall (recv) + one syscall (write) per request, with **zero
|
|
909
|
+
Ruby method invocations**.
|
|
910
|
+
|
|
911
|
+
**Handoff.** When the C loop sees a request it can't serve from
|
|
912
|
+
the static cache (POST, path miss, malformed framing,
|
|
913
|
+
`Content-Length`, `Transfer-Encoding`, `Upgrade`,
|
|
914
|
+
`HTTP2-Settings`, `Connection: upgrade`, HTTP/1.0), it invokes
|
|
915
|
+
the handoff callback with `(fd_int, partial_buffer_str)` and
|
|
916
|
+
continues to the next accept. Ruby resumes ownership of the fd
|
|
917
|
+
via `dispatch_handed_off`, which wraps the fd in `Socket.for_fd`,
|
|
918
|
+
applies the read timeout (matches the regular Ruby accept
|
|
919
|
+
path), pre-loads the partial buffer onto the Connection's
|
|
920
|
+
`@inbuf` ivar so the parser sees the bytes the C loop already
|
|
921
|
+
consumed, and dispatches through the existing thread-pool /
|
|
922
|
+
inline path.
|
|
923
|
+
|
|
924
|
+
**Tests.** `spec/hyperion/connection_loop_spec.rb` covers
|
|
925
|
+
smoke (registered route → C loop served-count grows), mixed
|
|
926
|
+
registered + unregistered (C loop hands off to the Ruby callback
|
|
927
|
+
spy with the partial buffer + fd), body-present requests
|
|
928
|
+
(`POST /hello` hands off), lifecycle hooks
|
|
929
|
+
(`set_lifecycle_active(true)` fires the registered callback
|
|
930
|
+
once per served request; `false` is silent — no callback
|
|
931
|
+
invocation, even with one set), GVL release (a slow C-loop
|
|
932
|
+
parked on `accept(2)` doesn't block another Ruby thread doing
|
|
933
|
+
arithmetic), keep-alive on a single connection (two pipelined
|
|
934
|
+
requests on the same socket → two responses), Server-level
|
|
935
|
+
engagement (only-static-routes engages the C loop;
|
|
936
|
+
mixed-with-dynamic-handlers refuses to engage and falls back
|
|
937
|
+
to the regular Ruby loop).
|
|
938
|
+
|
|
939
|
+
Total: 1112 specs / 0 failures / 11 pending — +10 specs over
|
|
940
|
+
the 2.12-B baseline (1102 / 0 / 11).
|
|
941
|
+
|
|
942
|
+
**Bench (3 trials, median, openclaw-vm 16 vCPU Ubuntu 24.04 Linux 6.8.0,
|
|
943
|
+
wrk -t4 -c100 -d20s, `bench/hello_static.ru`):**
|
|
944
|
+
|
|
945
|
+
| Variant | r/s (median) | p99 |
|
|
946
|
+
|---|---:|---:|
|
|
947
|
+
| 2.12-B `handle_static` Hyperion (Ruby accept loop + C `serve_request`) | 5,502 | 1.59 ms |
|
|
948
|
+
| **2.12-C `handle_static` Hyperion (C accept loop)** | **15,685** | **107 µs** |
|
|
949
|
+
| Agoo 2.15.14 (reference) | 19,024 | 10.47 ms |
|
|
950
|
+
|
|
951
|
+
`handle_static` hello: **2.85× over the 2.12-B baseline** (5,502 → 15,685).
|
|
952
|
+
Gap to Agoo's 19,024 r/s closed from **3.46×** to **1.21×** — the
|
|
953
|
+
2.12-C path is now within striking distance of Agoo on this row.
|
|
954
|
+
Tail latency (p99) is now **107 µs**, an **15× improvement** over
|
|
955
|
+
the 2.12-B 1.59 ms p99 and **97× better** than Agoo's 10.47 ms p99.
|
|
956
|
+
|
|
957
|
+
The Rack-style fallback (`bench/hello.ru`, no `handle_static`) was
|
|
958
|
+
re-checked: 4,648 r/s median (vs 4,477 r/s 2.12-B baseline — within
|
|
959
|
+
bench noise, no regression). The C-loop path is only engaged when
|
|
960
|
+
the operator opts in via `Server.handle_static` registration on
|
|
961
|
+
every route; the regular dynamic-Rack path is unchanged.
|
|
962
|
+
|
|
963
|
+
### 2.12-B — Fresh 4-way re-bench (post-2.10/2.11 wins)
|
|
964
|
+
|
|
965
|
+
The 4-way head-to-head in `docs/BENCH_HYPERION_2_0.md`
|
|
966
|
+
§ "4-way head-to-head (2.10-B baseline, 2026-05-01)" was
|
|
967
|
+
captured before the 2.10-C / 2.10-D / 2.10-E / 2.10-F /
|
|
968
|
+
2.10-G / 2.11-A / 2.11-B wins landed. Every Hyperion column
|
|
969
|
+
in that doc was therefore stale; the Puma / Falcon / Agoo
|
|
970
|
+
columns were unchanged (no version bumps on the bench host).
|
|
971
|
+
This stream re-runs the entire 6-row matrix on the 2.11.0
|
|
972
|
+
codebase — same host (`openclaw-vm`, 16 vCPU, Ubuntu 24.04,
|
|
973
|
+
Linux 6.8.0), same tools (`wrk` + `perfer`), 3 trials per
|
|
974
|
+
row, median reported — and writes the results to a new
|
|
975
|
+
`docs/BENCH_HYPERION_2_11.md` (pointed at from the README's
|
|
976
|
+
Benchmarks section; the 2.0 doc is now marked "historical
|
|
977
|
+
baseline (pre-2.10/2.11 wins)").
|
|
978
|
+
|
|
979
|
+
The harness at `bench/4way_compare.sh` gains a sixth server
|
|
980
|
+
label, `hyperion_handle_static`, which boots Hyperion against
|
|
981
|
+
an alternate rackup whose hot path is the
|
|
982
|
+
`Hyperion::Server.handle_static` direct route + 2.10-F C-ext
|
|
983
|
+
fast-path response writer + 2.10-C PageCache fold. This
|
|
984
|
+
exposes two Hyperion columns on the rows where it applies
|
|
985
|
+
(hello + small static): "Rack-style" (the legacy generic-Rack
|
|
986
|
+
path most apps run unchanged) and "`handle_static`" (the peak
|
|
987
|
+
path operators can opt into by registering one pre-built
|
|
988
|
+
route at boot). New rackup `bench/static_handle_static.ru`
|
|
989
|
+
preloads the 1 KB static asset bytes at boot and serves them
|
|
990
|
+
through the same direct-route fast path; the existing
|
|
991
|
+
`bench/hello_static.ru` (added in 2.10-F) plays the same role
|
|
992
|
+
for the hello row.
|
|
993
|
+
|
|
994
|
+
**Headline shifts (medians, vs the 2.10-B baseline at the
|
|
995
|
+
same workload):**
|
|
996
|
+
|
|
997
|
+
| # | Workload | 2.10-B Hyperion | 2.11.0 Rack-style | 2.11.0 `handle_static` | Gap-vs-leader shift |
|
|
998
|
+
|---:|---|---:|---:|---:|---|
|
|
999
|
+
| 1 | hello | 4,587 | 4,477 (-2.4%, noise) | **5,502** (+19.9%) | gap to Agoo: 4.22× → **3.46×** with `handle_static` |
|
|
1000
|
+
| 2 | static 1 KB | 1,380 | 1,687 (**+22.2%**, PageCache 2.10-C auto-engage) | **5,935** (+330%) | gap to Agoo: 1.89× behind → **flipped, Hyperion +127%** |
|
|
1001
|
+
| 3 | static 1 MiB | 1,378 | 1,513 (+9.8%) | n/a (handle_static buffers in memory; defeats sendfile) | Hyperion lead vs Agoo: 9.07× → 9.74× (held) |
|
|
1002
|
+
| 4 | CPU JSON 50-key | 3,450 | 3,659 (+6.0%) | n/a (per-request `JSON.generate`) | gap to Agoo: 1.85× → **2.05× (widened)**; Agoo +17.5% in same window |
|
|
1003
|
+
| 5 | PG-bound async (`pg_sleep 50ms`) | 1,564 | 1,565 (+0.1%) | n/a | Hyperion-only; identical |
|
|
1004
|
+
| 6 | SSE 1000 events × 50 B | 500 | 472 (-5.6%, noise) | n/a (single fixed response) | Hyperion lead vs Puma: 3.65× → 3.58× (flat) |
|
|
1005
|
+
|
|
1006
|
+
**Reading the deltas.**
|
|
1007
|
+
|
|
1008
|
+
- **The 2.10-D + 2.10-F + 2.10-C win-stack lands cleanly on
|
|
1009
|
+
static 1 KB.** Hyperion's `handle_static` row at 5,935 r/s
|
|
1010
|
+
wins the column outright by +127% (2.27×) over Agoo, +208%
|
|
1011
|
+
over Falcon, +282% over Puma. The Rack-style row also moved
|
|
1012
|
+
up +22.2% from the PageCache auto-engage even without
|
|
1013
|
+
explicit `handle_static` registration. Operators who can
|
|
1014
|
+
register one pre-built route via `handle_static` pick up a
|
|
1015
|
+
3.5× lift over the generic Rack-style path on this exact
|
|
1016
|
+
shape.
|
|
1017
|
+
- **Hello narrowed but did not close.** The gap to Agoo went
|
|
1018
|
+
from 4.22× to 3.46× with `handle_static`; the Rack-style
|
|
1019
|
+
row stayed flat (within bench noise) — meaning the 2.10-D
|
|
1020
|
+
direct-route win **only manifests when the rackup actually
|
|
1021
|
+
opts in via `handle_static`**.
|
|
1022
|
+
- **CPU JSON widened.** Agoo +17.5% in the same window
|
|
1023
|
+
Hyperion +6.0% took the gap from 1.85× to 2.05×. This is
|
|
1024
|
+
the one row the 2.10/2.11 streams didn't move; closing it
|
|
1025
|
+
is the obvious 2.12 follow-on.
|
|
1026
|
+
- **Large static, PG-bound async, SSE — Hyperion's existing
|
|
1027
|
+
wins held.** Sendfile (9.7× over Agoo on 1 MiB), fiber-
|
|
1028
|
+
cooperative I/O (Hyperion-only on PG-bound, identical to
|
|
1029
|
+
2.10-B), ChunkedCoalescer (3.58× over Puma, 16.7× over
|
|
1030
|
+
Falcon on SSE) all stayed clean. Agoo's SSE behavior
|
|
1031
|
+
regressed to a hard segfault at boot on `bench/sse_generic.ru`
|
|
1032
|
+
(different shape from 2.10-B's "buffers entire response,
|
|
1033
|
+
takes ~5 s to flush"; same conclusion either way — Agoo is
|
|
1034
|
+
not a viable SSE server on this rackup at any throughput).
|
|
1035
|
+
- **Tail latency is still Hyperion's clean win** across every
|
|
1036
|
+
row with a non-trivial p99: hello 1.73 ms vs Agoo 10.47 ms
|
|
1037
|
+
/ Puma 29 ms / Falcon 408 ms; 1 KB 1.69 ms vs 57–86 ms;
|
|
1038
|
+
1 MiB 4.63 ms vs 82–720 ms; CPU JSON 2.60 ms vs 17–411 ms;
|
|
1039
|
+
SSE 2.85 ms vs 11–42 ms.
|
|
1040
|
+
|
|
1041
|
+
**Harness changes** (`bench/4way_compare.sh`):
|
|
1042
|
+
|
|
1043
|
+
- New server label `hyperion_handle_static` — boots Hyperion
|
|
1044
|
+
against an alternate rackup specified by the new
|
|
1045
|
+
`HYPERION_STATIC_RACKUP` env var (falls back to the same
|
|
1046
|
+
`RACKUP` as the legacy `hyperion` label if unset, which
|
|
1047
|
+
becomes a harmless no-op duplicate). The four legacy
|
|
1048
|
+
labels (`hyperion`, `puma`, `falcon`, `agoo`) keep their
|
|
1049
|
+
byte-identical 2.10-B shape so the older doc stays
|
|
1050
|
+
reproducible.
|
|
1051
|
+
|
|
1052
|
+
**New rackup**: `bench/static_handle_static.ru` — preloads
|
|
1053
|
+
the 1 KB static asset (`/tmp/hyperion_bench_1k.bin`) at boot,
|
|
1054
|
+
registers it via `Hyperion::Server.handle_static`, and falls
|
|
1055
|
+
through to a 404 lambda for any other path. Mirrors the role
|
|
1056
|
+
`bench/hello_static.ru` plays for the hello row.
|
|
1057
|
+
|
|
1058
|
+
**Spec coverage** (`spec/hyperion/bench_handle_static_rackups_spec.rb`,
|
|
1059
|
+
9 examples):
|
|
1060
|
+
|
|
1061
|
+
- `bench/hello_static.ru` parses cleanly and registers a `/`
|
|
1062
|
+
StaticEntry with the expected response bytes.
|
|
1063
|
+
- `bench/static_handle_static.ru` preloads the asset and
|
|
1064
|
+
registers a route at boot; fails fast if the asset is
|
|
1065
|
+
missing rather than booting empty (prevents a silently-
|
|
1066
|
+
bound 404 from masking a misconfigured bench run).
|
|
1067
|
+
- `bench/4way_compare.sh` knows how to boot all five variant
|
|
1068
|
+
labels (hyperion, hyperion_handle_static, puma, falcon,
|
|
1069
|
+
agoo) — a typo in the harness's case statement or the
|
|
1070
|
+
new env var name fails this spec at unit-level instead
|
|
1071
|
+
of waiting for a 41-minute bench sweep to surface the
|
|
1072
|
+
regression.
|
|
1073
|
+
|
|
1074
|
+
Spec count: 1093 → 1102 (+9). 0 failures, 11 pending —
|
|
1075
|
+
invariant preserved.
|
|
1076
|
+
|
|
1077
|
+
**Reproducing.** See
|
|
1078
|
+
`docs/BENCH_HYPERION_2_11.md` § "Reproducing 4-way" for the
|
|
1079
|
+
six per-row commands. Bench host: `openclaw-vm`, sweep dir
|
|
1080
|
+
`/home/ubuntu/bench-2.12-B/`. Total wall time ~41 min.
|
|
1081
|
+
|
|
3
1082
|
## 2.11.0 — 2026-05-01
|
|
4
1083
|
|
|
5
1084
|
### 2.11-B — HPACK FFI marshalling round-2 (cglue confirmed as firm default; +43% v3 vs v2 on Rails-shape h2)
|