Skip to content

perf: copy ASCII bytes with the bulk String.getBytes instead of a charAt loop - #1299

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:enhanced-string-bulk-ascii-bytes
Open

pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:enhanced-string-bulk-ascii-bytes

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

EnhancedString.getAsciiBytes (and asciiBytes, which delegates to it) copied a String into a byte[] one character at a time through String.charAt. On JDK 9+ compact strings every charAt call 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/close token, LazyValueBytesRenderable/SingletonValueRenderable model value, the multipart boundary needle and the Sec-WebSocket-Accept hash 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 single System.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 in Rendering relies on — it inspects rendered bytes, so a char whose low byte is 0x0D must keep rendering as 0x0D to 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 threw StringIndexOutOfBoundsException. The new code copies min(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 EnhancedStringSpec pinning 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, plus EnhancedStringBenchmark in http-bench-jmh, which keeps the old charAt loop 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:

input old charAt loop (asciiBytes) asciiBytes getAsciiBytes (into existing array)
chunked (7 chars) 21.97 ± 8.18 ns 16.00 ± 4.02 ns 8.36 ± 0.67 ns
Content-Type: application/json; charset=UTF-8\r\n (48 chars) 31.71 ± 4.34 ns 16.37 ± 1.38 ns 9.00 ± 0.23 ns
1024 × x 229.63 ± 25.53 ns 151.66 ± 4.90 ns 26.86 ± 6.52 ns

asciiBytes still allocates the result array, which is most of what is left in that column; getAsciiBytes is the pure copy and is where the arraycopy fast 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.8
  • sbt "http-bench-jmh/Jmh/run -f 2 -wi 5 -i 8 -w 1 -r 2 EnhancedStringBenchmark" — table above
  • sbt http-core/mimaReportBinaryIssues — clean
  • scalafmt --mode diff-ref=origin/main, sbt headerCreateAll, git diff --check — clean

References

None - follow-up to the recent header parsing hardening work

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
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