Skip to content

perf: do header clone, augment and sort once in http2 encodeHeaders - #20

Open
burruplambert wants to merge 2 commits into
bogdanfinn:masterfrom
burruplambert:perf/h2-encode-headers-single-pass
Open

perf: do header clone, augment and sort once in http2 encodeHeaders#20
burruplambert wants to merge 2 commits into
bogdanfinn:masterfrom
burruplambert:perf/h2-encode-headers-single-pass

Conversation

@burruplambert

@burruplambert burruplambert commented Aug 1, 2026

Copy link
Copy Markdown

Depends on #18

This branch carries #18's commit as well as its own. The ordering test added here needs it: without that reset, a sorter returned to the pool by an earlier ordered write still has its order map set, and a later orderless sort silently uses it. That is observable in the test suite - run TestHeaderOrder and then encode a request with no ordering keys, and accept-encoding sorts to the front because it happens to appear in the earlier test's Header-Order: list.

Merge #18 first and this rebases to a single commit, or merge this and #18 becomes redundant.

Summary

(*ClientConn).encodeHeaders enumerates the request headers twice - once to count the header list size against peerMaxHeaderListSize, once to write them. The enumerateHeaders closure cloned req.Header, added content-length and accept-encoding, built the Header-Order: index map and ran the full sort on every call, so all of that work ran twice per request.

This hoists the clone, augment and sort out of the closure so they run once, and both passes iterate the precomputed result. It also passes nil instead of an empty exclude map to SortedKeyValues/SortedKeyValuesBy - the map was allocated per request and only ever read.

The clone stays: it is what keeps the added headers off the caller's req.Header, which is why it was introduced in 31b4c55. It just no longer happens twice.

Tests

TestClientConnEncodeHeadersOrder pins the exact emitted header sequence across the combinations of the two ordering keys being present or absent:

  • neither ordering key and no Transport.PseudoHeaderOrder (no pseudo-headers are emitted at all on this path; pre-existing behavior, see the note below)
  • neither ordering key, with the order supplied by the Transport
  • PHeader-Order: only
  • Header-Order: only, with some keys absent from the list
  • both keys, with cookie splitting and content-length
  • no ordering keys, covering the connection-specific and Host filtering, an empty value slice, and a multi-value User-Agent

It passes unchanged against the base, which is the point - it pins existing behavior rather than describing the new code.

Measurements

BenchmarkClientConnEncodeHeaders measures header encoding directly. Against the base:

time/op B/op allocs/op
Ordered (14 headers plus both order keys) -43% -53% 46 -> 22
Unordered (8 headers) -38% -48% 22 -> 15

In production profiling of a header-heavy client, encodeHeaders fell from 10.7% to 7.1% of total CPU.

Note on the existing benchmark

BenchmarkClientRequestHeaders cannot be used to measure this, because it fails on master: a round trip with no PHeader-Order: header and no Transport.PseudoHeaderOrder emits no pseudo-headers at all and the server rejects the stream with PROTOCOL_ERROR. TestTransportRequestPathPseudo panics on master in the same area (cc.t is nil). Both reproduce identically without this change and are not touched here, since fixing them changes wire behavior and belongs in its own PR.

headerSorter instances are pooled and shared between SortedKeyValuesBy
and SortedKeyValues, but SortedKeyValues never cleared hs.order. A
sorter previously used for an ordered sort would keep its order map and
apply it to a later orderless sort, producing the wrong header order
whenever the new header's lowercased keys collide with entries in the
stale order map.

Reset hs.order before sorting in SortedKeyValues and add a regression
test.
@burruplambert
burruplambert force-pushed the perf/h2-encode-headers-single-pass branch from 4ea3b00 to 385022a Compare August 2, 2026 13:18
encodeHeaders enumerates the request headers twice - once to count the
header list size against peerMaxHeaderListSize, once to write them. The
enumerate closure cloned req.Header, added content-length and
accept-encoding, built the Header-Order: index map and ran the full
sort on every call, so all of that work ran twice per request.

Hoist the clone/augment/sort out of the closure so it runs once and
both passes iterate the precomputed result. Also pass nil instead of an
empty exclude map to SortedKeyValues/SortedKeyValuesBy - the map was
allocated per request and only ever read.

The emitted header sequence is unchanged, including the existing
behavior that the magic Header-Order:/PHeader-Order: entries are
counted by the size pass and skipped by the write pass.

Mirrored in h2_bundle.go. Adds BenchmarkClientConnEncodeHeaders, which
measures header encoding directly; the existing
BenchmarkClientRequestHeaders cannot run here because a round trip
without a configured pseudo-header order fails (pre-existing issue).
@burruplambert
burruplambert force-pushed the perf/h2-encode-headers-single-pass branch from 385022a to 985792c Compare August 2, 2026 13:21
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