From 7119876d084283e9cad6c40daad651d0f8fe2454 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 13 Sep 2026 13:20:12 +0100 Subject: [PATCH] perf: copy ASCII bytes with the bulk String.getBytes instead of a charAt 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 --- .../impl/util/EnhancedStringBenchmark.scala | 73 +++++++++++++++++++ .../pekko/http/impl/util/EnhancedString.scala | 14 ++-- .../http/impl/util/EnhancedStringSpec.scala | 68 +++++++++++++++++ 3 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 http-bench-jmh/src/main/scala/org/apache/pekko/http/impl/util/EnhancedStringBenchmark.scala create mode 100644 http-core/src/test/scala/org/apache/pekko/http/impl/util/EnhancedStringSpec.scala diff --git a/http-bench-jmh/src/main/scala/org/apache/pekko/http/impl/util/EnhancedStringBenchmark.scala b/http-bench-jmh/src/main/scala/org/apache/pekko/http/impl/util/EnhancedStringBenchmark.scala new file mode 100644 index 0000000000..057cbe4ffb --- /dev/null +++ b/http-bench-jmh/src/main/scala/org/apache/pekko/http/impl/util/EnhancedStringBenchmark.scala @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.http.impl.util + +import java.util.concurrent.TimeUnit + +import scala.annotation.tailrec + +import org.openjdk.jmh.annotations._ + +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@BenchmarkMode(Array(Mode.AverageTime)) +@Fork(2) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +private[util] class EnhancedStringBenchmark { + + @Param(Array("short", "header", "long")) + var input = "header" + + var string: String = null + var target: Array[Byte] = null + + @Setup + def setup(): Unit = { + string = input match { + case "short" => "chunked" + case "header" => "Content-Type: application/json; charset=UTF-8\r\n" + case "long" => "x" * 1024 + } + target = new Array[Byte](string.length + 16) + } + + // the per-character loop that `getAsciiBytes` used before it switched to the bulk `String.getBytes` copy, + // kept for comparison + @tailrec private def charAtLoop(s: String, array: Array[Byte], ix: Int): Unit = + if (ix < array.length) { + array(ix) = s.charAt(ix).asInstanceOf[Byte] + charAtLoop(s, array, ix + 1) + } + + @Benchmark + def baseline_charAt_asciiBytes(): Array[Byte] = { + val array = new Array[Byte](string.length) + charAtLoop(string, array, 0) + array + } + + @Benchmark + def asciiBytes(): Array[Byte] = string.asciiBytes + + @Benchmark + def getAsciiBytes(): Array[Byte] = { + string.getAsciiBytes(target, 8) + target + } +} diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/util/EnhancedString.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/util/EnhancedString.scala index b6c54431ee..3fd309dc4e 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/EnhancedString.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/EnhancedString.scala @@ -17,7 +17,7 @@ import java.util.Locale import org.apache.pekko.annotation.InternalApi -import scala.annotation.tailrec +import scala.annotation.{ nowarn, tailrec } import scala.collection.immutable /** @@ -71,13 +71,13 @@ private[http] class EnhancedString(val underlying: String) extends AnyVal { * Truncates characters to 8-bit byte value. * If the array does not have enough space for the whole string only the portion that fits is copied. */ + // `String.getBytes(int, int, byte[], int)` is deprecated (not for removal) but is the only JDK primitive that copies + // the low 8 bits of each char straight into an existing array: a single `System.arraycopy` for a Latin-1 coded + // string on JDK 9+, instead of a `charAt` call with its coder branch and bounds check per character. + @nowarn("cat=deprecation") def getAsciiBytes(array: Array[Byte], offset: Int): Unit = { - @tailrec def rec(ix: Int): Unit = - if (ix < array.length) { - array(ix) = underlying.charAt(ix - offset).asInstanceOf[Byte] - rec(ix + 1) - } - rec(offset) + val len = math.min(underlying.length, array.length - offset) + if (len > 0) underlying.getBytes(0, len, array, offset) } /** diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/util/EnhancedStringSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/util/EnhancedStringSpec.scala new file mode 100644 index 0000000000..0bfb331118 --- /dev/null +++ b/http-core/src/test/scala/org/apache/pekko/http/impl/util/EnhancedStringSpec.scala @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.pekko.http.impl.util + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class EnhancedStringSpec extends AnyWordSpec with Matchers { + + "EnhancedString.asciiBytes" should { + "map every ASCII character to the byte of the same value" in { + val ascii = new String(Array.tabulate(128)(_.toChar)) + ascii.asciiBytes shouldEqual Array.tabulate(128)(_.toByte) + } + + "truncate a character to its low 8 bits instead of substituting a replacement character" in { + // U+0100 is not Latin-1, so the string uses the UTF-16 coder on JDK 9+; the byte must still be the low 8 bits + // rather than the '?' that encoding with a charset would produce. + "aÿbĀcሴ".asciiBytes shouldEqual Array[Byte]('a', 0xFF.toByte, 'b', 0x00, 'c', 0x34) + } + + "encode the empty string to an empty array" in { + "".asciiBytes shouldEqual Array.emptyByteArray + } + } + + "EnhancedString.getAsciiBytes" should { + "copy the bytes into the target array starting at the offset" in { + val array = Array.fill[Byte](8)('.') + "abc".getAsciiBytes(array, 2) + new String(array, "ISO-8859-1") shouldEqual "..abc..." + } + + "copy only the portion that fits when the array is too small" in { + val array = Array.fill[Byte](4)('.') + "abcdef".getAsciiBytes(array, 2) + new String(array, "ISO-8859-1") shouldEqual "..ab" + } + + "copy nothing when the offset is at or past the end of the array" in { + val array = Array.fill[Byte](3)('.') + "abc".getAsciiBytes(array, 3) + "abc".getAsciiBytes(array, 4) + new String(array, "ISO-8859-1") shouldEqual "..." + } + + "truncate a non-Latin-1 character to its low 8 bits when copying at an offset" in { + val array = new Array[Byte](3) + "Łł".getAsciiBytes(array, 1) + array shouldEqual Array[Byte](0, 0x41, 0x42) + } + } +}