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


The following commit(s) were added to refs/heads/main by this push:
     new 3d32394c3a perf: faster ByteString equality and fragment lookup (#3463)
3d32394c3a is described below

commit 3d32394c3a9c5fd922157cf7e868a4a5763cda55
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Aug 28 07:31:04 2026 +0100

    perf: faster ByteString equality and fragment lookup (#3463)
    
    * perf: compare ByteString bytes directly in equals
    
    ByteString inherited equality from Seq, which compares element by element
    through `iterator` and boxes every Byte. Comparing two 64KB ByteStrings
    was around 60x slower than java.util.Arrays.equals on the same bytes.
    
    Override `equals` to compare the underlying arrays. The comparison is
    driven from whichever side is a single compacted array so it can reuse
    the existing SWAR-based `matchesAt`; when neither side is, the new
    `compareBytesTo` walks the fragments in place rather than compacting.
    Two fragmented ByteStrings are compared with independent cursors so
    neither side has to re-locate a fragment for each run of bytes.
    
    `ByteStrings` also gains a `matchesAt` override, so the existing
    `indexOfSlice`/`startsWith` paths no longer fall back to the per-byte
    implementation inherited from ByteString.
    
    `hashCode` is unchanged: it is content based and independent of the
    internal layout, so it continues to agree with the new `equals`.
    
    Tests cover every internal representation (compacted, sliced,
    two-fragment and multi-fragment) against each other, the hashCode
    agreement, content differing in the first and last byte, differing
    lengths, equality with other Seq[Byte] implementations, and Set
    membership across representations.
    
    * perf: memoise the fragment lookup in ByteStrings
    
    `ByteStrings.apply` resolved an index by scanning the fragment vector
    from the start on every call, and `byteAtUnchecked` was not overridden at
    all, so it fell back to `apply`. Any operation that walks a fragmented
    ByteString by index was therefore quadratic in the number of fragments:
    traversing a 1024-fragment ByteString took around 410ms, against 3ms
    once the lookup is memoised.
    
    `byteAtUnchecked` now remembers the fragment resolved by the previous
    call. Sequential access -- the dominant pattern -- either stays inside
    that fragment or resumes the scan from it. `apply` becomes a bounds check
    in front of `byteAtUnchecked`, so both share the memoised lookup and the
    duplicated scan is gone.
    
    The remembered triple is held in a single immutable object and published
    by one reference write. Readers take a single reference and can never
    observe the start of one fragment paired with the index of another, which
    three separate int fields would allow. The field is deliberately not
    volatile: it is only a hint, so a reader that misses another thread's
    update simply rescans, and ByteString is immutable, so a resolved mapping
    never becomes wrong.
    
    This also speeds up the artery TCP framing path: `ByteStringParser`'s
    `ByteReader` reads sequentially with `apply`, and the `read*Unchecked`
    helpers that `ByteStrings` does not override are built from `apply`.
    
    Tests cover forward, backward, repeated and alternating access, the
    agreement with `toArray` and the iterator, out of range indices, and
    concurrent reads from several threads.
    
    * perf: pack the ByteStrings fragment hint into a long
    
    The memoised fragment lookup held its (index, start, end) triple in a
    small object, allocating a fresh one every time the resolved fragment
    changed. Traversing a ByteString of 1024 single-byte fragments allocated
    24 bytes per read; even with 64-byte fragments it was 24 bytes per
    fragment crossing.
    
    Pack the index and start offset into a single long field instead: index
    in the high 32 bits, start in the low 32. A single read still yields a
    consistent pair, so a reader can never combine the index of one fragment
    with the start of another, which separate int fields would allow. The end
    offset is no longer stored and is recomputed as start + fragment.length,
    a cheap array read on the miss path only.
    
    Allocation on the traversal path drops to zero. Throughput is unchanged:
    the two forms measure within run-to-run noise of each other, so this is
    about allocation rather than speed.
---
 .../org/apache/pekko/util/ByteStringSpec.scala     | 198 +++++++++++++++++++++
 .../scala/org/apache/pekko/util/ByteString.scala   | 195 ++++++++++++++++++--
 .../ByteString_byteAtUnchecked_Benchmark.scala     | 114 ++++++++++++
 .../pekko/util/ByteString_equals_Benchmark.scala   | 110 ++++++++++++
 4 files changed, 606 insertions(+), 11 deletions(-)

diff --git 
a/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala 
b/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
index 70d3e3e06e..56e5c6a46d 100644
--- a/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
+++ b/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
@@ -1744,6 +1744,204 @@ class ByteStringSpec extends AnyWordSpec with Matchers 
with Checkers {
     }
   }
 
+  "ByteString equality" must {
+    // Builds the same content in every internal representation: compacted 
(ByteString1C),
+    // a slice of a larger array (ByteString1), a two-fragment pair 
(ByteString2) and a
+    // many-fragment rope (ByteStrings). equals must not depend on which one 
it is handed.
+    def allRepresentations(bytes: Array[Byte]): List[(String, ByteString)] = {
+      val n = bytes.length
+      val whole = ByteString(bytes)
+      var result = List("compact" -> whole.compact, "whole" -> whole)
+      if (n >= 1) {
+        val padded = ByteString(Array[Byte](0x7F, 0x7F)) ++ whole ++ 
ByteString(Array[Byte](0x7F))
+        result ::= "sliced" -> padded.drop(2).dropRight(1)
+        result ::= "per-byte rope" -> bytes.map(b => 
ByteString(Array(b))).reduce(_ ++ _)
+      }
+      if (n >= 2) result ::= "two fragments" -> (ByteString(bytes.take(1)) ++ 
ByteString(bytes.drop(1)))
+      if (n >= 4) {
+        val q = n / 4
+        result ::= "four fragments" -> (ByteString(bytes.slice(0, q)) ++ 
ByteString(bytes.slice(q, 2 * q)) ++
+        ByteString(bytes.slice(2 * q, 3 * q)) ++ ByteString(bytes.slice(3 * q, 
n)))
+        result ::= "uneven fragments" -> (ByteString(bytes.slice(0, 1)) ++ 
ByteString(bytes.slice(1, 3)) ++
+        ByteString(bytes.slice(3, n)))
+      }
+      result
+    }
+
+    val sizes = List(0, 1, 2, 3, 7, 8, 9, 15, 16, 17, 31, 32, 33, 64, 127, 
128, 1000)
+
+    def sample(n: Int): Array[Byte] = Array.tabulate[Byte](n)(i => ((i * 31 + 
7) % 251).toByte)
+
+    "hold between all internal representations of the same content" in {
+      for {
+        n <- sizes
+        bytes = sample(n)
+        (leftName, left) <- allRepresentations(bytes)
+        (rightName, right) <- allRepresentations(bytes)
+      } withClue(s"size $n, $leftName vs $rightName: ") {
+        left should ===(right)
+        right should ===(left)
+      }
+    }
+
+    "agree with hashCode for all internal representations" in {
+      for {
+        n <- sizes
+        bytes = sample(n)
+        (leftName, left) <- allRepresentations(bytes)
+        (rightName, right) <- allRepresentations(bytes)
+      } withClue(s"size $n, $leftName vs $rightName: ") {
+        left.hashCode should ===(right.hashCode)
+      }
+    }
+
+    "distinguish content that differs in the first byte" in {
+      for (n <- sizes if n > 0) {
+        val bytes = sample(n)
+        val differing = bytes.clone()
+        differing(0) = (differing(0) ^ 0xFF).toByte
+        for {
+          (leftName, left) <- allRepresentations(bytes)
+          (rightName, right) <- allRepresentations(differing)
+        } withClue(s"size $n, $leftName vs $rightName: ") {
+          left should !==(right)
+          right should !==(left)
+        }
+      }
+    }
+
+    "distinguish content that differs in the last byte" in {
+      for (n <- sizes if n > 0) {
+        val bytes = sample(n)
+        val differing = bytes.clone()
+        differing(n - 1) = (differing(n - 1) ^ 0xFF).toByte
+        for {
+          (leftName, left) <- allRepresentations(bytes)
+          (rightName, right) <- allRepresentations(differing)
+        } withClue(s"size $n, $leftName vs $rightName: ") {
+          left should !==(right)
+        }
+      }
+    }
+
+    "distinguish content of different lengths" in {
+      for (n <- sizes) {
+        val bytes = sample(n)
+        val longer = ByteString(bytes :+ 1.toByte)
+        for ((name, bs) <- allRepresentations(bytes)) withClue(s"size $n, 
$name: ") {
+          bs should !==(longer)
+          longer should !==(bs)
+        }
+      }
+    }
+
+    "hold against other Seq[Byte] implementations" in {
+      for {
+        n <- sizes
+        bytes = sample(n)
+        (name, bs) <- allRepresentations(bytes)
+      } withClue(s"size $n, $name: ") {
+        bs should ===(bytes.toVector)
+        bytes.toVector should ===(bs)
+        bs should ===(bytes.toList)
+        bs.hashCode should ===(bytes.toVector.hashCode)
+      }
+    }
+
+    "not consider a ByteString equal to a non-Seq value" in {
+      val bs = ByteString(sample(8))
+      bs.equals("not a ByteString") should ===(false)
+      bs.equals(null) should ===(false)
+      bs.equals(42) should ===(false)
+    }
+
+    "let equal ByteStrings of different representations share a Set entry" in {
+      for (n <- sizes if n > 0) {
+        val representations = allRepresentations(sample(n)).map(_._2)
+        withClue(s"size $n: ")(representations.toSet should have size 1)
+      }
+    }
+  }
+
+  "ByteStrings.byteAtUnchecked" must {
+    // byteAtUnchecked memoises the fragment resolved by the previous call, so 
these exercise
+    // forward, backward and random access as well as the transitions between 
them.
+    val fragmentLengths = List(1, 5, 2, 8, 1, 16, 3)
+    val expected: Array[Byte] = {
+      var next = 0
+      fragmentLengths.flatMap { len =>
+        val chunk = Array.tabulate[Byte](len)(i => (next + i).toByte)
+        next += len
+        chunk
+      }.toArray
+    }
+    val rope: ByteString = fragmentLengths
+      .foldLeft((ByteString.empty, 0)) { case ((acc, offset), len) =>
+        (acc ++ ByteString(expected.slice(offset, offset + len)), offset + len)
+      }
+      ._1
+
+    "return the right byte when read forwards" in {
+      for (i <- expected.indices) withClue(s"index $i: ")(rope(i) should 
===(expected(i)))
+    }
+
+    "return the right byte when read backwards" in {
+      for (i <- expected.indices.reverse) withClue(s"index $i: ")(rope(i) 
should ===(expected(i)))
+    }
+
+    "return the right byte for repeated reads of the same index" in {
+      for (i <- expected.indices) {
+        rope(i) should ===(expected(i))
+        rope(i) should ===(expected(i))
+      }
+    }
+
+    "return the right byte when alternating between the ends" in {
+      val last = expected.length - 1
+      for (i <- 0 to last / 2) {
+        rope(i) should ===(expected(i))
+        rope(last - i) should ===(expected(last - i))
+      }
+    }
+
+    "produce the same bytes as toArray and the iterator" in {
+      rope.toArray should ===(expected)
+      rope.iterator.toArray should ===(expected)
+    }
+
+    "still reject out of range indices" in {
+      an[IndexOutOfBoundsException] should be thrownBy rope(-1)
+      an[IndexOutOfBoundsException] should be thrownBy rope(expected.length)
+    }
+
+    "return the right byte when read concurrently from several threads" in {
+      // The memoised fragment is shared mutable state read without 
synchronisation, so a torn
+      // or stale value must never produce a wrong byte.
+      val threads = 8
+      val readsPerThread = 20000
+      val mismatches = new java.util.concurrent.atomic.AtomicInteger(0)
+      val workers = (0 until threads).map { t =>
+        val thread = new Thread(() => {
+          val random = new scala.util.Random(t)
+          var read = 0
+          while (read < readsPerThread) {
+            val i = t % 3 match {
+              case 0 => random.nextInt(expected.length)
+              case 1 => read % expected.length
+              case _ => expected.length - 1 - (read % expected.length)
+            }
+            if (rope(i) != expected(i)) mismatches.incrementAndGet()
+            read += 1
+          }
+        })
+        thread.start()
+        thread
+      }
+      workers.foreach(_.join())
+      mismatches.get should ===(0)
+    }
+  }
+
   "A ByteString" must {
     "have correct size" when {
       "concatenating" in { check((a: ByteString, b: ByteString) => (a ++ 
b).size == a.size + b.size) }
diff --git a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala 
b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
index 73ffdc6d53..bbc47fb3a5 100644
--- a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
+++ b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
@@ -416,6 +416,9 @@ object ByteString {
      * INTERNAL API: compare `len` bytes from this ByteString starting at 
`haystackOffset`
      * against `needle[needleOffset..needleOffset+len)`.
      */
+    private[pekko] override def compareBytesTo(that: ByteString, thatOffset: 
Int): Boolean =
+      that.matchesAt(thatOffset, bytes, 0, length)
+
     private[pekko] override def matchesAt(
         haystackOffset: Int, needle: Array[Byte], needleOffset: Int, len: 
Int): Boolean = {
       var hIdx = haystackOffset
@@ -832,6 +835,18 @@ object ByteString {
      * INTERNAL API: compare `len` bytes from this ByteString starting at 
logical `haystackOffset`
      * against `needle[needleOffset..needleOffset+len)`.
      */
+    private[pekko] override def compareBytesTo(that: ByteString, thatOffset: 
Int): Boolean =
+      that.matchesAt(thatOffset, bytes, startIndex, length)
+
+    /**
+     * INTERNAL API: compares `len` bytes of this fragment starting at 
`thisOffset` against
+     * `other` starting at `otherOffset`. Both sides are single arrays, so 
this goes straight to
+     * the SWAR-based `matchesAt` with no fragment lookup.
+     */
+    private[pekko] def matchesFragmentAt(
+        thisOffset: Int, other: ByteString1, otherOffset: Int, len: Int): 
Boolean =
+      other.matchesAt(otherOffset, bytes, startIndex + thisOffset, len)
+
     private[pekko] override def matchesAt(
         haystackOffset: Int, needle: Array[Byte], needleOffset: Int, len: 
Int): Boolean = {
       var hIdx = startIndex + haystackOffset
@@ -900,6 +915,14 @@ object ByteString {
   }
 
   private[pekko] object ByteStrings extends Companion {
+
+    /**
+     * INTERNAL API: sentinel for `ByteStrings.fragmentHint` meaning "nothing 
resolved yet".
+     * The high 32 bits hold -1, so the hit test and the forward-resume path 
both fail and the
+     * lookup scans from fragment 0.
+     */
+    private[ByteString] final val NoHint: Long = -1L << 32
+
     def apply(bytestrings: Vector[ByteString1]): ByteString =
       apply(bytestrings, bytestrings.foldLeft(0)(_ + _.length))
 
@@ -1468,18 +1491,120 @@ object ByteString {
     if (bytestrings.isEmpty) throw new IllegalArgumentException("bytestrings 
must not be empty")
     if (bytestrings.head.isEmpty) throw new 
IllegalArgumentException("bytestrings.head must not be empty")
 
-    def apply(idx: Int): Byte = {
-      if (0 <= idx && idx < length) {
-        var pos = 0
-        var seen = 0
-        var frag = bytestrings(pos)
-        while (idx >= seen + frag.length) {
-          seen += frag.length
-          pos += 1
-          frag = bytestrings(pos)
+    def apply(idx: Int): Byte =
+      if (0 <= idx && idx < length) byteAtUnchecked(idx)
+      else throw new IndexOutOfBoundsException(idx.toString)
+
+    // Remembers the fragment resolved by the last byteAtUnchecked call, so 
sequential access --
+    // the dominant pattern -- stays on the same fragment or steps to the next 
one instead of
+    // rescanning the fragment vector from index 0 for every byte.
+    //
+    // Packed into a single long so the triple is read and written atomically: 
the fragment index
+    // in the high 32 bits and its start offset in the low 32. A reader can 
therefore never pair
+    // the index of one fragment with the start of another, which separate int 
fields would allow.
+    // The end offset is not stored; it is recomputed as start + 
fragment.length, which is a
+    // cheap array read. The field is deliberately not volatile: it is only a 
hint, so a reader
+    // that misses another thread's update simply rescans, and ByteString is 
immutable, so a
+    // resolved mapping never becomes wrong.
+    private[this] var fragmentHint: Long = ByteStrings.NoHint
+
+    private[pekko] override def byteAtUnchecked(offset: Int): Byte = {
+      val hint = fragmentHint // single read: index and start below are 
mutually consistent
+      val hintIdx = (hint >>> 32).toInt
+      val hintStart = hint.toInt
+      if (hintIdx >= 0) {
+        val frag = bytestrings(hintIdx)
+        if (offset >= hintStart && offset - hintStart < frag.length)
+          return frag.byteAtUnchecked(offset - hintStart)
+      }
+      resolveAndRead(offset, hintIdx, hintStart)
+    }
+
+    private def resolveAndRead(offset: Int, hintIdx: Int, hintStart: Int): 
Byte = {
+      var pos = 0
+      var seen = 0
+      if (hintIdx >= 0) {
+        val hintEnd = hintStart + bytestrings(hintIdx).length
+        if (offset >= hintEnd && hintIdx + 1 < bytestrings.length) {
+          // moving forward past the remembered fragment: resume the scan from 
it
+          pos = hintIdx + 1
+          seen = hintEnd
         }
-        frag(idx - seen)
-      } else throw new IndexOutOfBoundsException(idx.toString)
+      }
+      var frag = bytestrings(pos)
+      while (offset >= seen + frag.length) {
+        seen += frag.length
+        pos += 1
+        frag = bytestrings(pos)
+      }
+      fragmentHint = (pos.toLong << 32) | (seen.toLong & 0xFFFFFFFFL)
+      frag.byteAtUnchecked(offset - seen)
+    }
+
+    private[pekko] override def compareBytesTo(that: ByteString, thatOffset: 
Int): Boolean =
+      that match {
+        case bss: ByteStrings if thatOffset == 0 =>
+          // both sides fragmented: walk them with independent cursors so 
neither side has to
+          // re-locate a fragment by scanning from the start
+          compareFragmented(bss)
+        case _ =>
+          var offset = thatOffset
+          var i = 0
+          while (i < bytestrings.length) {
+            val frag = bytestrings(i)
+            if (!frag.compareBytesTo(that, offset)) return false
+            offset += frag.length
+            i += 1
+          }
+          true
+      }
+
+    /** Compares two equally-sized fragmented ByteStrings in a single pass 
over both. */
+    private def compareFragmented(that: ByteStrings): Boolean = {
+      val mine = this.bytestrings
+      val theirs = that.bytestrings
+      var i = 0
+      var j = 0
+      var iOff = 0
+      var jOff = 0
+      while (i < mine.length) {
+        val a = mine(i)
+        val b = theirs(j)
+        val toCmp = math.min(a.length - iOff, b.length - jOff)
+        if (!a.matchesFragmentAt(iOff, b, jOff, toCmp)) return false
+        iOff += toCmp
+        jOff += toCmp
+        if (iOff == a.length) { i += 1; iOff = 0 }
+        if (jOff == b.length) { j += 1; jOff = 0 }
+      }
+      true
+    }
+
+    /**
+     * Compares against `needle` by walking the fragments covering
+     * `[haystackOffset, haystackOffset + len)`, so each contiguous run uses 
the fragment's own
+     * SWAR-based `matchesAt` instead of the inherited per-byte fallback.
+     */
+    private[pekko] override def matchesAt(
+        haystackOffset: Int, needle: Array[Byte], needleOffset: Int, len: 
Int): Boolean = {
+      if (len == 0) return true
+      var fragIdx = 0
+      var fragOffset = haystackOffset
+      while (fragIdx < bytestrings.length && fragOffset >= 
bytestrings(fragIdx).length) {
+        fragOffset -= bytestrings(fragIdx).length
+        fragIdx += 1
+      }
+      var nIdx = needleOffset
+      val end = needleOffset + len
+      while (nIdx < end) {
+        val frag = bytestrings(fragIdx)
+        val toCmp = math.min(end - nIdx, frag.length - fragOffset)
+        if (!frag.matchesAt(fragOffset, needle, nIdx, toCmp)) return false
+        nIdx += toCmp
+        fragIdx += 1
+        fragOffset = 0
+      }
+      true
     }
 
     /** Avoid `iterator` in performance sensitive code, call ops directly on 
ByteString instead */
@@ -1944,6 +2069,54 @@ object ByteString {
   // Cache the hash code since ByteString is immutable
   override lazy val hashCode: Int = super.hashCode()
 
+  /**
+   * Compares this ByteString to another for equality.
+   *
+   * The inherited `Seq` implementation compares element by element through 
`iterator`, boxing
+   * every `Byte`. This override compares the underlying byte arrays directly, 
reusing the
+   * SWAR-based [[matchesAt]] so that fragmented ByteStrings are compared 
without being compacted.
+   *
+   * Equality with other `Seq[Byte]` implementations is preserved by falling 
back to the inherited
+   * implementation for non-ByteString arguments.
+   */
+  override def equals(other: Any): Boolean = other match {
+    case that: ByteString => (this eq that) || (this.length == that.length && 
sameBytesAs(that))
+    case _                => super.equals(other)
+  }
+
+  /**
+   * INTERNAL API: compares the bytes of two ByteStrings already known to have 
the same length.
+   *
+   * Drives the comparison from whichever side is a single compacted array so 
[[matchesAt]] can
+   * compare 8 bytes at a time; otherwise delegates to [[compareBytesTo]], 
which walks fragments
+   * in place rather than compacting either side.
+   */
+  private def sameBytesAs(that: ByteString): Boolean =
+    if (length == 0) true
+    else
+      that match {
+        case b: ByteString.ByteString1C => this.matchesAt(0, 
b.toArrayUnsafe(), 0, length)
+        case _                          =>
+          this match {
+            case b: ByteString.ByteString1C => that.matchesAt(0, 
b.toArrayUnsafe(), 0, length)
+            case _                          => that.compareBytesTo(this, 0)
+          }
+      }
+
+  /**
+   * INTERNAL API: compares all `length` bytes of this ByteString against 
`that` starting at
+   * `thatOffset`. Overridden by the concrete layouts so each contiguous run 
is compared with
+   * [[matchesAt]] instead of byte by byte.
+   */
+  private[pekko] def compareBytesTo(that: ByteString, thatOffset: Int): 
Boolean = {
+    var i = 0
+    while (i < length) {
+      if (byteAtUnchecked(i) != that.byteAtUnchecked(thatOffset + i)) return 
false
+      i += 1
+    }
+    true
+  }
+
   // override protected[this] def newBuilder: ByteStringBuilder = 
ByteString.newBuilder
 
   // *must* be overridden by derived classes. This construction is necessary
diff --git 
a/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_byteAtUnchecked_Benchmark.scala
 
b/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_byteAtUnchecked_Benchmark.scala
new file mode 100644
index 0000000000..95e2e89325
--- /dev/null
+++ 
b/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_byteAtUnchecked_Benchmark.scala
@@ -0,0 +1,114 @@
+/*
+ * 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.util
+
+import java.util.concurrent.TimeUnit
+
+import org.openjdk.jmh.annotations._
+
+import org.apache.pekko
+import pekko.util.ByteString.{ ByteString1, ByteStrings }
+
+/**
+ * Exercises `ByteStrings.byteAtUnchecked`, which resolves an absolute offset 
to a fragment.
+ *
+ * `ByteStrings.apply` delegates to `byteAtUnchecked` after a bounds check, so 
indexed access,
+ * sequential traversal and `map` all share the memoised fragment lookup.
+ */
+@State(Scope.Benchmark)
+@Measurement(timeUnit = TimeUnit.MILLISECONDS)
+class ByteString_byteAtUnchecked_Benchmark {
+
+  // 1024 single-byte fragments: the worst case for locating a fragment by 
offset
+  val manyFragments: ByteString = ByteStrings(Vector.tabulate(1024)(i => 
ByteString1(Array((i % 251).toByte))))
+
+  // same content, 64 fragments of 16 bytes
+  val fewFragments: ByteString =
+    ByteStrings(Vector.tabulate(64)(i => 
ByteString1(Array.tabulate[Byte](16)(j => ((i * 16 + j) % 251).toByte))))
+
+  val identity: Byte => Byte = b => b
+
+  /*
+  Measured with: bench-jmh/jmh:run -f1 -wi 3 -i 3 -w 1s -r 1s 
.*ByteString_byteAtUnchecked.*
+  (short run; the error bars are wide, but the sequential difference is far 
larger than the noise)
+
+  Before -- every offset was resolved by scanning the fragment vector from the 
start:
+
+  fewFragments_map          thrpt    3  392361.866 ± 1027423.684  ops/s
+  manyFragments_map         thrpt    3   91349.440 ±  123062.017  ops/s
+  manyFragments_random      thrpt    3     508.262 ±     291.734  ops/s
+  manyFragments_reverse     thrpt    3     638.345 ±     498.546  ops/s
+  manyFragments_sequential  thrpt    3     650.823 ±     584.320  ops/s
+
+  After -- byteAtUnchecked remembers the fragment resolved by the previous 
call:
+
+  fewFragments_map          thrpt    3  369418.384 ±  890396.160  ops/s
+  manyFragments_map         thrpt    3  104726.270 ±  117780.717  ops/s
+  manyFragments_random      thrpt    3     596.351 ±     783.657  ops/s
+  manyFragments_reverse     thrpt    3     401.330 ±      52.761  ops/s
+  manyFragments_sequential  thrpt    3   54838.759 ±   29275.476  ops/s
+
+  Sequential access is roughly 84x faster. Random access is unchanged (the 
hint never hits) and
+  reverse access does not benefit either, since each step lands before the 
remembered fragment
+  and falls back to a scan from the start -- both stay within the noise of the 
previous numbers.
+   */
+
+  private val randomIndices: Array[Int] = {
+    val random = new scala.util.Random(0)
+    Array.fill(1024)(random.nextInt(1024))
+  }
+
+  @Benchmark
+  def manyFragments_sequential: Int = {
+    var sum = 0
+    var i = 0
+    while (i < manyFragments.length) {
+      sum += manyFragments(i)
+      i += 1
+    }
+    sum
+  }
+
+  @Benchmark
+  def manyFragments_reverse: Int = {
+    var sum = 0
+    var i = manyFragments.length - 1
+    while (i >= 0) {
+      sum += manyFragments(i)
+      i -= 1
+    }
+    sum
+  }
+
+  @Benchmark
+  def manyFragments_random: Int = {
+    var sum = 0
+    var i = 0
+    while (i < randomIndices.length) {
+      sum += manyFragments(randomIndices(i))
+      i += 1
+    }
+    sum
+  }
+
+  @Benchmark
+  def manyFragments_map: ByteString = manyFragments.map(identity)
+
+  @Benchmark
+  def fewFragments_map: ByteString = fewFragments.map(identity)
+}
diff --git 
a/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_equals_Benchmark.scala
 
b/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_equals_Benchmark.scala
new file mode 100644
index 0000000000..092532d462
--- /dev/null
+++ 
b/bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_equals_Benchmark.scala
@@ -0,0 +1,110 @@
+/*
+ * 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.util
+
+import java.util.concurrent.TimeUnit
+
+import org.openjdk.jmh.annotations._
+
+@State(Scope.Benchmark)
+@Measurement(timeUnit = TimeUnit.MILLISECONDS)
+class ByteString_equals_Benchmark {
+
+  private def bytes(n: Int): Array[Byte] = Array.tabulate[Byte](n)(i => (i % 
251).toByte)
+
+  private def fragmented(fragments: Int, fragmentSize: Int): ByteString = (0 
until fragments).foldLeft(
+    ByteString.empty) { (acc, i) =>
+    acc ++ ByteString(Array.tabulate[Byte](fragmentSize)(j => ((i * 
fragmentSize + j) % 251).toByte))
+  }
+
+  // compacted, single backing array
+  val flatA: ByteString = ByteString(bytes(64 * 1024)).compact
+  val flatB: ByteString = ByteString(bytes(64 * 1024)).compact
+
+  // same content, but spread over 1024 fragments
+  val ropeA: ByteString = fragmented(1024, 64)
+  val ropeB: ByteString = fragmented(1024, 64)
+
+  // differs in the very first byte: measures the early-exit path
+  val flatDiffersFirst: ByteString = {
+    val b = bytes(64 * 1024)
+    b(0) = (b(0) ^ 0xFF).toByte
+    ByteString(b).compact
+  }
+
+  // differs only in the last byte: forces a full scan before returning false
+  val flatDiffersLast: ByteString = {
+    val b = bytes(64 * 1024)
+    b(b.length - 1) = (b(b.length - 1) ^ 0xFF).toByte
+    ByteString(b).compact
+  }
+
+  val smallA: ByteString = ByteString(bytes(8))
+  val smallB: ByteString = ByteString(bytes(8))
+
+  /*
+  Measured with: bench-jmh/jmh:run -f1 -wi 3 -i 3 -w 1s -r 1s 
.*ByteString_equals.*
+  (short run; the wide error bars reflect the low iteration count, the 
differences are far larger)
+
+  Before -- equality inherited from Seq, comparing element by element through 
iterator and
+  boxing every Byte:
+
+  ByteString_equals_Benchmark.flat_differs_first  thrpt    3  181247559.958 ± 
61693364.344  ops/s
+  ByteString_equals_Benchmark.flat_differs_last   thrpt    3       6427.248 ±  
   4360.116  ops/s
+  ByteString_equals_Benchmark.flat_equal_flat     thrpt    3      17772.167 ±  
   6425.067  ops/s
+  ByteString_equals_Benchmark.flat_equal_rope     thrpt    3       1938.294 ±  
   1108.938  ops/s
+  ByteString_equals_Benchmark.rope_equal_rope     thrpt    3        959.603 ±  
   2045.986  ops/s
+  ByteString_equals_Benchmark.small_equal         thrpt    3   53499809.406 ± 
21113673.795  ops/s
+
+  After -- array comparison reusing the SWAR-based matchesAt, plus the 
memoised fragment
+  lookup in ByteStrings.byteAtUnchecked:
+
+  ByteString_equals_Benchmark.flat_differs_first  thrpt    3  289835597.355 ± 
76041571.839  ops/s
+  ByteString_equals_Benchmark.flat_differs_last   thrpt    3      92604.106 ±  
  50614.043  ops/s
+  ByteString_equals_Benchmark.flat_equal_flat     thrpt    3      70767.215 ±  
  47918.719  ops/s
+  ByteString_equals_Benchmark.flat_equal_rope     thrpt    3      32567.330 ±  
  16392.230  ops/s
+  ByteString_equals_Benchmark.rope_equal_rope     thrpt    3      25819.355 ±  
  15453.821  ops/s
+  ByteString_equals_Benchmark.small_equal         thrpt    3  106841120.007 ± 
11546486.517  ops/s
+
+  hashCode is unchanged by this work and is included only to show it stays 
flat.
+   */
+
+  @Benchmark
+  def flat_equal_flat: Boolean = flatA == flatB
+
+  @Benchmark
+  def rope_equal_rope: Boolean = ropeA == ropeB
+
+  @Benchmark
+  def flat_equal_rope: Boolean = flatA == ropeB
+
+  @Benchmark
+  def flat_differs_first: Boolean = flatA == flatDiffersFirst
+
+  @Benchmark
+  def flat_differs_last: Boolean = flatA == flatDiffersLast
+
+  @Benchmark
+  def small_equal: Boolean = smallA == smallB
+
+  @Benchmark
+  def flat_hashCode: Int = flatA.hashCode
+
+  @Benchmark
+  def rope_hashCode: Int = ropeA.hashCode
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to