perf(http3): one send per response - pure C# h3 now beats nghttp3 at every size - #171
Merged
Conversation
ioxide.http3 lost to nghttp3 on small responses and won on large ones. The
crossover was a FIXED per-request cost, so profiling meant finding what every
response repeats regardless of body size.
Two suspects. Caching the encoded head - QPACK field section, content-length
and frame headers, on a response object a hot handler reuses for every
request - was worth only 2.8%. Kept, but not the cause.
The cause was two SendStream calls per response: one for the ~40-byte header
frame, one for the body. Every short response made two trips through the QUIC
send path. nghttp3 never did - its writev hands back ONE buffer carrying both
frames. Copying a small body in behind the head to make a single call is far
cheaper than the second call.
13-byte body, 2 reactors, 16 conns, 8 streams
before 371250 req/s 5.37 us cpu/req
after 897217 req/s 2.22 us cpu/req 2.4x
That was the only size it lost at, and it is now the size it wins by most:
nghttp3 pure C#
13 B 580919 897217 1.54x (was 0.64x)
50 KiB 38738 49727 1.28x
1 MiB 1986 2612 1.32x
Bodies over 4 KiB still send separately - copying them would cost more than
the second call saves. The threshold is a reasoned guess, not a swept optimum.
Playground/Http3/Managed is the pure-C# sample this was measured with; the
package shipped with no sample at all, only an E2E test. Http3/Nghttp3 gains
the body knob every other sample of its family already had, so the two can be
compared at one size rather than 13 bytes against 8 KiB.
MDA2AV
added a commit
that referenced
this pull request
Aug 10, 2026
Three panes had drifted or were missing. Http3/Nghttp3's pane went stale when #171 gave that sample a body knob - the exact drift the generator exists to prevent, and it only stayed hidden because nothing regenerates on merge. Http3/Managed and Http3/Streamed had no pane at all, having been added the same evening they were written. All 40 samples are generated now. The only hand-written panes left are `vs` and `pxmatrix`, which are prose comparisons with no sample to derive from. Verified: tabs, labels, panes and CSS agree; both generators are idempotent; the new panes compile standalone against the real packages.
MDA2AV
added a commit
that referenced
this pull request
Aug 10, 2026
* bench: register the HTTP/3 samples so their numbers survive Http3/Managed and Http3/Streamed were benchmarked all evening through throwaway shell loops, which is how a body-size mismatch and two h3x flag mistakes got into the record before being caught. Neither had a baseline, so nothing would have noticed them regressing. Both are in bench/samples.tsv now, which asserts full coverage of the Playground - 40 samples, 40 registered - so a sample nobody can benchmark shows up as a gap rather than as silence. First run, 2 reactors, 64 conns, 10s: Http3/Nghttp3 480371 req/s 4.17us +0.9% vs baseline Http3/Buffered 516707 req/s 3.87us +3.2% Http3/Managed 665936 req/s 2.96us (new) Http3/Streamed 89906 req/s 22.03us (new) The two pre-existing samples are unchanged within noise, which is the point of having them there. * docs(examples): every sample is on the site again, generated Three panes had drifted or were missing. Http3/Nghttp3's pane went stale when #171 gave that sample a body knob - the exact drift the generator exists to prevent, and it only stayed hidden because nothing regenerates on merge. Http3/Managed and Http3/Streamed had no pane at all, having been added the same evening they were written. All 40 samples are generated now. The only hand-written panes left are `vs` and `pxmatrix`, which are prose comparisons with no sample to derive from. Verified: tabs, labels, panes and CSS agree; both generators are idempotent; the new panes compile standalone against the real packages.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ioxide.http3lost toioxide.nghttp3on small responses and won on large ones, so the gap was a fixed per-request cost. Profiling it meant finding what every response repeats regardless of body size.Two suspects, one answer
Caching the encoded head — QPACK field section, content-length and frame headers, on a response object a hot handler reuses for every request — was worth 2.8% (371,250 → 381,480 req/s). Kept, but not the cause.
The cause was two
SendStreamcalls per response: one for the ~40-byte header frame, one for the body. Every short response made two trips through the QUIC send path. nghttp3 never did — itswritevhands back one buffer carrying both frames, and the shim sends it once. Copying a small body in behind the head to make a single call is far cheaper than the second call.Result
That was the only size it lost at, and it is now the size it wins by most:
Bodies over 4 KiB still send separately — copying them would cost more than the second call saves. That threshold is a reasoned guess, not a swept optimum.
Also here
Playground/Http3/Managed— the pure-C# sample these numbers were measured with. The package shipped with no sample at all, only an E2E test, which is why it had never been benchmarked against its native counterpart.Http3/Nghttp3gains thebodyBytesknob every other sample of its family already had, so the two can be compared at the same size instead of 13 bytes against 8 KiB.Verification
Solution builds; Unit 29, E2E 46, Http 35, Tls 15, File 4, Chaos 37 pass. All benchmark cells reported zero failed requests.