Skip to content

perf: Huffman decode HPACK string literals into reusable scratch arrays - #1302

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-huffman-decode-scratch
Open

pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-huffman-decode-scratch

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

The inbound mirror of #1301. For every string literal in every inbound HEADERS frame — and browsers Huffman-code nearly all of them — HuffmanDecoder.decode collected the symbols in a ByteArrayOutputStream: one synchronized write(int) per decoded symbol, growth from the default 32 bytes by doubling (3–4 regrowths for a typical user-agent), then a toByteArray copy; Decoder.readStringLiteral then copied the result again into a String. The raw (non-Huffman) path was one allocation + copy in InputStream.readNBytes(int) and a second copy into the String.

Modification

  • Decoder keeps two scratch arrays, reused across header blocks (a Decoder is per-connection and single-threaded, like the Encoder in perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream #1301): the coded literal is read into literalBuf with readNBytes(byte[], int, int), and for a Huffman literal HuffmanDecoder.decode(buf, length, out) writes the symbols by index into decodedBuf. The String is built straight from the scratch array with ISO-8859-1 (every octet → the char of the same value, as StringTools.asciiStringFromBytes did), the one copy left in either path.
  • decodedBuf is sized from a static bound rather than grown while decoding: the shortest code in the HPACK Huffman table is 5 bits, so n coded bytes decode to at most n * 8 / 5 symbols (HuffmanDecoder.maxDecodedLength). No growth logic in the decoder, no stream.
  • The scratch arrays grow (doubling) to the longest literal seen on the connection; a literal longer than the remaining max header list size and the dynamic table capacity is skipped rather than read, so that bounds retention.
  • HuffmanDecoder's EOS / invalid-padding checks and their preallocated exceptions are unchanged.
  • One MiMa exclude for the package-private HuffmanDecoder.decode signature (MiMa 1.2.0 reports it, as it did for the encoder in perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream #1301).

StringTools.asciiStringFromBytes no longer has a caller in main code; left in place with its spec since #1297 just added it — happy to drop it here if you prefer.

Tests: new HuffmanDecoderSpec (RFC 7541 Appendix C.4 vectors decoded directly, all-256-octet round trip against an encoder written from the HPACK table in the test itself so it does not depend on HuffmanEncoder's API, a literal of 5-bit codes that fills the maxDecodedLength bound exactly, the length argument being honoured, EOS-in-stream and bad-padding rejection). HpackDecoderSpec gains: literals longer than the 128-byte initial scratch (Huffman and raw), consecutive blocks of varying literal lengths through one Decoder (a shorter literal must not pick up the previous one's tail from the reused arrays), and a hand-built raw literal of octets 0x80–0xFF decoding to the chars of the same value. HpackDecoderBenchmark added to http-bench-jmh.

Not touched: the twitter decoder does not detect padding longer than 7 bits (a8eb10649cbf + ff decodes as no-cache); that is pre-existing and out of scope here.

Result

HpackDecoderBenchmark.decodeHeaders decodes the same 10-header browser request block as HpackEncoderBenchmark (~430 octets of values, all string literals), JDK 17.0.19, Apple Silicon. Short runs (-f 1 -wi 3 -i 5 -w 1 -r 1) on a laptop that was under heavy unrelated load (1-min load average 5–20), so the error bars are wide — treat these as indicative and worth a quiet re-run:

mode main this PR
huffman (forced on; what a browser sends) 5734 ± 1466 ns (a second run: 16043 ± 22414) 4428 ± 1178 ns (second run: 5143 ± 3059)
raw (forced off) 1370 ± 125 ns (second run: 1210 ± 464) 774 ± 176 ns (second run: 1057 ± 870)
default 5396 ± 6874 ns 5566 ± 1054 ns

So roughly −20–25% Huffman and −35–40% raw; the default column is too noisy to read. The remaining Huffman cost is the per-byte state machine (in.read() + tree walk), which the lock removal does not touch.

Tests

  • sbt "http-core/Test/testOnly org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanDecoderSpec org.apache.pekko.http.impl.engine.http2.hpack.HpackDecoderSpec" — 18/18 on Scala 2.13.18 and 3.3.8
  • sbt "http2-tests/Test/testOnly ...Http2ServerSpec ...Http2ClientSpec ...RequestParsingSpec" — 212 pass, 20 pending (pre-existing)
  • sbt "http-bench-jmh/Jmh/run -f 1 -wi 3 -i 5 -w 1 -r 1 HpackDecoderBenchmark" on main and on this branch — table above
  • sbt http-core/mimaReportBinaryIssues, sbt headerCheckAll, sbt javafmtCheckAll (JDK 17), scalafmt --mode diff-ref=upstream/main, git diff --check — clean

References

Refs #1301 - the same change for the encoder; Refs #1251 - the InputStream decoding this builds on

Motivation:
HuffmanDecoder collected the decoded symbols in a ByteArrayOutputStream,
one synchronized write(int) per symbol, growing it from 32 bytes by
doubling, and copied it out with toByteArray; Decoder then copied the
result again into a String. The raw literal path allocated and copied
once through InputStream.readNBytes and again into the String. This
runs for every string literal of every inbound header block, which
browsers Huffman code almost entirely.

Modification:
Decoder keeps two scratch arrays, reused across header blocks: the coded
literal is read into one with readNBytes(byte[], int, int), and
HuffmanDecoder.decode(buf, length, out) writes the symbols by index into
the other, which the caller sizes from the static bound that the shortest
code in the HPACK Huffman table is 5 bits (maxDecodedLength). The String
is built straight from the scratch array with the ISO-8859-1 charset, the
single copy left in either path. The arrays grow to the longest literal
seen on the connection, which the max header list size and the dynamic
table capacity bound. A MiMa exclude covers the package-private
HuffmanDecoder signature.

Result:
No lock, no stream growth and one copy instead of two or three per
string literal: decoding a typical browser request's header block is
about 20-25% faster Huffman coded and 35-40% faster raw on JDK 17 (see
the PR for the JMH figures, taken on a loaded laptop).

Tests:
- sbt "http-core/Test/testOnly org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanDecoderSpec org.apache.pekko.http.impl.engine.http2.hpack.HpackDecoderSpec" (Scala 2.13.18 and 3.3.8)
- sbt "http2-tests/Test/testOnly org.apache.pekko.http.impl.engine.http2.Http2ServerSpec org.apache.pekko.http.impl.engine.http2.Http2ClientSpec org.apache.pekko.http.impl.engine.http2.RequestParsingSpec"
- sbt "http-bench-jmh/Jmh/run -f 1 -wi 3 -i 5 -w 1 -r 1 HpackDecoderBenchmark" on main and on this branch
- sbt http-core/mimaReportBinaryIssues; sbt headerCheckAll; sbt javafmtCheckAll (JDK 17)
- scalafmt --mode diff-ref=upstream/main

References:
Refs apache#1301 - the same change for the encoder; Refs apache#1251 - the InputStream decoding this builds on
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant