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]

Reply via email to