http3: encode response headers against the QPACK static table - #187
Merged
Conversation
The encoder consulted the static table for :status alone. Every other field went
out as Literal With Literal Name, spelling out a name the peer already holds by
index - content-type cost 12 bytes on the wire on every response.
Three tiers now, cheapest first: an entry matching name and value is one byte, a
known name is an index plus the literal value, and only an unknown name is
spelled out. Names match case-insensitively, values exactly, since a field value
is case-sensitive.
The lookup is bucketed by name length, which matters more than it looks. A plain
linear scan of the 50-odd distinct names cost 6% of throughput at this request
rate - more than the bytes it saved. Bucketed, it compares one or two candidates
and the byte saving turns into a gain:
literal names 3,087,560 / 3,123,721 / 3,171,774 req/s
static, linear scan 2,771,482 / 2,941,583 / 2,993,596 req/s
static, bucketed 3,291,610 / 3,316,463 / 3,367,788 req/s
h3x, 8 connections x 100 streams, buffered playground.
Note the value ioxide sends is "text/plain; charset=utf-8" with a space, which is
not static entry 54 ("text/plain;charset=utf-8"), so it takes the name reference
rather than the one-byte index. Dropping the space would make it an exact hit,
but that changes what goes on the wire and is left alone here.
The unit tests reach Qpack through InternalsVisibleTo rather than by widening the
public surface. They verify by decoding what the encoder produces: a wrong index
still yields a well-formed section, so only a round trip catches it naming the
wrong header.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Qpack.EncodeResponseFieldsconsulted the static table for:statusalone. Every other field went out as Literal With Literal Name, spelling out a name the peer already holds by index —content-typecost 12 bytes on the wire on every response, forever.Encoding is now three tiers, cheapest first:
0xC0)0x50)0x20)Names match case-insensitively (so a caller holding conventional capitalisation resolves, and the name is never written); values match exactly, since a field value is case-sensitive.
The lookup has to be cheap, or it costs more than it saves
Worth calling out, because the first version was a regression. A plain linear scan over the ~50 distinct static names, per header, cost 6% of throughput — more than the bytes it saved. Bucketing by name length narrows it to one or two comparisons, and the saving flips to a gain:
h3x -t 8 --connections 8 -m 100, buffered playground. Median 3,123,721 → 3,316,463 req/s, +6%.One thing left alone deliberately
Http3Response.Textsendscontent-type: text/plain; charset=utf-8— with a space. Static entry 54 istext/plain;charset=utf-8without one, so it misses the one-byte indexed encoding and takes the name reference instead. Dropping the space would make it an exact hit, but that changes what goes on the wire, so it is not in this PR.Tests
Unit 31 / E2E 51 / Chaos 47, all passing. Notablyhttp3: pure-C# parser GET vs nghttp3 clientstill passes, which is the check that matters — our encoder's output decoded by an independent QPACK implementation.New unit tests reach
QpackviaInternalsVisibleTorather than by widening the public surface. They verify by decoding what the encoder produces: an index that is off by one still yields a well-formed field section, so only a round trip catches it naming the wrong header. Covered: exact matches, known-name-unknown-value, capitalised names, unknown names lowercased on the way out, and case-sensitive value matching.Not addressed
ioxide.http3has no QPACK dynamic table at all — SETTINGS advertise capacity 0 and blocked streams 0, and the peer's SETTINGS values are walked for framing and discarded. That is unchanged here. Measured across clients, only Chrome advertises any capacity and nothing inserts inbound, so a dynamic table would be a browser-only win; this change benefits every client instead.