Skip to content

Optimize HTTP/2 decoding - #15498

Closed
wendigo wants to merge 18 commits into
jetty:jetty-12.1.xfrom
wendigo:swar-h2c-optimizations
Closed

wendigo wants to merge 18 commits into
jetty:jetty-12.1.xfrom
wendigo:swar-h2c-optimizations

Conversation

@wendigo

@wendigo wendigo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

No description provided.

wendigo added 18 commits July 27, 2026 14:08
HeaderParser stepped a 5-state machine once per octet to parse the
9 octets frame header, which is on the hot path of every frame.

When the whole header is available in the buffer (the common case),
read octets 0..7 with a single getLong() and extract length, type,
flags and the high 3 octets of the stream id with shifts and masks,
then read the last octet of the stream id. The octet-at-a-time state
machine is kept as the fallback for headers split across buffers.

This mirrors the existing 64-bit lookahead used by HttpParser for the
HTTP/1 request line.
PrefaceParser compared the 24 preface octets one at a time against
PREFACE_BYTES. Precompute the preface as overlapping 64-bit words so
that, when all the remaining preface octets are in the buffer, they
can be compared with 3 long comparisons instead of 24 byte ones.

The words are indexed by preface offset, so the fast path also covers
the h2c direct upgrade case, where the parser resumes at the offset
after PREFACE_PREAMBLE_BYTES; there fewer than 8 octets remain and the
comparison stays octet-wise.

The octet-at-a-time loop remains for prefaces split across buffers.
Failure handling is factored into invalidPreface() and is unchanged:
the buffer is cleared and a PROTOCOL_ERROR is notified.
SettingsBodyParser split each 6 octets setting across the SETTING_ID
and SETTING_VALUE states, so every setting cost two loop iterations
plus the state transition between them.

When all 6 octets are available, read the identifier and the value
together and stay in SETTING_ID for the next setting. The split-buffer
states are unchanged and still handle settings that straddle buffers.
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.
Encoding looked up table[c][0] and table[c][1] in an int[][], which is
a load of the row reference followed by a load of the element, twice
per character, with the rows scattered on the heap.

Pack each code and its bit length into a single long, (code << 5) |
bits, held in a flat array, so a character costs one load from one
contiguous table. Codes are at most 30 bits, so the length fits in 5
bits and the packed value fits in a long.

The bit accumulator was also drained one octet per put as soon as 8
bits were pending. Since codes are at most 30 bits, up to 31 bits can
accumulate while keeping the accumulator within 64 bits, which lets 4
octets be written with a single putInt. The octets are written in the
same order, as the buffer is big endian. Any remaining octets are
drained, and padded, as before.

Verified by the RFC 7541 encoding vectors in HuffmanTest and by round
tripping 200k random strings over the whole ISO-8859-1 range, decoded
both in bulk and one octet at a time.
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.
asciiToLowerCase and asciiToUpperCase scan for the first character
needing conversion before deciding whether any work is needed at all,
and most strings need none, so that scan decides the common case. It
tested one character per iteration, with a table load and a comparison
each.

Pack 4 characters into a long, one per 16 bit lane, and test all 4 at
once with a SWAR range comparison; the string is returned unchanged as
soon as the scan runs out without a match. The top bit of each lane is
cleared before the additions so they cannot carry into the next lane,
and those lanes are then discarded, as such a character is far outside
the ASCII range. The test can report a false positive for a non ASCII
lane, which a per character confirmation rejects; it never reports a
false negative for an ASCII lane, which is what makes the scan correct.

The two loops of the previous implementation also collapse into one:
scanning backwards for the last uppercase character and converting
everything below it is the same as scanning forwards for the first and
converting everything above it.

Verified against the previous implementations over all 65536
characters, over all 4 character combinations of the values around
every lane and range boundary, and over 2M random strings.
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.
Huffman decoding is the single hottest part of HPACK decoding, at
around 40% of the time to decode a realistic request. Each symbol cost
3 loads: one from the flattened tree to find the node, then one each
from rowbits and rowsym to read the code length and the symbol.

74 of the 95 printable ASCII characters have a code of 8 bits or fewer,
so nearly every symbol of real header text is a terminal node reached
directly from the root with the 8 bits already in hand. Precompute
those 256 root entries into a table holding the symbol and its length
packed as (bits << 8) | symbol, which decodes them with a single load
from 512 bytes that stay in the innermost cache. A zero entry means the
code is longer than 8 bits, and falls back to walking the tree.

EOS is a 30 bit code so it never appears in the table, and its check on
the tree walk is unaffected.

Huffman decode drops 11%, and decoding a request 5%.
A Huffman string is written as its encoded length followed by the
content, and the length was computed by octetsNeeded(), a full pass
over the string doing the same table lookups the encoding pass then
repeats. It was around 10% of the time to encode a request.

Encode the content first, into the space after the octet the length is
written into, then fill the length in. That octet only has room for a
length below the prefix maximum, which every string encoding to fewer
than 127 octets satisfies for the 7 bit prefix used by HPACK; for a
longer string the buffer is rewound and the length and content are
written the direct way, so no octets are ever shifted.

octetsNeeded() stays for the callers that size a buffer up front.

Verified byte for byte against the previous implementation over 186k
encodings: every prefix from 1 to 8, both huffman flags, several
leading octets, and lengths spanning the boundary where the rewind
path takes over, including strings of control characters whose codes
are long enough to force it.

Encoding a request drops 26%, a response 13%.
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.
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%.
HttpURI.Mutable.scheme(HttpScheme) went through scheme(String), which
lowercases via URIUtil.normalizeScheme. All four HttpScheme values are
lowercase already, so the scan can only ever return the string it was
given.

This is on the path of every HTTP/2 request, whose :scheme pseudo header
resolves to an HttpScheme before the URI is built. Verified that the
enum and string paths still produce the same scheme for every value.

Decoding a request in steady state drops a further 4%.
HpackBenchmark measures HPACK alone, which left the obvious question
unanswered: how much of handling a frame is HPACK, and how much is
everything else. Nothing covered the parser and generator layers.

Add generation and parsing of whole HEADERS and DATA frames, warmed the
way a connection warms them, alongside a cold HEADERS parse.

The answer for parsing is that HPACK is essentially all of it: a warmed
HEADERS frame parses in about the same time HPACK alone takes to decode
it, and a 1 KiB DATA frame parses in under 30ns because the body is
sliced rather than copied.
The check for an illegal path character read the table before testing
the index against its length:

    if (c > __pathCharacters.length || !__pathCharacters[c])

The table holds 128 entries, so for c == 128 the first test is false
and the second indexes one past the end. Every other character is
handled: below 128 the index is valid, above 128 the first test short
circuits. Only U+0080 lands in the gap.

The effect is an ArrayIndexOutOfBoundsException instead of an
ILLEGAL_PATH_CHARACTERS violation, so the URI never reaches the
compliance handling that decides what to do about it. A path can carry
that character over HTTP/2, where :path is decoded as ISO-8859-1.

The same expression appears in validateSegment(), fixed as well.
Every request parses a URI, from the request line over HTTP/1 and from
the :path pseudo header over HTTP/2, and nothing measured it.

Cover the shapes that behave differently: a short path, a typical path
with a query, a long path, a percent encoded path, a path with a
parameter, and an absolute URI.
Parsing the path switched on every character and, for the ones that are
not delimiters, consulted two boolean tables: one for whether the
character is legal, one for whether it is suspicious. Almost every
character of a real path is neither, so almost every character paid a
switch dispatch and two table lookups to establish that there was
nothing to do.

Collect the characters that need attention, the delimiters that drive
the state machine plus the illegal and suspicious ones, into a 128 bit
set held as two longs, and test it before the switch. The test is a
shift and a mask of a value the JIT can keep in a register, and it
takes the common character out of the switch entirely. The same test
short circuits validateSegment().

The set is derived from the same tables and delimiters in the static
initializer, so it cannot drift from them.

Verified against the previous implementation over 202k URIs through
three entry points, comparing path, canonical path, decoded path,
param, query, fragment, host, scheme and violations: identical
throughout. The corpus covers every character in several positions,
percent encodings, parameters, dot segments and random strings over an
alphabet of the interesting characters.

Parsing a long path drops 31%, an absolute URI 36%, a typical path with
a query 19%.
@wendigo

wendigo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@gregw ptal

@wendigo

wendigo commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@joakime anything needed here?

@sbordet
sbordet requested a review from lachlan-roberts August 3, 2026 09:56
@sbordet

sbordet commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

HEAD results: (smaller is better)

Benchmark                                Mode  Cnt     Score    Error  Units
HpackBenchmark.decodeHuffman             avgt    5   687.454 ±  9.217  ns/op
HpackBenchmark.decodeRequest             avgt    5  5041.390 ± 17.597  ns/op
HpackBenchmark.decodeRequestSteadyState  avgt    5   973.373 ±  9.193  ns/op
HpackBenchmark.decodeResponse            avgt    5  2037.918 ± 10.461  ns/op
HpackBenchmark.encodeHuffman             avgt    5   233.043 ±  0.403  ns/op
HpackBenchmark.encodeRequest             avgt    5  2469.985 ± 14.604  ns/op
HpackBenchmark.encodeRequestSteadyState  avgt    5   883.745 ±  5.572  ns/op
HpackBenchmark.encodeResponse            avgt    5  1154.096 ±  6.924  ns/op
HpackBenchmark.huffmanOctetsNeeded       avgt    5   115.365 ±  0.253  ns/op
HpackBenchmark.parseFrameHeader          avgt    5    11.265 ±  0.360  ns/op
HpackBenchmark.parsePreface              avgt    5    18.542 ±  0.020  ns/op
HpackBenchmark.toLowerCase               avgt    5    49.895 ±  0.063  ns/op
HpackBenchmark.validateFields            avgt    5   234.615 ±  3.292  ns/op

This PR results:

Benchmark                                Mode  Cnt     Score    Error  Units
HpackBenchmark.decodeHuffman             avgt    5   583.741 ±  8.333  ns/op
HpackBenchmark.decodeRequest             avgt    5  3916.156 ± 49.607  ns/op
HpackBenchmark.decodeRequestSteadyState  avgt    5   234.651 ±  2.760  ns/op
HpackBenchmark.decodeResponse            avgt    5  1643.720 ± 16.613  ns/op
HpackBenchmark.encodeHuffman             avgt    5   130.812 ±  0.147  ns/op
HpackBenchmark.encodeRequest             avgt    5  1620.598 ± 17.959  ns/op
HpackBenchmark.encodeRequestSteadyState  avgt    5   258.345 ±  2.889  ns/op
HpackBenchmark.encodeResponse            avgt    5   653.427 ±  4.636  ns/op
HpackBenchmark.huffmanOctetsNeeded       avgt    5    92.132 ±  0.072  ns/op
HpackBenchmark.parseFrameHeader          avgt    5     5.568 ±  0.051  ns/op
HpackBenchmark.parsePreface              avgt    5     5.960 ±  0.020  ns/op
HpackBenchmark.toLowerCase               avgt    5    43.683 ±  0.158  ns/op
HpackBenchmark.validateFields            avgt    5    74.233 ±  0.383  ns/op

@sbordet

sbordet commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

HEAD results: (smaller is better)

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  233.989 ± 2.223  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  204.770 ± 2.225  ns/op
HttpURIBenchmark.parseLongPath          avgt    5  118.377 ± 0.711  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  210.532 ± 1.477  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   20.740 ± 0.199  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5  107.543 ± 2.211  ns/op

This PR results:

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  205.982 ± 2.067  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  196.608 ± 4.145  ns/op
HttpURIBenchmark.parseLongPath          avgt    5   86.182 ± 0.408  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  183.221 ± 1.288  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   16.801 ± 0.208  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5   79.419 ± 1.401  ns/op

@sbordet

sbordet commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

HEAD results: (smaller is better)

Benchmark                                            Mode  Cnt     Score    Error  Units
Http2FrameBenchmark.generateDataFrame                avgt    5   688.629 ± 10.472  ns/op
Http2FrameBenchmark.generateHeadersFrameSteadyState  avgt    5  1374.786 ± 30.649  ns/op
Http2FrameBenchmark.parseDataFrame                   avgt    5    50.314 ±  1.307  ns/op
Http2FrameBenchmark.parseHeadersFrameCold            avgt    5  4275.535 ± 22.978  ns/op
Http2FrameBenchmark.parseHeadersFrameSteadyState     avgt    5   810.794 ±  5.666  ns/op

This PR results:

Benchmark                                            Mode  Cnt     Score    Error  Units
Http2FrameBenchmark.generateDataFrame                avgt    5   688.752 ± 15.171  ns/op
Http2FrameBenchmark.generateHeadersFrameSteadyState  avgt    5   899.652 ± 25.068  ns/op
Http2FrameBenchmark.parseDataFrame                   avgt    5    41.227 ±  0.277  ns/op
Http2FrameBenchmark.parseHeadersFrameCold            avgt    5  3398.515 ± 29.796  ns/op
Http2FrameBenchmark.parseHeadersFrameSteadyState     avgt    5   228.276 ±  1.668  ns/op

@wendigo

wendigo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@sbordet is it better? :)

@joakime

joakime commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@sbordet is it better? :)

Those results look like jmh testing.
In that case, higher numbers are better.

@wendigo

wendigo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@joakime ns/op is better when lower, throughput ops/s means higher is better

@wendigo

wendigo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@sbordet anything I can do to help land this?

@sbordet

sbordet commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@wendigo no, we are reviewing it, but looks straightforward, just some minor things that we don't want to miss.
Thanks for this PR, it'll likely land on the next release.

@wendigo

wendigo commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Any chance to merge it?

@lorban

lorban commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@wendigo it's quite a change, moderately big and quite complex. I've started digesting it, and I'm considering slicing it into a few PRs to simplify the review and accelerate merging.

I'm going to start working on that.

@lorban

lorban commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

@wendigo So far, I've had a deep look at the HttpURI optimizations. I hope you're in for a long discussion on the matter!

Clearly, the addition of isOrdinaryPathCharacter() in the parse() fast path helps. On my machine, the HttpURIBenchmark reports the following figures

baseline (vanilla 12.1.x branch):

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  310.998 ± 2.892  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  271.549 ± 3.358  ns/op
HttpURIBenchmark.parseLongPath          avgt    5  134.822 ± 0.702  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  294.817 ± 4.012  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   23.813 ± 0.090  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5  139.678 ± 1.476  ns/op

PR patch applied:

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  317.434 ± 6.859  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  255.449 ± 2.647  ns/op
HttpURIBenchmark.parseLongPath          avgt    5   91.638 ± 2.677  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  247.649 ± 1.764  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   18.617 ± 0.549  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5   86.297 ± 1.014  ns/op

There is a tiny degradation in parseAbsoluteUri, but all the other benchmarks measure a noticeable improvement. Nice!

I then tried replacing all the boolean[] we already have: __pathCharacters, __unreservedPctEncodedSubDelims and __suspiciousPathCharacters with a pair of long and a SWAR pattern and got this:

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  325.765 ± 4.654  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  316.569 ± 3.225  ns/op
HttpURIBenchmark.parseLongPath          avgt    5  194.614 ± 2.200  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  267.465 ± 3.323  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   35.858 ± 1.330  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5  104.204 ± 1.030  ns/op

and everything degraded with results worse than the baseline, except for parseTypicalPathQuery.

Next I restored the boolean[] and replaced your SWAR code with a boolean[]: too

Benchmark                               Mode  Cnt    Score   Error  Units
HttpURIBenchmark.parseAbsoluteUri       avgt    5  305.087 ± 2.714  ns/op
HttpURIBenchmark.parseEncodedPath       avgt    5  251.297 ± 3.443  ns/op
HttpURIBenchmark.parseLongPath          avgt    5   95.137 ± 1.295  ns/op
HttpURIBenchmark.parseParamPath         avgt    5  226.387 ± 1.813  ns/op
HttpURIBenchmark.parseShortPath         avgt    5   18.404 ± 0.492  ns/op
HttpURIBenchmark.parseTypicalPathQuery  avgt    5   80.910 ± 0.689  ns/op

There is a small degradation in parseLongPath but all the other benchmarks either are on par or do improve. parseTypicalPathQuery in particular gets a nice 6.5% speed bump.

I had a look at the ASM generated by the JIT and it makes sense that the boolean[] takes the crown as the JIT generates a 8-bit memory load + compare + conditional jump for the boolean[] vs a 64-bit memory load + shift + compare + conditional jump.

Here is the relevant code for the boolean[]

   1.32%  │        │   0x0000767ccfbe2af7:   jge    0x0000767ccfbe36c9           ;*if_icmplt {reexecute=0 rethrow=0 return_oop=0}
          │        │                                                             ; - org.eclipse.jetty.http.HttpURI$Mutable::isOrdinaryPathCharacter@4 (line 672)
          │        │                                                             ; - org.eclipse.jetty.http.HttpURI$Mutable::parse@1829 (line 1717)
          │        │   0x0000767ccfbe2afd:   cmp    $0x80,%ecx
   0.93%  │        │   0x0000767ccfbe2b03:   jae    0x0000767ccfbe34d7
          │        │   0x0000767ccfbe2b09:   movabs $0x64d02b180,%rdx            ;   {oop([Z{0x000000064d02b180})}
   0.14%  │        │   0x0000767ccfbe2b13:   movzbl 0x10(%rdx,%rcx,1),%edx
  13.92%  │        │   0x0000767ccfbe2b18:   test   %edx,%edx
   0.04%  │╭       │   0x0000767ccfbe2b1a:   je     0x0000767ccfbe2bdb           ;*ifne {reexecute=0 rethrow=0 return_oop=0}
          ││       │                                                             ; - org.eclipse.jetty.http.HttpURI$Mutable::parse@1832 (line 1717)

and its equivalent for the SWAR version:

   2.19%   │    │  │││  0x00007edab3be0c66:   jge    0x00007edab3be17b4           ;*if_icmplt {reexecute=0 rethrow=0 return_oop=0}
           │    │  │││                                                            ; - org.eclipse.jetty.http.HttpURI$Mutable::isOrdinaryPathCharacter@4 (line 673)
           │    │  │││                                                            ; - org.eclipse.jetty.http.HttpURI$Mutable::parse@1829 (line 1735)
           │    │  │││  0x00007edab3be0c6c:   cmp    $0x40,%edi
   0.02%   │    │  │││  0x00007edab3be0c6f:   movabs $0xb800000178000000,%rbp
   0.06%   │    │  │││  0x00007edab3be0c79:   movabs $0xd800c02dffffffff,%r13
   1.36%   │    │  │││  0x00007edab3be0c83:   cmovl  %r13,%rbp
   1.98%   │    │  │││  0x00007edab3be0c87:   shrx   %rdi,%rbp,%rbp
   3.67%   │    │  │││  0x00007edab3be0c8c:   test   $0x1,%bpl
   4.01%   │╭   │  │││  0x00007edab3be0c90:   je     0x00007edab3be0d57           ;*ifne {reexecute=0 rethrow=0 return_oop=0}
           ││   │  │││                                                            ; - org.eclipse.jetty.http.HttpURI$Mutable::isBitSet@21 (line 683)
           ││   │  │││                                                            ; - org.eclipse.jetty.http.HttpURI$Mutable::isOrdinaryPathCharacter@16 (line 675)
           ││   │  │││                                                            ; - org.eclipse.jetty.http.HttpURI$Mutable::parse@1829 (line 1735)

PerfNorm confirms that the boolean[] is more efficient:

for boolean[]:

Benchmark                                                       Mode  Cnt     Score   Error      Units
HttpURIBenchmark.parseTypicalPathQuery                          avgt    5    82.542 ± 1.700      ns/op
HttpURIBenchmark.parseTypicalPathQuery:CPI                      avgt          0.159          clks/insn
HttpURIBenchmark.parseTypicalPathQuery:IPC                      avgt          6.289          insns/clk
HttpURIBenchmark.parseTypicalPathQuery:L1-dcache-load-misses    avgt          3.161               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-dcache-loads          avgt        514.863               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-icache-load-misses    avgt          0.003               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-icache-loads          avgt          1.310               #/op
HttpURIBenchmark.parseTypicalPathQuery:branch-misses            avgt          0.075               #/op
HttpURIBenchmark.parseTypicalPathQuery:branches                 avgt        393.516               #/op
HttpURIBenchmark.parseTypicalPathQuery:cycles                   avgt        265.271               #/op
HttpURIBenchmark.parseTypicalPathQuery:dTLB-load-misses         avgt          0.047               #/op
HttpURIBenchmark.parseTypicalPathQuery:dTLB-loads               avgt          0.062               #/op
HttpURIBenchmark.parseTypicalPathQuery:iTLB-load-misses         avgt          0.003               #/op
HttpURIBenchmark.parseTypicalPathQuery:iTLB-loads               avgt          0.007               #/op
HttpURIBenchmark.parseTypicalPathQuery:instructions             avgt       1668.253               #/op
HttpURIBenchmark.parseTypicalPathQuery:stalled-cycles-frontend  avgt          5.362               #/op

for SWAR:

Benchmark                                                       Mode  Cnt     Score   Error      Units
HttpURIBenchmark.parseTypicalPathQuery                          avgt    5    87.569 ± 1.529      ns/op
HttpURIBenchmark.parseTypicalPathQuery:CPI                      avgt          0.165          clks/insn
HttpURIBenchmark.parseTypicalPathQuery:IPC                      avgt          6.061          insns/clk
HttpURIBenchmark.parseTypicalPathQuery:L1-dcache-load-misses    avgt          3.159               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-dcache-loads          avgt        495.189               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-icache-load-misses    avgt          0.004               #/op
HttpURIBenchmark.parseTypicalPathQuery:L1-icache-loads          avgt          1.278               #/op
HttpURIBenchmark.parseTypicalPathQuery:branch-misses            avgt          0.078               #/op
HttpURIBenchmark.parseTypicalPathQuery:branches                 avgt        389.194               #/op
HttpURIBenchmark.parseTypicalPathQuery:cycles                   avgt        281.539               #/op
HttpURIBenchmark.parseTypicalPathQuery:dTLB-load-misses         avgt          0.026               #/op
HttpURIBenchmark.parseTypicalPathQuery:dTLB-loads               avgt          0.043               #/op
HttpURIBenchmark.parseTypicalPathQuery:iTLB-load-misses         avgt          0.002               #/op
HttpURIBenchmark.parseTypicalPathQuery:iTLB-loads               avgt          0.007               #/op
HttpURIBenchmark.parseTypicalPathQuery:instructions             avgt       1706.314               #/op
HttpURIBenchmark.parseTypicalPathQuery:stalled-cycles-frontend  avgt          4.965               #/op

we can see that boolean[] does a few more L1 loads, a few more branches but the L1 loads don't miss more often and the branches are always perfectly predicted so the cycles per op go down.

This looks all good, and it looks like the SWAR code should be replaced with the boolean[] version but unfortunately, the latter requires 128 bytes to stay in the L1 cache (vs only 16 bytes for the SWAR version) otherwise its performance would quickly collapse. I suppose we're reaching the limits of microbenchmarking here: we would need to write a more coarse-grained benchmark that does end-to-end request processing minus the thread dispatching and socket work to properly compare the two in real conditions... which would be quite an undertaking!

Any opinion on that? @sbordet @gregw too?
Am I going too far and should just take the SWAR version as-is as it already provides a nice improvement?

Comment on lines 1238 to 1246
public Mutable scheme(HttpScheme scheme)
{
return scheme(scheme.asString());
// The known schemes are lowercase already, so skip normalizing them;
// this is on the path of every HTTP/2 request, whose :scheme pseudo
// header resolves to one of them.
_scheme = scheme.asString();
_uri = null;
return this;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change HTTP/1 behaviors?
These changes should not favor HTTP/2 over HTTP/1.

@lorban lorban Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joakime could you please reproduce this comment on #15673? Thanks!

@lorban

lorban commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

I'm going to replace this PR with a collection of 4 PRs, grouping changes per theme to ease reviews.

@lorban

lorban commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Superseded by:

@lorban lorban closed this Aug 26, 2026
@github-project-automation github-project-automation Bot moved this from 👀 In review to ✅ Done in FROZEN Jetty 12.1.13 Aug 26, 2026
@wendigo
wendigo deleted the swar-h2c-optimizations branch August 26, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

No open projects
Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

4 participants