From 548f07a43719caf406b0b5e5544469a980e3ea4a Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Fri, 14 Aug 2026 14:39:33 +0100 Subject: [PATCH] perf(qpack): encode response headers against the static table, 0.12.0 The encoder only ever used the static table for :status. Every other field was written as Literal With Literal Name, spelling out a name the table already holds - so a response carrying content-type, date and cache-control put all three names on the wire in full, 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. content-type: text/plain is entry 53, so it goes from 25 bytes to 1, and the header block for a small response drops from 28 bytes to 4. Names match case-insensitively. Callers hold HTTP's conventional capitalisation, and resolving it here means the name is never written, so there is nothing to lowercase and nothing to allocate for it. Values match exactly, since a field value is case-sensitive. This is worth more in practice than the dynamic table: it works against every client, whereas the dynamic table stays inert unless the peer advertised capacity, which only browsers do. Throughput is unchanged on a small-response benchmark (269k vs 268k req/s), which is expected - that workload is not bandwidth-bound. The gain is bytes per response, and it compounds with anything that packs more responses per datagram. --- src/Glyph3/Glyph3.csproj | 6 +- src/Glyph3/Qpack/Qpack.cs | 149 ++++++++++++++++++- tests/Glyph3.Tests/QpackStaticEncodeTests.cs | 128 ++++++++++++++++ 3 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 tests/Glyph3.Tests/QpackStaticEncodeTests.cs diff --git a/src/Glyph3/Glyph3.csproj b/src/Glyph3/Glyph3.csproj index 52adf65..475cfeb 100644 --- a/src/Glyph3/Glyph3.csproj +++ b/src/Glyph3/Glyph3.csproj @@ -12,9 +12,9 @@ 13.0 Glyph3 - 0.11.652.0 - 0.11.652.0 - 0.11.652 + 0.12.0.0 + 0.12.0.0 + 0.12.0 dotnet-web-stack A transport-agnostic HTTP/3 connection for .NET: frame parsing, QPACK (static table and Huffman) and request dispatch, in pure managed C# with no native dependencies. Glyph3 does no I/O - it takes stream bytes in and hands stream bytes back - so it runs over System.Net.Quic, over io_uring, or over a pair of in-memory queues in a test. Buffered and streamed request bodies, and responses written through an IBufferWriter. MIT diff --git a/src/Glyph3/Qpack/Qpack.cs b/src/Glyph3/Qpack/Qpack.cs index e0971ad..01b026e 100644 --- a/src/Glyph3/Qpack/Qpack.cs +++ b/src/Glyph3/Qpack/Qpack.cs @@ -3,11 +3,11 @@ namespace Glyph3; /// -/// QPACK (RFC 9204) without the dynamic table: our SETTINGS advertise capacity 0, so a conforming -/// peer may only send static-table references and literals - which reduces the decoder to the -/// static table, prefixed integers, and Huffman. The encoder mirrors that: indexed :status where -/// the table has the code, literal name references otherwise, literal names for everything else, -/// no Huffman on output (legal, and keeps the writer trivial). +/// QPACK (RFC 9204). The decoder resolves static references, prefixed integers and Huffman, plus +/// dynamic references when a table is configured. The encoder prefers the static table for every +/// field, not just :status: an entry matching both name and value costs one byte, a known name +/// costs an index plus the literal value, and only an unknown name is spelled out. No Huffman on +/// output - legal, and it keeps the writer trivial. /// internal static class Qpack { @@ -405,7 +405,7 @@ private static byte[] EncodeStaticOnlyResponseFields(Http3Response response, out foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in response.Headers) { - w += WriteLiteralHeader(buf.AsSpan(w), name.Span, value.Span); + w += WriteHeader(buf.AsSpan(w), name.Span, value.Span); } written = w; @@ -445,6 +445,141 @@ private static int WriteStatus(Span buf, int status) return w + dlen; } + + /// + /// The static table indexed for encoding: distinct names, each with the entries that share it. + /// + /// + /// Grouped rather than flat so a lookup compares one candidate per distinct name and rejects + /// most on length alone. Built once; the table is 99 fixed entries. + /// + private static readonly (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[] StaticNames = BuildStaticNames(); + + private static (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[] BuildStaticNames() + { + var byName = new List<(byte[] Name, int NameIndex, List<(byte[] Value, int Index)> Values)>(); + + for (int i = 0; i < QpackStatic.Table.Length; i++) + { + (byte[] name, byte[] value) = QpackStatic.Table[i]; + + int at = -1; + for (int j = 0; j < byName.Count; j++) + { + if (byName[j].Name.AsSpan().SequenceEqual(name)) + { + at = j; + break; + } + } + + if (at < 0) + { + byName.Add((name, i, [(value, i)])); + } + else + { + byName[at].Values.Add((value, i)); + } + } + + var result = new (byte[], int, (byte[], int)[])[byName.Count]; + + for (int i = 0; i < byName.Count; i++) + { + result[i] = (byName[i].Name, byName[i].NameIndex, byName[i].Values.ToArray()); + } + + return result; + } + + /// + /// Finds a header in the static table: an exact name and value match, or failing that a name. + /// + /// + /// Names are matched case-insensitively, so a caller holding HTTP's conventional capitalisation + /// still resolves - and then never writes the name at all, which is the point. Values are + /// matched exactly, because a field value is case-sensitive. + /// + private static bool TryFindStatic(ReadOnlySpan name, ReadOnlySpan value, out int exact, out int nameIndex) + { + exact = -1; + nameIndex = -1; + + foreach ((byte[] candidate, int candidateIndex, (byte[] Value, int Index)[] values) in StaticNames) + { + if (candidate.Length != name.Length || !EqualsIgnoreCase(candidate, name)) + { + continue; + } + + nameIndex = candidateIndex; + + foreach ((byte[] entryValue, int entryIndex) in values) + { + if (entryValue.AsSpan().SequenceEqual(value)) + { + exact = entryIndex; + break; + } + } + + return true; + } + + return false; + } + + private static bool EqualsIgnoreCase(ReadOnlySpan lowercase, ReadOnlySpan other) + { + for (int i = 0; i < lowercase.Length; i++) + { + byte c = other[i]; + + if (c is >= (byte)'A' and <= (byte)'Z') + { + c |= 0x20; + } + + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } + + /// + /// Writes one header, preferring the static table over spelling the name out. + /// + /// + /// Three tiers, cheapest first: both name and value in the table costs a single byte; a known + /// name costs an index plus the literal value; anything else falls back to the full literal. + /// This works against every client, unlike the dynamic table, which stays unused unless the + /// peer advertised capacity for one. + /// + private static int WriteHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value) + { + if (TryFindStatic(name, value, out int exact, out int nameIndex)) + { + if (exact >= 0) + { + // Indexed Field Line: 1 T=1 index(6+). + return WriteInt(buf, 0xC0, 6, exact); + } + + // Literal With Name Reference: 01 N=0 T=1 nameindex(4+), then H=0 value(7+). + int written = WriteInt(buf, 0x50, 4, nameIndex); + written += WriteInt(buf[written..], 0x00, 7, value.Length); + value.CopyTo(buf[written..]); + + return written + value.Length; + } + + return WriteLiteralHeader(buf, name, value); + } + /// Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+). private static int WriteLiteralHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value) { @@ -526,7 +661,7 @@ internal static byte[] EncodeResponseFields(Http3Response response, QpackEncoder continue; } - w += WriteLiteralHeader(buf.AsSpan(w), nameM.Span, valueM.Span); + w += WriteHeader(buf.AsSpan(w), nameM.Span, valueM.Span); } if (count > 0) diff --git a/tests/Glyph3.Tests/QpackStaticEncodeTests.cs b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs new file mode 100644 index 0000000..208c748 --- /dev/null +++ b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs @@ -0,0 +1,128 @@ +using System.Text; + +using Xunit; + +namespace Glyph3.Tests; + +/// +/// Response headers encoded against the static table. Checked by decoding what comes out, so an +/// index that is off by one fails here rather than on a peer. +/// +public class QpackStaticEncodeTests +{ + [Theory] + [InlineData("content-type", "text/plain", 53)] + [InlineData("content-type", "text/html; charset=utf-8", 52)] + [InlineData("content-type", "application/json", 46)] + [InlineData("accept-ranges", "bytes", 32)] + [InlineData("cache-control", "no-cache", 39)] + [InlineData("content-encoding", "gzip", 43)] + [InlineData("vary", "origin", 60)] + public void ANameAndValueBothInTheTableCostOneByte(string name, string value, int index) + { + byte[] encoded = Encode(name, value, out int written); + + // Prefix is two bytes, then :status, then this header as 1 T=1 index(6+). + Assert.Equal((byte)(0xc0 | index), encoded[written - 1]); + } + + [Theory] + [InlineData("date", "Wed, 13 Aug 2026 22:45:39 GMT")] + [InlineData("location", "/elsewhere")] + [InlineData("etag", "\"abc123\"")] + [InlineData("last-modified", "Wed, 13 Aug 2026 00:00:00 GMT")] + [InlineData("server", "GenHTTP/11.0.0.0")] + [InlineData("set-cookie", "a=b")] + [InlineData("content-type", "application/x-custom")] + public void AKnownNameWithAnUnknownValueReferencesTheNameOnly(string name, string value) + { + byte[] encoded = Encode(name, value, out int written); + + // The name must not appear on the wire at all - that is the entire point. + Assert.DoesNotContain(name, Encoding.ASCII.GetString(encoded, 0, written)); + Assert.Contains(value, Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name, value); + } + + [Theory] + [InlineData("Content-Type", "text/plain")] + [InlineData("DATE", "Wed, 13 Aug 2026 22:45:39 GMT")] + [InlineData("Cache-Control", "no-cache")] + public void ACapitalisedNameStillResolves(string name, string value) + { + // Callers hold HTTP's conventional capitalisation; matching case-insensitively means the + // name is never written, so there is nothing to lowercase. + byte[] encoded = Encode(name, value, out int written); + + Assert.DoesNotContain(name.ToLowerInvariant(), Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name.ToLowerInvariant(), value); + } + + [Theory] + [InlineData("x-custom-header", "value")] + [InlineData("x-request-id", "abc")] + public void AnUnknownNameIsStillWrittenOut(string name, string value) + { + byte[] encoded = Encode(name, value, out int written); + + Assert.Contains(name, Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, name, value); + } + + [Fact] + public void AValueIsMatchedCaseSensitively() + { + // Field values are case-sensitive, so TEXT/PLAIN is not entry 53 and must be written out. + byte[] encoded = Encode("content-type", "TEXT/PLAIN", out int written); + + Assert.Contains("TEXT/PLAIN", Encoding.ASCII.GetString(encoded, 0, written)); + + AssertRoundTrips(encoded, written, "content-type", "TEXT/PLAIN"); + } + + [Fact] + public void TheStaticTableShrinksATypicalResponse() + { + var response = new Http3Response { Status = 200 }; + Add(response, "content-type", "text/plain"); + Add(response, "accept-ranges", "bytes"); + Add(response, "vary", "origin"); + + byte[] encoded = Qpack.EncodeResponseFields(response, out int written); + + // Two prefix bytes, an indexed :status, and one byte per header. + Assert.Equal(6, written); + + Assert.True(encoded.Length >= written); + } + + private static byte[] Encode(string name, string value, out int written) + { + var response = new Http3Response { Status = 200 }; + + Add(response, name, value); + + return Qpack.EncodeResponseFields(response, out written); + } + + private static void Add(Http3Response response, string name, string value) + => response.Headers.Add((Encoding.ASCII.GetBytes(name), Encoding.ASCII.GetBytes(value))); + + private static void AssertRoundTrips(byte[] encoded, int written, string name, string value) + { + var request = new Http3Request(); + + Assert.True(Qpack.TryDecodeFieldSection(encoded.AsSpan(0, written), request)); + + // Decoded fields are ranges into an arena until this materialises them. + request.Freeze(); + + (ReadOnlyMemory Name, ReadOnlyMemory Value) field = + Assert.Single(request.Headers, h => Encoding.ASCII.GetString(h.Name.Span) == name); + + Assert.Equal(value, Encoding.ASCII.GetString(field.Value.Span)); + } +}