This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git
The following commit(s) were added to refs/heads/main by this push:
new 4265d809a perf: copy ASCII bytes with the bulk String.getBytes instead
of a charAt loop (#1299)
4265d809a is described below
commit 4265d809af4e044d90bed0d283d50e4a7741ae63
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Oct 19 08:41:54 2026 +0100
perf: copy ASCII bytes with the bulk String.getBytes instead of a charAt
loop (#1299)
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
---
.../http/impl/util/EnhancedStringBenchmark.scala | 73 ++++++++++++++++++++++
.../pekko/http/impl/util/EnhancedString.scala | 14 ++---
.../pekko/http/impl/util/EnhancedStringSpec.scala | 68 ++++++++++++++++++++
3 files changed, 148 insertions(+), 7 deletions(-)
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 000000000..057cbe4ff
--- /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 b6c54431e..3fd309dc4 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 000000000..0bfb33111
--- /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)
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]