Conversation
pjfanning
force-pushed
the
hpack-huffman-encode-bytes
branch
from
September 13, 2026 14:10
3ae1383 to
6504bf2
Compare
…yte array Motivation: HuffmanEncoder read its input one char at a time through String.charAt (twice per string literal, once to size it and once to encode it) and wrote its output one byte at a time through OutputStream.write(int), which is synchronized on the ByteArrayOutputStream that HeaderCompression passes in. The per-byte write dominated the cost of HPACK encoding. Modification: HuffmanEncoder now takes byte[] input, as the upstream twitter/hpack code did, and encode returns the coded bytes as an array sized by the length the caller already computed, which Encoder writes in one call. Encoder converts each string literal to octets once and uses them for the length check, the Huffman form and the raw form. That conversion, StringTools.asciiStringBytes, now encodes as ISO-8859-1, the exact inverse of asciiStringFromBytes: a raw literal carrying an opaque octet in 0x80-0xFF now sends that octet where US-ASCII encoding substituted '?'; the Huffman form already sent the octet. MiMa excludes cover the package-private HuffmanEncoder signatures. Result: Encoding a typical 10-header browser request into a ByteArrayOutputStream is ~2.6x faster on JDK 17 (9.6us to 3.6us, see the PR for the JMH table). The raw literal path is unchanged in speed and both literal forms now round-trip every octet through the decoder. Tests: - sbt "http-core/Test/testOnly org.apache.pekko.http.shaded.com.twitter.hpack.HpackEncoderSpec org.apache.pekko.http.impl.util.StringToolsSpec" (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 2 -wi 5 -i 8 -w 1 -r 2 HpackEncoderBenchmark" before and after - sbt http-core/mimaReportBinaryIssues; sbt javafmtCheckAll (JDK 17) - scalafmt --mode diff-ref=upstream/main; sbt headerCreateAll References: Refs apache#1299 - same bulk-copy change for EnhancedString.getAsciiBytes
pjfanning
force-pushed
the
hpack-huffman-encode-bytes
branch
from
September 13, 2026 14:11
6504bf2 to
4433c96
Compare
Member
Author
|
will try to get #1301 merged - which includes this |
pjfanning
added a commit
to pjfanning/incubator-pekko-http
that referenced
this pull request
Sep 13, 2026
…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
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.
Motivation
HuffmanEncoder(the shaded twitter/hpack copy) read its input one char at a time throughString.charAt(i) & 0xFF— twice per string literal, once ingetEncodedLengthto decide whether Huffman is shorter and once inencode— and wrote its output one byte at a time throughOutputStream.write(int). The stream it is given byHeaderCompressionis aByteArrayOutputStream, whosewrite(int)issynchronized, so the per-output-byte call was where the time actually went; profiling thecharAtloop led straight to it.Modification
HuffmanEncoder.getEncodedLengthtakesbyte[], as upstream twitter/hpack does.HuffmanEncoder.encode(byte[] data, int encodedLength)returns the coded bytes as an array of exactlyencodedLengthbytes (the value the caller has already computed) and throwsIllegalArgumentExceptionif the two disagree.Encoderwrites that array with a singleout.write(bytes, 0, len).Encoder.encodeStringLiteralconverts the string to octets once and uses them for the length check, the Huffman form and the raw form.StringTools.asciiStringBytes, now encodes 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.HuffmanEncoderis package-private andStringToolsis@InternalApi, so no public API changes. MiMa 1.2.0 nevertheless reports the twoHuffmanEncodersignatures; they are excluded in2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes.Behaviour change, deliberate: 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-literal 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 (the Huffman form used to truncate it to its low 8 bits, the raw form already produced?).Adds
HpackEncoderSpec(RFC 7541 Appendix C.4 Huffman vectors, C.2.1 raw literal, all-256-octet round trip in both literal forms, length mismatch rejection), extendsStringToolsSpec, and addsHpackEncoderBenchmarkinhttp-bench-jmh.Result
HpackEncoderBenchmark.encodeHeadersencodes a typical 10-header browser request (:method,:path,user-agent,accept,cookie, …, ~430 octets of values) into aByteArrayOutputStreamwith indexing disabled so every value is sent as a string literal. JDK 17.0.19, Apple Silicon laptop,-f 2 -wi 5 -i 8 -w 1 -r 2, average time per request, lower is better:default(Huffman when shorter, the production setting)huffman(forced on)raw(forced off)An intermediate run with only the
String→byte[]input change (still writing per byte to the stream) came in at ~8.9 µs forhuffman, i.e. ~10%: the input side was the smaller half of the problem, the per-bytewrite(int)was the larger.Tests
sbt "http-core/Test/testOnly org.apache.pekko.http.shaded.com.twitter.hpack.HpackEncoderSpec org.apache.pekko.http.impl.util.StringToolsSpec"— 15/15 pass on Scala 2.13.18 and 3.3.8sbt "http2-tests/Test/testOnly ...Http2ServerSpec ...Http2ClientSpec ...RequestParsingSpec"— 212 pass, 20 pending (pre-existing)sbt "http-bench-jmh/Jmh/run -f 2 -wi 5 -i 8 -w 1 -r 2 HpackEncoderBenchmark"onorigin/mainand on this branch — table abovesbt http-core/mimaReportBinaryIssues— clean with the twoHuffmanEncoderexcludessbt javafmtCheckAll(JDK 17),scalafmt --mode diff-ref=upstream/main,sbt headerCreateAll,git diff --check— cleanReferences
Refs #1299 - same bulk-copy change for
EnhancedString.getAsciiBytes