Skip to content

perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream - #1301

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-header-block-buffer
Open

pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:hpack-header-block-buffer

Conversation

@pjfanning

@pjfanning pjfanning commented Sep 13, 2026

Copy link
Copy Markdown
Member

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:

  1. HuffmanEncoder (the shaded twitter/hpack copy) read each string literal one char at a time through String.charAt(i) & 0xFF — twice, once in getEncodedLength to decide whether Huffman is shorter and once in encode.
  2. It wrote its output one byte at a time through OutputStream.write(int), and every other write from Encoder (the 1–3 integer-prefix bytes per header, the bulk raw-literal write) went through the same stream: the ByteArrayOutputStream that HeaderCompression passes in, whose every write is synchronized. The per-output-byte lock was where most of the time went; the per-header locks that remained after fixing that were another ~20%.
  3. The finished block was copied out with toByteArray.

ByteStringOutputStream (#1235) already hands its bytes to a ByteString without copying, but it extends ByteArrayOutputStream (so inherits the synchronized writes) and is one-shot (toByteStringUnsafe forbids reuse).

Modification

  • HuffmanEncoder takes byte[] input, as upstream twitter/hpack does: getEncodedLength(byte[]) and encode(data, dst, dstOffset, encodedLength), which codes straight into a reserved range of the output array and throws IllegalArgumentException if the length the caller computed disagrees with what it produced.
  • Encoder.encodeStringLiteral converts the string to octets once and uses them for the length check, the Huffman form and the raw form. It takes a ByteStringOutputStream instead of any OutputStream (and drops the IOExceptions that could never happen).
  • StringTools.asciiStringBytes 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. 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 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.
  • ByteStringOutputStream becomes a plain OutputStream with unsynchronized write(int) / write(byte[], int, int), plus reserve(length): Int + array (reserve a range for the caller to fill directly) and takeByteString(), which hands the block over and leaves the stream empty for reuse: when most of the buffer is used it wraps the array via ByteString.fromArrayUnsafe and 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" heuristic toByteStringUnsafe had).
  • HeaderCompression calls takeByteString() instead of ByteString.fromArrayUnsafe(os.toByteArray) + reset(). PerMessageDeflate uses takeByteString(); behaviour is identical, it just stops locking per write.
  • One MiMa excludes file, 2.0.x.backwards.excludes/hpack-encoder-bytestring-output-stream.excludes, for the shaded Encoder/HuffmanEncoder signatures MiMa 1.2.0 reports (package-private / shaded internals; ByteStringOutputStream and StringTools are private[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); ByteStringOutputStreamSpec gains reuse-after-handover, reserve/array and negative-capacity cases; StringToolsSpec gains the octet round trip, the ? substitution and the empty string. HpackEncoderBenchmark is added to http-bench-jmh.

Result

HpackEncoderBenchmark.encodeHeaders encodes 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:

mode main after the byte[] Huffman change alone (was #1300) this PR
default (Huffman when shorter, the production setting) 9619 ± 369 ns 3644 ± 121 ns 3522 ± 166 ns
huffman (forced on) 9573 ± 603 ns 3819 ± 316 ns 3569 ± 233 ns
raw (forced off) 2542 ± 106 ns 2525 ± 143 ns 1672 ± 94 ns

≈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.8
  • sbt "http-core/Test/testOnly org.apache.pekko.http.impl.engine.ws.*" — 172 pass, 12 pending (pre-existing); covers PerMessageDeflate in both directions
  • sbt "http2-tests/Test/testOnly ...Http2ServerSpec ...Http2ClientSpec ...RequestParsingSpec" — 212 pass, 20 pending (pre-existing)
  • sbt "http-bench-jmh/Jmh/run HpackEncoderBenchmark" 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 #1235 - the ByteStringOutputStream this generalises; Refs #1300 - superseded by this PR; Refs #1299 - the same bulk-copy idea for EnhancedString.getAsciiBytes

…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
pjfanning force-pushed the hpack-header-block-buffer branch from d8ad862 to bf61666 Compare September 13, 2026 15:03
@pjfanning pjfanning changed the title perf: gather HPACK header blocks in an unsynchronized ByteStringOutputStream perf: encode HPACK header blocks from byte arrays into an unsynchronized ByteStringOutputStream Sep 13, 2026
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