Skip to content

Optimize HPack - #15674

Open
lorban wants to merge 11 commits into
jetty-12.1.xfrom
enhancement/12.1/wendigo-hpack
Open

Optimize HPack#15674
lorban wants to merge 11 commits into
jetty-12.1.xfrom
enhancement/12.1/wendigo-hpack

Conversation

@lorban

@lorban lorban commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The HPack parts of #15498 made by @wendigo

@lorban lorban self-assigned this Aug 26, 2026
@lorban lorban moved this to 🏗 In progress in FROZEN Jetty 12.1.13 Aug 26, 2026
wendigo and others added 3 commits August 27, 2026 11:33
A h2c connection spends most of its per request time parsing frame
headers and encoding and decoding HPACK, and there was no benchmark
covering any of it, so there was no way to tell whether a change to
that path was an improvement.

Cover frame header and preface parsing, HPACK encoding and decoding of
a realistic request and response, and the pieces they are built from:
Huffman encoding and decoding, field name and value validation, and
ASCII lowercasing.
Signed-off-by: Ludovic Orban <lorban@bitronix.be>
The existing HPACK benchmarks build a fresh encoder and decoder per
invocation, which measures the first request on a connection, where
every field is a literal. On a long lived connection the dynamic table
absorbs the repeated fields: the same request that encodes to 392
octets cold encodes to 12 once the table is warm, so the Huffman coding
that dominates the cold path barely runs.

Add an encoder and decoder pair warmed the way a connection warms them,
so both ends of the range are covered. The decoder is fed exactly the
sequence the encoder produced, otherwise its dynamic table would not
match.

The two paths have quite different profiles, and the steady state one
is the better guide for a server holding connections open.
@lorban
lorban force-pushed the enhancement/12.1/wendigo-hpack branch 2 times, most recently from 54e1931 to 3baf263 Compare August 31, 2026 07:40
lorban and others added 8 commits August 31, 2026 09:44
Signed-off-by: Ludovic Orban <lorban@bitronix.be>
isLegalH2H3FieldName looked up TOKENS[c] per character, which is an
array load followed by a dereference of the Token object and a switch
on its type; both validators are run for every field on every HPACK
encode and decode. isLegalFieldValue additionally re-evaluated the
"is this the first or last character" test on every iteration.

Classify all 256 ISO-8859-1 characters once, into a flat byte array of
bit flags derived from TOKENS, so a character costs one load and one
mask. Because the flags of a whole string can be accumulated with &,
legality becomes a single test at the end rather than one per
character, and the first/last field-vchar test moves out of the loop.

Verified against the previous implementations over all 65536 characters
and 1.2M strings, which agree in every case.
An entry of the HPACK dynamic table is referenced by index by every
message that uses it, and each reference re-derived whether its name
and value were legal, which is a full scan of both. On a connection in
steady state, where the table absorbs the repeated fields, this was the
single largest cost of decoding a message.

Memoize the outcome on the entry. The field is immutable, so it cannot
change; the memo races benignly, as the field hash in HttpField already
does, and HpackContext is single threaded anyway.

The outcome is remembered rather than assumed. A field that fails
validation is still added to the dynamic table, because the failure
only fails its own stream, so a peer could otherwise smuggle an illegal
value into the table on a stream it is willing to lose and then
reference it freely. Replaying the remembered outcome keeps every later
stream that references the entry failing, exactly as before.

Verified against the previous implementation with that smuggling
sequence: both fail the inserting stream and every later stream that
references the entry by index, with the same message.

Decoding a request in steady state drops 52%.
Encoding a message walked the field list three times: once to verify
every field could be encoded, once inside getCSV() to collect the hop
headers named by the Connection header, and once to encode. The first
two do not write anything, so they merge into a single pass.

That pass also memoizes validation on the dynamic table entry, as the
decoder now does. A field already in the table passed the same check
when it was added, and re-deriving it is a scan of the name and of the
value on every message. Static entries are still checked normally: a
field an application named after a pseudo header matches a static entry
but must still be rejected. The outcome is memoized rather than
assumed, so turning validation off, admitting an illegal field to the
table, and turning it back on still rejects it.

The lookup is skipped while the dynamic table is empty, where it could
only miss; that keeps the first message on a connection, which has
nothing to gain here, from paying for it.

Verified against the previous implementation across legal fields,
pseudo header names, illegal values, repeated encodes that exercise the
memo, and the validation off then on sequence: identical in every case.

Encoding a request in steady state drops 52%.
An ISO-8859-1 String is byte for byte its encoded bytes, but both
decoders built one up by appending one character at a time to a
StringBuilder: a bounds check, a position update and a capacity check
per octet, for every literal header name and value.

HpackDecoder.toISO88591String now decodes straight from the buffer,
using the backing array when there is one, so that String construction
is a single array copy. The explicit remaining() check preserves the
BufferUnderflowException thrown by the previous per-octet reads, which
the array path would otherwise not raise.

NBitStringDecoder accumulates into a byte array that grows as needed,
since it must handle strings split across buffers; the array starts
small rather than at the declared length, so a large declared length
does not allocate up front.
Signed-off-by: Ludovic Orban <lorban@bitronix.be>
Signed-off-by: Ludovic Orban <lorban@bitronix.be>
…ck with isEmpty()

Signed-off-by: Ludovic Orban <lorban@bitronix.be>
@lorban
lorban force-pushed the enhancement/12.1/wendigo-hpack branch from 3baf263 to 1262b32 Compare August 31, 2026 07:44
@lorban

lorban commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I've cleaned up the micro benchmark, and a few nits here and there (I had to modify how NBitStringDecoder.stringDecode() grows its internal array to use the standard mechanism), but otherwise this looks like a good collection of optimizations that do improve perf all around the targeted code:

12.1.x vanilla

Benchmark                           Mode  Cnt     Score    Error  Units
HpackBenchmark.decodeRequestCold    avgt    5  6062.625 ± 64.617  ns/op
HpackBenchmark.decodeRequestWarm    avgt    5  1185.532 ± 20.199  ns/op
HpackBenchmark.decodeResponseCold   avgt    5  2323.511 ± 39.608  ns/op
HpackBenchmark.decodeResponseWarm   avgt    5  1200.097 ± 15.176  ns/op
HpackBenchmark.encodeRequestCold    avgt    5  3011.979 ± 38.598  ns/op
HpackBenchmark.encodeRequestWarm    avgt    5  1023.418 ± 12.889  ns/op
HpackBenchmark.encodeResponseCold   avgt    5  1246.548 ± 34.596  ns/op
HpackBenchmark.encodeResponseWarm   avgt    5   630.774 ±  2.404  ns/op
HpackBenchmark.validateFieldNames   avgt    5    66.720 ±  0.421  ns/op
HpackBenchmark.validateFieldValues  avgt    5   197.171 ±  1.984  ns/op

with PR:

Benchmark                           Mode  Cnt     Score    Error  Units
HpackBenchmark.decodeRequestCold    avgt    5  5436.242 ± 73.315  ns/op
HpackBenchmark.decodeRequestWarm    avgt    5   375.053 ±  5.669  ns/op
HpackBenchmark.decodeResponseCold   avgt    5  2441.238 ± 26.355  ns/op
HpackBenchmark.decodeResponseWarm   avgt    5   377.037 ±  4.428  ns/op
HpackBenchmark.encodeRequestCold    avgt    5  2360.081 ± 36.741  ns/op
HpackBenchmark.encodeRequestWarm    avgt    5   341.844 ±  4.553  ns/op
HpackBenchmark.encodeResponseCold   avgt    5  1081.546 ± 15.205  ns/op
HpackBenchmark.encodeResponseWarm   avgt    5   354.718 ±  2.793  ns/op
HpackBenchmark.validateFieldNames   avgt    5    43.911 ±  0.255  ns/op
HpackBenchmark.validateFieldValues  avgt    5    81.510 ±  0.257  ns/op

@wendigo

wendigo commented Aug 31, 2026

Copy link
Copy Markdown

@lorban thanks :)

@sbordet sbordet added the Sponsored This issue affects a user with a commercial support agreement label Aug 31, 2026
@sbordet sbordet moved this to 👀 In review in Jetty 12.1.14 Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance Sponsored This issue affects a user with a commercial support agreement

Projects

Status: 👀 In review

Development

Successfully merging this pull request may close these issues.

3 participants