Optimize HTTP/2 decoding - #15498
Optimize HTTP/2 decoding#15498wendigo wants to merge 18 commits into
Conversation
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%.
|
@gregw ptal |
|
@joakime anything needed here? |
|
HEAD results: (smaller is better) This PR results: |
|
HEAD results: (smaller is better) This PR results: |
|
HEAD results: (smaller is better) This PR results: |
|
@sbordet is it better? :) |
Those results look like jmh testing. |
|
@joakime ns/op is better when lower, throughput ops/s means higher is better |
|
@sbordet anything I can do to help land this? |
|
@wendigo no, we are reviewing it, but looks straightforward, just some minor things that we don't want to miss. |
|
Any chance to merge it? |
|
@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. |
|
@wendigo So far, I've had a deep look at the Clearly, the addition of baseline (vanilla 12.1.x branch): PR patch applied: There is a tiny degradation in I then tried replacing all the and everything degraded with results worse than the baseline, except for Next I restored the There is a small degradation in I had a look at the ASM generated by the JIT and it makes sense that the Here is the relevant code for the and its equivalent for the SWAR version: PerfNorm confirms that the for for SWAR: we can see that This looks all good, and it looks like the SWAR code should be replaced with the Any opinion on that? @sbordet @gregw too? |
| 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; | ||
| } |
There was a problem hiding this comment.
Why change HTTP/1 behaviors?
These changes should not favor HTTP/2 over HTTP/1.
|
I'm going to replace this PR with a collection of 4 PRs, grouping changes per theme to ease reviews. |
No description provided.