perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream - #1301
Open
pjfanning wants to merge 1 commit into
Open
perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream#1301pjfanning wants to merge 1 commit into
pjfanning wants to merge 1 commit into
Conversation
…zed ByteStringOutputStream Motivation: HuffmanEncoder read each string literal one char at a time through String.charAt, twice (once to size it, once to code it), and wrote its output one byte at a time through OutputStream.write(int). Every write, including the integer prefixes and bulk literal writes from Encoder, went through the synchronized ByteArrayOutputStream that HeaderCompression passes in, and the finished block was copied out with toByteArray. The per-byte synchronized write dominated the cost of encoding a header block; the remaining per-header locks were another fifth of what was left. Modification: HuffmanEncoder takes byte[] input, as the upstream twitter/hpack code did, and codes straight into a reserved range of the output array. Encoder converts each string literal to octets once (via StringTools.asciiStringBytes, now ISO-8859-1 and so the exact inverse of asciiStringFromBytes: a raw literal carrying an octet in 0x80-0xFF sends that octet where US-ASCII substituted '?', as the Huffman form already did) and uses them for the length check and both literal forms. ByteStringOutputStream (apache#1235) is generalised into a plain OutputStream with unsynchronized writes, a reserve/array pair for direct fills, and takeByteString(), which hands the block over without copying when most of the buffer is used (starting the next block on a fresh array sized to the last one) or copies out and keeps the array when only a small part is, as toByteStringUnsafe did. Encoder writes into that stream instead of any OutputStream; HeaderCompression and PerMessageDeflate use it. One MiMa excludes file covers the shaded Encoder and HuffmanEncoder signatures. Result: Encoding a typical 10-header browser request is ~2.7x faster on JDK 17 (about 9.6us to 3.5us, see the PR for the JMH table), both HPACK string literal forms round-trip every octet through the decoder, and the WebSocket deflate stages no longer lock per write. Tests: - sbt "http-core/Test/testOnly org.apache.pekko.http.impl.util.ByteStringOutputStreamSpec org.apache.pekko.http.impl.util.StringToolsSpec org.apache.pekko.http.impl.engine.http2.hpack.HpackDecoderSpec org.apache.pekko.http.shaded.com.twitter.hpack.*" (Scala 2.13.18 and 3.3.8) - sbt "http-core/Test/testOnly org.apache.pekko.http.impl.engine.ws.*" - 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 HpackEncoderBenchmark" 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#1235 - the ByteStringOutputStream this generalises; Refs apache#1300 - superseded by this PR
pjfanning
force-pushed
the
hpack-header-block-buffer
branch
from
September 13, 2026 15:03
d8ad862 to
bf61666
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #1300 (closed): that change plus the buffer that gathers the output, as one commit.
Motivation
Encoding an HPACK header block paid for three things it did not need to:
HuffmanEncoder(the shaded twitter/hpack copy) read each string literal one char at a time throughString.charAt(i) & 0xFF— twice, once ingetEncodedLengthto decide whether Huffman is shorter and once inencode.OutputStream.write(int), and every other write fromEncoder(the 1–3 integer-prefix bytes per header, the bulk raw-literal write) went through the same stream: theByteArrayOutputStreamthatHeaderCompressionpasses in, whose everywriteissynchronized. The per-output-byte lock was where most of the time went; the per-header locks that remained after fixing that were another ~20%.toByteArray.ByteStringOutputStream(#1235) already hands its bytes to aByteStringwithout copying, but it extendsByteArrayOutputStream(so inherits the synchronized writes) and is one-shot (toByteStringUnsafeforbids reuse).Modification
HuffmanEncodertakesbyte[]input, as upstream twitter/hpack does:getEncodedLength(byte[])andencode(data, dst, dstOffset, encodedLength), which codes straight into a reserved range of the output array and throwsIllegalArgumentExceptionif the length the caller computed disagrees with what it produced.Encoder.encodeStringLiteralconverts the string to octets once and uses them for the length check, the Huffman form and the raw form. It takes aByteStringOutputStreaminstead of anyOutputStream(and drops theIOExceptions that could never happen).StringTools.asciiStringBytesencodes as ISO-8859-1 (a single array copy for a Latin-1 coded string on JDK 9+), making it the exact inverse ofasciiStringFromBytes, which http/2: reject a header field carrying CR, LF or NUL, and answer a malformed field with a 400 #1297 moved to ISO-8859-1 decoding. Deliberate behaviour change: HPACK string literals are opaque octets and the decoder maps every byte 0x00–0xFF to the char of the same value. The Huffman path already mapped such a char back to its octet (& 0xFF), but the raw path encoded withUS_ASCII, which turned any char in 0x80–0xFF into?. The two forms now agree and both round-trip every octet throughDecoder(tested). A char above 0xFF, which no octet can represent, becomes?in both forms.ByteStringOutputStreambecomes a plainOutputStreamwith unsynchronizedwrite(int)/write(byte[], int, int), plusreserve(length): Int+array(reserve a range for the caller to fill directly) andtakeByteString(), which hands the block over and leaves the stream empty for reuse: when most of the buffer is used it wraps the array viaByteString.fromArrayUnsafeand starts the next block on a fresh array sized to the last one; when only a small part is used it copies out and keeps the array (the same "don't retain a big mostly-empty array" heuristictoByteStringUnsafehad).HeaderCompressioncallstakeByteString()instead ofByteString.fromArrayUnsafe(os.toByteArray)+reset().PerMessageDeflateusestakeByteString(); behaviour is identical, it just stops locking per write.2.0.x.backwards.excludes/hpack-encoder-bytestring-output-stream.excludes, for the shadedEncoder/HuffmanEncodersignatures MiMa 1.2.0 reports (package-private / shaded internals;ByteStringOutputStreamandStringToolsareprivate[http]).Tests added:
HpackEncoderSpec(RFC 7541 Appendix C.4 Huffman vectors, C.2.1 raw literal, all-256-octet round trip in both literal forms, offset write and length-mismatch rejection);ByteStringOutputStreamSpecgains reuse-after-handover, reserve/array and negative-capacity cases;StringToolsSpecgains the octet round trip, the?substitution and the empty string.HpackEncoderBenchmarkis added tohttp-bench-jmh.Result
HpackEncoderBenchmark.encodeHeadersencodes a typical 10-header browser request (:method,:path,user-agent,accept,cookie, …, ~430 octets of values) into the output buffer with indexing disabled so every value is sent as a string literal. JDK 17.0.19, Apple Silicon laptop, average time per request, lower is better:mainbyte[]Huffman change alone (was #1300)default(Huffman when shorter, the production setting)huffman(forced on)raw(forced off)≈2.7x on the production path, ≈1.5x on the raw path. The middle and right columns were measured in different sessions (a later re-run of the middle column on a busier laptop gave 4536 / 5412 / 2582), so the per-column ratios are more reliable than a subtraction between them.
Tests
sbt "http-core/Test/testOnly org.apache.pekko.http.impl.util.ByteStringOutputStreamSpec org.apache.pekko.http.impl.util.StringToolsSpec org.apache.pekko.http.impl.engine.http2.hpack.HpackDecoderSpec org.apache.pekko.http.shaded.com.twitter.hpack.*"— 32/32 on Scala 2.13.18 and 3.3.8sbt "http-core/Test/testOnly org.apache.pekko.http.impl.engine.ws.*"— 172 pass, 12 pending (pre-existing); coversPerMessageDeflatein both directionssbt "http2-tests/Test/testOnly ...Http2ServerSpec ...Http2ClientSpec ...RequestParsingSpec"— 212 pass, 20 pending (pre-existing)sbt "http-bench-jmh/Jmh/run HpackEncoderBenchmark"onmainand on this branch — table abovesbt http-core/mimaReportBinaryIssues,sbt headerCheckAll,sbt javafmtCheckAll(JDK 17),scalafmt --mode diff-ref=upstream/main,git diff --check— cleanReferences
Refs #1235 - the
ByteStringOutputStreamthis generalises; Refs #1300 - superseded by this PR; Refs #1299 - the same bulk-copy idea forEnhancedString.getAsciiBytes