Conversation
pjfanning
added a commit
to pjfanning/incubator-pekko-http
that referenced
this pull request
Sep 13, 2026
…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
added a commit
to pjfanning/incubator-pekko-http
that referenced
this pull request
Sep 13, 2026
…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
…rAt loop Motivation: EnhancedString.getAsciiBytes copied a string into a byte array one character at a time via String.charAt, which on JDK 9+ pays a coder branch and a bounds check per character. Modification: Use String.getBytes(int, int, byte[], int), the JDK primitive with the same low-8-bits semantics. It is a single System.arraycopy for a Latin-1 coded string and one tight loop for a UTF-16 coded one. The method is deprecated (not for removal), so it is annotated with @nowarn("cat=deprecation"). Add EnhancedStringSpec pinning the truncation, offset and short-array behaviour, and a JMH benchmark. Result: asciiBytes is ~1.4-2x faster and getAsciiBytes ~2.6-8.5x faster on JDK 17 (see the PR for the JMH table); rendered bytes are unchanged. Tests: - sbt "http-core/Test/testOnly org.apache.pekko.http.impl.util.EnhancedStringSpec" (Scala 2.13.18 and 3.3.8) - sbt "http-bench-jmh/Jmh/run -f 2 -wi 5 -i 8 -w 1 -r 2 EnhancedStringBenchmark" - sbt http-core/mimaReportBinaryIssues - scalafmt --mode diff-ref=origin/main; sbt headerCreateAll References: None - follow-up to the recent header parsing hardening work
pjfanning
force-pushed
the
enhanced-string-bulk-ascii-bytes
branch
from
September 13, 2026 14:12
85a364c to
7119876
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.
Motivation
EnhancedString.getAsciiBytes(andasciiBytes, which delegates to it) copied aStringinto abyte[]one character at a time throughString.charAt. On JDK 9+ compact strings everycharAtcall carries a coder branch (isLatin1()) plus a bounds check, so the loop shows up as per-character overhead on what is really a bulk copy. These bytes back every rendered status line, header-name prefix,chunked/Keep-Alive/closetoken,LazyValueBytesRenderable/SingletonValueRenderablemodel value, the multipart boundary needle and theSec-WebSocket-Accepthash input.Modification
Use
String.getBytes(int srcBegin, int srcEnd, byte[] dst, int dstBegin), the JDK primitive that copies the low 8 bits of each char straight into an existing array. For a Latin-1 coded string it is a singleSystem.arraycopy; for a UTF-16 coded string it is one tight truncating loop. It is@Deprecated(since 1.1, not for removal), so the method carries@nowarn("cat=deprecation"); the build otherwise turns deprecation warnings into errors.getBytes(ISO_8859_1)/getBytes(US_ASCII)were rejected on purpose: they substitute?for chars above their range instead of truncating, which would silently change rendered output. Bit-exact truncation is also what the outgoing header CR/LF guard inRenderingrelies on — it inspects rendered bytes, so a char whose low byte is0x0Dmust keep rendering as0x0Dto be caught and discarded.One behavioural relaxation: the old loop always filled the array up to
array.length, so a string shorter than the remaining space threwStringIndexOutOfBoundsException. The new code copiesmin(string.length, array.length - offset)bytes. All callers pass exact-size arrays so nothing observes the difference, and the documented contract ("only the portion that fits is copied") is unchanged.Adds
EnhancedStringSpecpinning the ASCII mapping, low-8-bit truncation for non-Latin-1 chars (which forces the UTF-16 coder), offset handling and the short-array/past-the-end cases, plusEnhancedStringBenchmarkinhttp-bench-jmh, which keeps the oldcharAtloop as a baseline.Result
JDK 17.0.19, Apple Silicon laptop,
-f 2 -wi 5 -i 8 -w 1 -r 2, average time, lower is better:charAtloop (asciiBytes)asciiBytesgetAsciiBytes(into existing array)chunked(7 chars)Content-Type: application/json; charset=UTF-8\r\n(48 chars)xasciiBytesstill allocates the result array, which is most of what is left in that column;getAsciiBytesis the pure copy and is where thearraycopyfast path shows (~8.5x on the 1 KiB input). Rendered bytes are identical before and after.Tests
sbt "http-core/Test/testOnly org.apache.pekko.http.impl.util.EnhancedStringSpec"— 7/7 pass on Scala 2.13.18 and 3.3.8sbt "http-bench-jmh/Jmh/run -f 2 -wi 5 -i 8 -w 1 -r 2 EnhancedStringBenchmark"— table abovesbt http-core/mimaReportBinaryIssues— cleanscalafmt --mode diff-ref=origin/main,sbt headerCreateAll,git diff --check— cleanReferences
None - follow-up to the recent header parsing hardening work