Skip to content

perf: Huffman encode HPACK string literals from a byte array into a byte array - #1300

Closed
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-huffman-encode-bytes
Closed

pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-huffman-encode-bytes

Conversation

@pjfanning

@pjfanning pjfanning commented Sep 13, 2026

Copy link
Copy Markdown
Member

Motivation

HuffmanEncoder (the shaded twitter/hpack copy) read its input one char at a time through String.charAt(i) & 0xFF — twice per string literal, once in getEncodedLength to decide whether Huffman is shorter and once in encode — and wrote its output one byte at a time through OutputStream.write(int). The stream it is given by HeaderCompression is a ByteArrayOutputStream, whose write(int) is synchronized, so the per-output-byte call was where the time actually went; profiling the charAt loop led straight to it.

Modification

  • HuffmanEncoder.getEncodedLength takes byte[], as upstream twitter/hpack does.
  • HuffmanEncoder.encode(byte[] data, int encodedLength) returns the coded bytes as an array of exactly encodedLength bytes (the value the caller has already computed) and throws IllegalArgumentException if the two disagree. Encoder writes that array with a single out.write(bytes, 0, len).
  • Encoder.encodeStringLiteral converts the string 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 (a single array copy for a Latin-1 coded string on JDK 9+), making it the exact inverse of asciiStringFromBytes, 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.

HuffmanEncoder is package-private and StringTools is @InternalApi, so no public API changes. MiMa 1.2.0 nevertheless reports the two HuffmanEncoder signatures; they are excluded in 2.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 with US_ASCII, which turned any char in 0x80–0xFF into ?. The two forms now agree and both round-trip every octet through Decoder (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), extends StringToolsSpec, and adds HpackEncoderBenchmark in http-bench-jmh.

Result

HpackEncoderBenchmark.encodeHeaders encodes a typical 10-header browser request (:method, :path, user-agent, accept, cookie, …, ~430 octets of values) into a ByteArrayOutputStream with 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:

mode before after
default (Huffman when shorter, the production setting) 9619 ± 369 ns 3644 ± 121 ns 2.6x
huffman (forced on) 9573 ± 603 ns 3819 ± 316 ns 2.5x
raw (forced off) 2542 ± 106 ns 2525 ± 143 ns unchanged

An intermediate run with only the Stringbyte[] input change (still writing per byte to the stream) came in at ~8.9 µs for huffman, i.e. ~10%: the input side was the smaller half of the problem, the per-byte write(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.8
  • sbt "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" on origin/main and on this branch — table above
  • sbt http-core/mimaReportBinaryIssues — clean with the two HuffmanEncoder excludes
  • sbt javafmtCheckAll (JDK 17), scalafmt --mode diff-ref=upstream/main, sbt headerCreateAll, git diff --check — clean

References

Refs #1299 - same bulk-copy change for EnhancedString.getAsciiBytes

@pjfanning
pjfanning force-pushed the hpack-huffman-encode-bytes branch from 3ae1383 to 6504bf2 Compare September 13, 2026 14:10
…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

Copy link
Copy Markdown
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
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