Skip to content

perf(engine): Write the HTTP/1.1 response head in a single pass - #974

Merged
Kaliumhexacyanoferrat merged 2 commits into
mainfrom
perf/response-head-single-write
Nov 4, 2026
Merged

Kaliumhexacyanoferrat merged 2 commits into
mainfrom
perf/response-head-single-write

Conversation

@Kaliumhexacyanoferrat

Copy link
Copy Markdown
Owner

Summary

ResponseSerializer.WriteHead issued 15–25 small writer.Write calls per response, each with its own GetSpan/Advance pair on the PipeWriter. It now adds up the head size first, takes one span and copies the status line, Server/Date, connection, content and custom headers into it with a single Advance.

  • The content properties (Type, Length, Encoding) are read once.
  • Content-Length is formatted in place (up to 20 digits reserved).
  • BufferWriterExtensions.Write(ulong) had no callers left, so it is removed (internal).

Affects HTTP/1.1 responses on the Internal and Ioxide engines. Kestrel writes its own headers and is unchanged. Output bytes are identical, so users will not notice any behavior change.

Benchmarks

Raspberry Pi 4 (Cortex-A72, arm64, no LSE atomics), .NET 11 rc2, in-process BenchmarkDotNet, middleware-only (BenchmarkContext). Means over 5–6 runs of main and 4 runs of this branch, interleaved. Run-to-run noise on the Pi is about ±5%.

Benchmark main this PR Δ
SimpleBenchmark (Hello World) 2.57 µs 2.21 µs −14%
Realistic (13 browser request headers, 4 extra response headers)* 6.87 µs 6.36 µs −7%
Chunked response (16 × 300 B writes)* 4.95 µs 4.68 µs −5.5%
Webservices BaselineBenchmark 9.53–9.83 µs 9.40 µs ~−2% (within noise)

Allocations are unchanged (0 B for the first three, 1.01 KB for the webservice).

* These were ad-hoc benchmarks for this measurement and are not part of this PR. I can add them to Benchmarks/ if they are useful.

Tests

  • New HeaderTests.TestLargeHeadersAreWritten (multi-engine): a 20 KB header, larger than one pipe segment, plus a small header and a body all arrive intact.
  • dotnet build GenHTTP.slnx -c Release passes.
  • Acceptance suite (net11.0) on the Pi: Internal passes. Kestrel passes except TestHttp2And3, which also fails on main here (no msquic on the Pi). Ioxide could not run here because io_uring hits the Pi's 8 MB RLIMIT_MEMLOCK (the test host crashes in Ring.Create/pbuf_ring), so CI will verify Ioxide.

Also tried and dropped

Other ideas from the arm64 list were measured on the same Pi but are not included:

  • ByteString hash with wide tail reads + Ascii.EqualsIgnoreCase: 26–39% faster in isolation, but about 6% slower end-to-end on the realistic request in every run.
  • Table-based hex for chunk headers: 28% faster in isolation, no measurable end-to-end effect.
  • Atomics: the request path has essentially none (the context pool is per connection), so there was nothing to reduce.

🤖 Generated with Claude Code

https://claude.ai/code/session_016Z4d2PxGuaT75fzUxMqgyN

@Kaliumhexacyanoferrat

Copy link
Copy Markdown
Owner Author

x64 benchmarks

The gain holds on x64 as well. The PR was faster than the baseline on all four benchmarks in every round.

Setup: Intel i7-13800H (Windows 11, x64), .NET 11 rc3, net11.0, in-process BenchmarkDotNet 0.15.8, middleware-only (BenchmarkContext). The baseline is the PR's parent 4a67a98c, so the Glyph11 update on main does not affect the comparison. The three variants were run alternately, round by round, because this laptop (hybrid P/E cores) drifts by ±10–15% between launches.

Mean of 4 rounds:

Benchmark Baseline 41a6f20 dc84091 Δ vs. baseline
SimpleBenchmark (Hello World) 292 ns 243 ns 244 ns −17%
Realistic (13 browser request headers, 4 extra response headers)* 927 ns 727 ns 730 ns −21%
Chunked response (16 × 300 B writes)* 679 ns 617 ns 621 ns −9%
Webservices BaselineBenchmark 444 ns 406 ns 410 ns −8%

Change per round, 41a6f20c vs. baseline:

Benchmark R1 R2 R3 R4
Simple −14% −12% −16% −25%
Realistic −12% −23% −22% −28%
Chunked −4% −9% −9% −15%
Webservices −4% −12% −13% −4%

An earlier pass of 3 rounds (baseline vs. 41a6f20c only) showed the same direction: Simple −19%, Realistic −21%, Chunked −8%, Webservices −8%. That makes 7 out of 7 rounds faster for every benchmark.

Allocations are unchanged (0 B for the first three, 24 B for the webservice).

dc840916 checks for Server and Date in the loop that adds up the header size, instead of two extra ContainsKey passes, and reserves the exact number of digits for Content-Length. It is within ±1% of 41a6f20c, which is noise. It does not change performance; it only simplifies the code.

* These are my own versions of the ad-hoc benchmarks described above, not the exact code used on the Pi. They are not part of this PR.

Tests: dotnet build GenHTTP.slnx -c Release passes. The engine acceptance tests (GenHTTP.Testing.Acceptance.Engine) pass on net10.0 and net11.0, with Internal and Kestrel (Ioxide is skipped on Windows).

🤖 Generated with Claude Code

@Kaliumhexacyanoferrat Kaliumhexacyanoferrat self-assigned this Nov 4, 2026
@Kaliumhexacyanoferrat Kaliumhexacyanoferrat added the enhancement New feature or request label Nov 4, 2026
@Kaliumhexacyanoferrat Kaliumhexacyanoferrat added this to the Version 11.3 milestone Nov 4, 2026
Kaliumhexacyanoferrat and others added 2 commits November 4, 2026 14:53
ResponseSerializer issued 15-25 small writes per response, each with its
own GetSpan/Advance pair on the PipeWriter. The head size is now computed
up front and everything is copied into one span with a single Advance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z4d2PxGuaT75fzUxMqgyN
The custom headers are now scanned once to compute their size and to find
Server or Date, instead of two extra ContainsKey passes. The span requested
for the head uses the exact Content-Length digit count. Also documents
ShouldSendBody.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Kaliumhexacyanoferrat
Kaliumhexacyanoferrat force-pushed the perf/response-head-single-write branch from dc84091 to 1999799 Compare November 4, 2026 13:53
@Kaliumhexacyanoferrat
Kaliumhexacyanoferrat merged commit d0160b7 into main Nov 4, 2026
9 checks passed
@Kaliumhexacyanoferrat
Kaliumhexacyanoferrat deleted the perf/response-head-single-write branch November 4, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant