Skip to content

perf(http3): one send per response - pure C# h3 now beats nghttp3 at every size - #171

Merged
MDA2AV merged 1 commit into
mainfrom
feat/http3-perf
Aug 9, 2026
Merged

perf(http3): one send per response - pure C# h3 now beats nghttp3 at every size#171
MDA2AV merged 1 commit into
mainfrom
feat/http3-perf

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 9, 2026

Copy link
Copy Markdown
Owner

ioxide.http3 lost to ioxide.nghttp3 on 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 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, 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.

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

Result

That was the only size it lost at, and it is now the size it wins by most:

body nghttp3 pure C#
13 B 580,919 897,217 1.54× (was 0.64×)
50 KiB 38,738 49,727 1.28×
1 MiB 1,986 2,612 1.32×

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/Nghttp3 gains the bodyBytes knob 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.

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
MDA2AV merged commit 96137b9 into main Aug 9, 2026
1 check passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant