Skip to content

nghttp2: a connection reuses a finished request's objects for its next stream, so a warm request allocates none of the parser's - #491

Open
MDA2AV wants to merge 4 commits into
mainfrom
perf/nghttp2-request-reuse
Open

MDA2AV wants to merge 4 commits into
mainfrom
perf/nghttp2-request-reuse

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

  • Every stream got a new PendingRequest, Nghttp2Request, header KeyValueList with its array, and a field-range List with its array: about 650 B per request.
  • Every dispatch pass copied the ready list into an array.

The fix

The same approach #486 takes for ioxide.http2:

  • A request whose body arrived whole has left _pending by the time it is dispatched (at CallbackEndStream). Once its handler is done, Retire disposes it and keeps it on the connection (_pendingPool) with its Nghttp2Request, header list and field ranges. CallbackBeginHeaders takes one from there for the next stream.
    • It retires at the three places that disposed it before: the synchronous buffered dispatch, CompleteAsync and ServeStreamedAsync.
  • A request whose body is streamed stays in _pending after its handler returns, until its stream ends, because later DATA and trailers still find it there. It is only disposed, as before. Pooling it at retire could hand a stream that is still live to a new one.
  • Freeze fills the request's own Nghttp2Request instead of a new one.
  • The pool is bounded:
    • it keeps at most 32;
    • never one whose field list grew past 64 (nghttp2 does not limit a request's header count);
    • and only on the reactor, the one thread that rents.
  • Reset lets go of the request's memories into the arena that went back to ArrayPool, so the pool pins nothing.
  • The ready list is a Queue that a dispatch drains, instead of a List copied to an array each pass.

Behaviour

  • Nghttp2Request is reused for a later stream on the same connection once its handler is done, when its body arrived whole.
    • It was already only valid during the handler: its memories point into an arena that goes back to the pool at retire.
    • The doc comment says so now.
  • Unchanged for a streamed body's request.

API

None public.

Tests

New in the Unit suite (Nghttp2RequestReuseTests.cs), over the same strict test transport the managed stack's tests use:

  • nghttp2 request: the next stream's request holds nothing of the one before
    • A POST with an authority, two headers and a body, then a GET that sends only a host. nghttp2 needs one or the other, and with no :authority a stale one would show.
    • The GET gets the same request object, and it carries none of the POST's fields.
  • nghttp2 request: a parked handler's request goes back once it answers, holding nothing
    • This is the CompleteAsync path. Once back, the request's memories are empty, so the pool pins no arena.
  • nghttp2 request: a streamed response's request goes back once its handler is done
    • The ServeStreamedAsync path.
  • nghttp2 request: a request a peer sent 65 header lines is not kept for the next stream
    • Its field list grew past 64 entries, so the next stream gets a fresh request; a small one after it is kept.
  • nghttp2 request: a connection keeps at most 32 requests for later streams
    • 40 handlers park at once and answer one by one; the pool, read by reflection, holds 32.
  • nghttp2 request: a request whose body is still arriving is not handed to the next stream
    • A body-reader handler returns before its stream ends.
    • With Retire's BodyReader guard removed, this fails: stream 3's headers land on stream 1's request as trailers, and stream 3 is never dispatched.
  • nghttp2 request: a warm connection's request allocates none of the parser's objects
    • It sends 16 requests with one header each, after two warm-up passes, and counts that all 16 handlers ran inside the window.
    • It allows under 200 B per request; what is left is the test transport's own flush.

Against main's ioxide.nghttp2 (the tests only):

FAIL  nghttp2 request: the next stream's request holds nothing of the one before: the second stream reuses the first one's request
FAIL  nghttp2 request: a warm connection's request allocates none of the parser's objects: a request on a warm connection allocated 694 B
145 passed, 2 failed, 0 skipped

(The first version of the tests. The tests that check reuse fail there the same way, and the ones that check what is not kept pass, since main never pools.)

Each later test was also run against a mutation of the code it guards and failed there: Retire turned back into Dispose on the streamed path, the IsSmall check removed, the cap removed, and a retired request keeping its memories.

With the fix:

  • Unit: 152 passed.
  • E2E: 453 passed.
  • Chaos: 83 passed, including the nghttp2 twins.
  • Http: 95 passed.

Review

An 8-agent review of this PR and its siblings found the reuse sound: no pooled request is reachable, and each is retired once. It found the following, fixed in 87f5da2:

  • Pinned arenas: pooled requests kept their arenas alive.
  • Pool bounds: the pool was unbounded, and could keep lists a peer grew to megabytes.
  • Test gaps: the tests never covered the streamed-body guard or the async retire path, and could not catch a stale :authority.

It also found an older bug, already on main: in buffered mode the shim never forwards a peer's RST_STREAM(NO_ERROR), so that request leaks until the connection closes. That one is filed as #493.

A later round found that the comment on the in-place walk was wrong, because a drain's on_stream_close runs during it. That was harmless, as it never touches the list. The Queue needs no such claim (df9298f).

Bench

Re-measured on the final code, with the same setup, 6 more rounds:

  • Nghttp2Buffered: -6.0% server CPU per request (control 0.0%), +5.4% req/s. Ranges: 892-1,083 ns on base, 850-967 ns on the branch.
  • Nghttp2Response: -3.8% server CPU per request (control +2.7%), +3.9% req/s, which is close to the noise.
  • Allocations: 89 B per request on Nghttp2Buffered (base 750 B), and 466 B on Nghttp2Response (base 1,130 B).
  • That is less than the first run's -9.0% below, with the pool's review changes and the Queue in between. The same Queue shows no cost on the managed stack (http2: a streamed flush that waits for the write pump allocates nothing, and a connection reuses its request objects #486).

The tables below are the first run.

Setup: the same as #486.

  • One reactor pinned to CPU 6, and h2load -c 32 -m 16 -t 1 (with --alpn-list=h3 for HTTP/3) pinned to CPU 2, on separate physical cores.
  • Three builds: main (base), this branch, and main again from a second worktree as a control.
  • Six rounds of 10 s, each after a 3 s warm-up, with the order rotating every round.
  • Each build is compared with base inside its own round; the tables show the median across rounds.
  • Streamed samples send 8 chunks of 1 KiB per response.
Sample Metric base branch control vs base branch vs base
Http2/Nghttp2Buffered server CPU per request 908 ns 829 ns -0.1% -9.0%
req/s (server at 100%) 1.10M 1.20M +0.1% +9.7%
Http2/Nghttp2Response server CPU per request 3.01 µs 2.86 µs +3.6% -5.4%
req/s 332K 349K -3.4% +5.7%
  • Buffered: the per-round ranges do not overlap: 898-960 ns on base, 818-868 ns on the branch.
  • Streamed: the control moved 3.6% on this sample, so its -5.4% is close to the noise.

Allocations, from dotnet-counters over 20 s under the same load:

base branch control
Nghttp2Buffered, B per request 762 88 766
Nghttp2Response, B per request 1,138 465 1,138
gen0 GCs/s, buffered / streamed 48 / 22 6 / 8 48 / 22

What is left:

  • On both samples, mostly the sample handlers' own responses, with their header lists and chunk (app code).
  • On the streamed sample, about 19 B of flush boxes per request.

Fixes #489.

…t stream, so a warm request allocates none of the parser's

Every stream got a new PendingRequest, Nghttp2Request, header KeyValueList and array, and a
field-range List with its array: about 650 B per request. A request whose body arrived whole has
left _pending by the time it is dispatched, so once its handler is done it goes back to the
connection with all of them, and the next stream reuses it. A streamed body's request stays in
_pending until its stream ends, so it is only disposed, as before. A dispatch swaps two ready
lists instead of copying one.
…of the arena; tests cover every retire path

From review:
- A retired request still pointed into the arena Dispose had just returned to ArrayPool, so a pooled
  entry could pin it (with a body, up to 16 MiB). Reset now lets go of the request's memories and
  headers.
- The pool keeps at most 32, never one whose field list grew past 64 (a peer's thousands of header
  lines; nghttp2 does not limit their number), and only on the reactor, the one thread that rents.
- Tests: the reuse test sends a host instead of :authority, so a stale authority would show; a
  parked handler's request goes back once it answers (CompleteAsync); a request whose body is still
  arriving is never handed on (fails with the BodyReader guard removed: stream 3 is never
  dispatched); the warm-request test warms both ready lists and counts its handlers.
…r the streamed path, the size check and the cap

From review: the second ready list guarded against something that cannot happen. Only nghttp2's
callbacks add to it, and they run inside ih2_read, which never runs during the walk. The walk now
goes over _readyThisPass by index and clears it after; _dispatching and TakeReady are gone.

Three tests close the gaps that let a retire path or a bound regress unseen:
- a streamed response's request goes back once its handler is done (ServeStreamedAsync);
- a request with 65 header lines is not kept, and a small one after it is;
- 40 parked handlers leave 32 requests in the pool.
Each fails against a mutation of the code it guards. Comments are cut to one line.
…es a retired request let go of its arena

No wrapper and no claim about what can add to the list during a walk: a drain's on_stream_close runs inside it, and a queue doesn't care.

This branch has not been deployed

No deployments
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.

nghttp2: a request allocates about 650 B of parser objects that could be reused per connection

1 participant