Skip to content

Avoid allocating the Content-Length string when writing HTTP/2 and HTTP/3 response headers - #70117

Open
davidfowl wants to merge 4 commits into
mainfrom
perf/h2-h3-content-length-no-alloc
Open

davidfowl wants to merge 4 commits into
mainfrom
perf/h2-h3-content-length-no-alloc

Conversation

@davidfowl

@davidfowl davidfowl commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Motivation

When Kestrel writes HTTP/2 or HTTP/3 response headers, HttpResponseHeaders.Enumerator formats ContentLength (a long) into a string, and the HPACK/QPACK writers then encode that string. The runtime only caches number strings below 300, so every response with Content-Length >= 300 allocates a string just to copy its digits into the output buffer.

Change

  • HttpResponseHeaders.GetEnumeratorWithRawContentLength(): an enumerator mode, used only for response headers (not trailers), that does not format Content-Length. The generator (KnownHeaders.cs) and HttpHeaders.Generated.cs are updated.
  • Http2HeadersEnumerator / Http3HeadersEnumerator:
    • Use the raw mode and expose TryGetCurrentContentLength(out long).
    • Current still returns the formatted string, created lazily, so other consumers are unaffected.
  • HPackHeaderWriter: when the current header is Content-Length and the default encoding selector is in use, it writes a literal header field without indexing (static name index 28) followed by the ASCII digits, using Utf8Formatter. This matches the bytes produced today, because Content-Length is never added to the dynamic table.
  • QPackHeaderWriter:
    • Value 0 → static index 4 (content-length: 0).
    • Any other value → literal with static name reference 4, then the digits.
    • totalHeaderSize accounting is unchanged.
  • A custom ResponseHeaderEncodingSelector falls back to the existing string path.

Benchmarks

Http2FrameWriterBenchmark.WriteResponseHeaders from src/Servers/Kestrel/perf/Microbenchmarks, with MemoryDiagnoser.

Environment:

  • BenchmarkDotNet --config perflab (InProcess), --warmupCount 5 --iterationCount 15.
  • AMD EPYC 9V74 with 2 vCPU (container), Ubuntu 24.04.
  • .NET 12.0.0 (12.0.26.52911), SDK 12.0.100-alpha.1.26521.103.
  • Two independent before/after rounds. This is a shared, small VM, so expect some noise.
LargeHeaderSize main run 1 main run 2 PR run 1 PR run 2 Allocated main → PR
0 139.9 ± 2.35 ns 140.4 ± 2.31 ns 124.5 ± 2.20 ns 123.5 ± 0.58 ns 32 B → 0 B
10 629.1 ± 52.5 ns 550.7 ± 3.75 ns 599.4 ± 51.9 ns 596.6 ± 42.0 ns 32 B → 0 B
20 1,342.1 ± 21.3 ns 1,397.1 ± 106.8 ns 1,314.3 ± 5.8 ns 1,315.5 ± 7.3 ns 32 B → 0 B
  • LargeHeaderSize=0: about 12% faster, reproduced in both rounds.
  • LargeHeaderSize=10 and 20: within noise.
  • Allocation: the 32 B per response on main is gone.

Tests

  • New tests check that the fast path writes exactly the same bytes as the existing string path:
    • Http2HPackEncoderTests.BeginEncodeHeaders_ContentLength_MatchesStringEncoding and Http3QPackEncoderTests.BeginEncodeHeaders_ContentLength_MatchesStringEncoding, for values 0, 9, 299, 300, 1024 and long.MaxValue.
    • *_BufferTooSmall_MatchesStringEncoding: for buffer sizes 1–31, the fast and string paths give the same result, the same bytes and the same remaining headers.
  • Microsoft.AspNetCore.Server.Kestrel.Core.Tests: 9925 passed, 13 failed, 1 skipped. The 13 failures are AddressBinderTests / KestrelServerTests IPv6 tests that fail identically on main, because the test machine has no IPv6.
  • InMemory.FunctionalTests (filter Http2|Http3): 775 passed, 0 failed, 5 skipped.

Benchmark (3 interleaved rounds)

  • Http2FrameWriterBenchmark.WriteResponseHeaders: existing; H2 path with Content-Length 1024.
  • QPackHeaderWriterBenchmark.BeginEncodeHeaders: new in this PR, commit 5442cf0, under src/Servers/Kestrel/perf/Microbenchmarks/Http3. It covers the H3/QPACK path with Content-Length 0 and 1024, which no existing benchmark exercised directly.
  • HPackHeaderWriterBenchmark: existing control; it sets Content-Length 0.

The same benchmark source was compiled into the main build.

Environment: AMD EPYC 9V74, 2 vCPU (container), Ubuntu 24.04; .NET SDK 12.0.100-alpha.1.26521.103, .NET 12.0.0 (12.0.26.52911); BenchmarkDotNet v0.13.0 --config perflab (InProcess, MemoryDiagnoser), WarmupCount=3, IterationCount=10. "main" is the merge base 4f556f73a6. Both builds use the same benchmark source, and runs were interleaved main, PR, main, PR, main, PR. Each cell shows mean / allocated per operation; allocation figures are exact bytes from the CSV exporter.

Case main r1 PR r1 main r2 PR r2 main r3 PR r3 Δ time (median) Alloc main → PR
QPackHeaderWriterBenchmark.BeginEncodeHeaders [ContentLength=0] 59.9 ns / 0 B 61.4 ns / 0 B 62.2 ns / 0 B 60.0 ns / 0 B 60.6 ns / 0 B 59.8 ns / 0 B -0.9% 0 B → 0 B
QPackHeaderWriterBenchmark.BeginEncodeHeaders [ContentLength=1024] 81.6 ns / 32 B 70.3 ns / 0 B 83.2 ns / 32 B 69.8 ns / 0 B 83.1 ns / 32 B 69.7 ns / 0 B -16.1% 32 B → 0 B
Http2FrameWriterBenchmark.WriteResponseHeaders [LargeHeaderSize=0] 137.4 ns / 32 B 123.0 ns / 0 B 139.8 ns / 32 B 121.9 ns / 0 B 138.3 ns / 32 B 124.8 ns / 0 B -11.1% 32 B → 0 B
Http2FrameWriterBenchmark.WriteResponseHeaders [LargeHeaderSize=10] 604.5 ns / 32 B 584.3 ns / 0 B 583.5 ns / 32 B 546.3 ns / 0 B 566.4 ns / 32 B 561.1 ns / 0 B -3.8% 32 B → 0 B
Http2FrameWriterBenchmark.WriteResponseHeaders [LargeHeaderSize=20] 1,257 ns / 32 B 1,278 ns / 0 B 1,412 ns / 32 B 1,425 ns / 0 B 1,362 ns / 32 B 1,309 ns / 0 B -3.9% 32 B → 0 B
HPackHeaderWriterBenchmark.BeginEncodeHeaders_KnownHeaders 415.1 ns / 0 B 414.8 ns / 0 B 421.7 ns / 0 B 419.4 ns / 0 B 415.4 ns / 0 B 426.2 ns / 0 B +1.0% 0 B → 0 B
HPackHeaderWriterBenchmark.BeginEncodeHeaders_KnownHeaders_CustomEncoding 515.2 ns / 0 B 518.6 ns / 0 B 496.8 ns / 0 B 509.3 ns / 0 B 492.8 ns / 0 B 513.7 ns / 0 B +3.4% 0 B → 0 B
HPackHeaderWriterBenchmark.BeginEncodeHeaders_UnknownHeaders 414.3 ns / 0 B 426.1 ns / 0 B 415.3 ns / 0 B 430.6 ns / 0 B 411.1 ns / 0 B 425.3 ns / 0 B +2.8% 0 B → 0 B
HPackHeaderWriterBenchmark.BeginEncodeHeaders_UnknownHeaders_CustomEncoding 414.0 ns / 0 B 424.5 ns / 0 B 417.4 ns / 0 B 430.5 ns / 0 B 414.2 ns / 0 B 429.2 ns / 0 B +3.6% 0 B → 0 B
  • Content-Length ≥ 300 (the case this PR targets): the 32 B string allocation is gone in every round. H2 LargeHeaderSize=0 is 11% faster and QPACK ContentLength=1024 is 16% faster, with no overlap between main and PR. The LargeHeaderSize=10/20 cases are dominated by the large custom header, and their time difference (about −4%) is within noise.
  • QPACK ContentLength=0 (static table entry) is unchanged.
  • HPackHeaderWriterBenchmark (Content-Length 0, which main serves from a cached "0" string): the UnknownHeaders variants are about 3% slower in the PR in all 3 rounds (411–417 ns vs 424–431 ns). The KnownHeaders variants range from −0.5% to +4% per round. Zero bytes are allocated on both sides. A plausible cause is the extra per-header Content-Length check and formatting the digits instead of reusing the cached string, but I haven't verified that.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

… response headers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 30, 2026 16:53
@github-actions github-actions Bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label Oct 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The HTTP/2 and HTTP/3 custom-encoding fallback paths repeatedly format Content-Length on each Current read, increasing per-response string allocations rather than reusing a cached header pair.

2 open issues
What changed in this PR

This PR reduces Kestrel’s HTTP/2 and HTTP/3 response-header allocations by encoding Content-Length directly from its numeric value rather than creating an intermediate string.

Changes:

  • Adds raw Content-Length enumeration with on-demand string formatting.
  • Adds HPACK and QPACK numeric encoding paths while retaining the string path for custom encoding selectors.
  • Adds byte-equivalence and small-buffer tests for both protocols.
File Description
src/​Servers/​Kestrel/​shared/​KnownHeaders.cs Updates the generator to support raw Content-Length enumeration.
src/​Servers/​Kestrel/​shared/​Http2HeadersEnumerator.cs Exposes numeric Content-Length to the HTTP/2 writer.
src/​Servers/​Kestrel/​shared/​HPackHeaderWriter.cs Encodes Content-Length digits without an intermediate string.
src/​Servers/​Kestrel/​Core/​test/​Http3/​Http3QPackEncoderTests.cs Tests QPACK byte equivalence and small-buffer behavior.
src/​Servers/​Kestrel/​Core/​test/​Http2/​Http2HPackEncoderTests.cs Tests HPACK byte equivalence and small-buffer behavior.
src/​Servers/​Kestrel/​Core/​src/​Internal/​Http3/​QPackHeaderWriter.cs Adds numeric Content-Length encoding and preserves size accounting.
src/​Servers/​Kestrel/​Core/​src/​Internal/​Http3/​Http3HeadersEnumerator.cs Exposes numeric Content-Length to the HTTP/3 writer.
src/​Servers/​Kestrel/​Core/​src/​Internal/​Http/​HttpResponseHeaders.cs Adds an internal raw Content-Length enumeration mode.
src/​Servers/​Kestrel/​Core/​src/​Internal/​Http/​HttpHeaders.Generated.cs Updates generated enumeration to conditionally format Content-Length.

Review effort: Balanced

With a custom ResponseHeaderEncodingSelector the HPACK/QPACK writers fall
back to reading Current multiple times; format the value once and reuse it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen

PureWeen commented Nov 1, 2026

Copy link
Copy Markdown
Member

/review

davidfowl and others added 2 commits November 2, 2026 23:04
Covers the HTTP/3 Content-Length encoding path, which the existing
Kestrel microbenchmarks don't exercise directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Retriggering CI once (empty commit e87e555) after what looks like a Helix infrastructure failure.

  • In build 1660826, aspnetcore-ci (Helix tests Monitor Helix Jobs) failed, but no test failed: 0 failed and 85,409 passed out of 86,464.
  • The failure came from a Helix work item or infrastructure problem that produced no test result. Build Analysis found no known-issue match.
  • At the PR head (5442cf0), the local Kestrel Core tests pass, including the HPACK/QPACK encoder and headers enumerator tests. The local in-memory functional tests also pass, including HTTP/2 and HTTP/3 (1,662 passed, 0 failed).
  • I'm retriggering CI once. The PR's file list is unchanged.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Copy link
Copy Markdown
Member Author

The aspnetcore-ci failure on e87e555 (build 1660960) is a known HTTP/3 flake and is not caused by this PR.

Failing test: Interop.FunctionalTests.Http3.Http3RequestTests.ConnectionLifetimeNotificationFeature_RequestClose_ConnectionEnds on Ubuntu.2404.Amd64.Open. Build Analysis gives a 1.07% historical failure rate.
System.Exception : Wait for logs failure: Check for initial GOAWAY frame sent on server initiated shutdown. (Http3RequestTests.cs:1957)

Why it's unrelated to this diff

Local runs at e87e555 (Linux x64)

  • Kestrel Core HPACK/QPACK encoder, headers-enumerator, response-headers and frame-writer tests: 199/199 passed.
  • In-memory GOAWAY, connection-lifetime and RequestClose tests (HTTP/2 + HTTP/3): 20/20 runs passed, 18/18 tests in each.
  • In-memory HTTP/2 and HTTP/3 functional suites: 759/759 passed.
  • I couldn't reproduce the Interop test itself: this machine has no IPv6, so QuicListener.IsSupported is false and the test is skipped.

E2E legs: The failures are Blazor-only tests. ServerRoutingTest.NavigationLock_CanBlockExternalNavigation has a 31.36% failure rate and VirtualizationTest.VariableHeight_HtmlTable_DoesNotProduceNestedTrElements has 13.64%. The Mono server and Mono client/WASM legs also fail intermittently on recent main commits.

I won't retrigger again. A maintainer can rerun the failed jobs.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

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

area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants