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 d23eb51f3a perf: avoid copying the temp buffer in 
ByteStringBuilder.result (#3471)
d23eb51f3a is described below

commit d23eb51f3aab73a35051e0d2b11629e2ae04606e
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Aug 28 07:30:17 2026 +0100

    perf: avoid copying the temp buffer in ByteStringBuilder.result (#3471)
    
    Motivation:
    `ByteStringBuilder` accumulates writes from `putByte`/`putBytes`/`putInt`/
    `asOutputStream` into a temporary array and flushes it in `clearTemp`, which
    always does `java.util.Arrays.copyOf(_temp, _tempLength)`. On the final 
flush
    performed by `result()` that copy is avoidable: the buffer is not needed for
    further writes at that point, so every builder result pays a full copy of 
its
    tail chunk for nothing.
    
    Modification:
    Add a private `clearTempForResult` used only by `result()`. When more than 
half
    of the temp buffer holds data, the buffer is handed over to the resulting
    `ByteString1` instead of being copied, and `_temp`/`_tempCapacity` are 
reset so
    that a later write to the builder allocates a fresh buffer rather than 
mutating
    the bytes that were handed over. When only a small part of the buffer is 
used it
    is still copied, so a small result does not retain a much larger array. The
    mid-stream `clearTemp` calls made by `addAll` are unchanged, since the 
buffer is
    still reused there.
    
    Result:
    No copy of the tail chunk on `result()` for builders that are at least half
    full. A result produced by the hand-over path can be a non-compact
    `ByteString1`; that is copy-neutral, because a consumer calling 
`toArrayUnsafe`
    on it pays exactly the copy that used to happen inside `clearTemp`, and
    `ByteStringUtils.toProtoByteStringUnsafe` still takes its zero-copy
    `asByteBuffers` path for it.
    
    Tests:
    - `sbt "actor-tests/testOnly org.apache.pekko.util.ByteStringSpec"` - 218 
tests succeeded, 0 failed
    - `ByteStringSpec` gains cases for the mostly-used, exactly-filled and 
small-write-in-a-large-buffer flush paths, that a returned `ByteString` is 
unaffected by later writes to the builder, and that the content is correct for 
every fill level from 0 to 200 bytes
    - `scalafmt --list --mode diff-ref=upstream/main` - no files reported
    - `git diff --check` - clean
    - Broader run of `actor-tests/testOnly org.apache.pekko.util.*`, the stream 
io/compression/framing suites and `actor/mimaReportBinaryIssues` was started 
but stopped before completing, so it is not recorded as a pass. MiMa is 
expected to be unaffected: the change adds one private method and alters no 
signature.
    
    References:
    None - follows the same no-copy approach as apache/pekko-http#1235
---
 .../org/apache/pekko/util/ByteStringSpec.scala     | 56 ++++++++++++++++++++++
 .../scala/org/apache/pekko/util/ByteString.scala   | 23 ++++++++-
 2 files changed, 78 insertions(+), 1 deletion(-)

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 e7d15c7541..70d3e3e06e 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
@@ -2409,6 +2409,62 @@ class ByteStringSpec extends AnyWordSpec with Matchers 
with Checkers {
       (ByteString1(Array[Byte](1, 2, 3), 0, Int.MinValue) should 
be).theSameInstanceAs(ByteString1.empty)
     }
 
+    "ByteStringBuilder.result hands over a mostly used temp buffer without 
copying" in {
+      val builder = ByteString.newBuilder
+      builder.sizeHint(64)
+      val data = Array.tabulate[Byte](40)(i => i.toByte)
+      builder.putBytes(data)
+      val result = builder.result()
+      result should ===(ByteString(data))
+      // the 64 byte buffer is wrapped rather than copied, so the result 
covers only part of its array
+      result.isCompact should ===(false)
+    }
+
+    "ByteStringBuilder.result hands over an exactly filled temp buffer without 
copying" in {
+      val builder = ByteString.newBuilder
+      builder.sizeHint(64)
+      val data = Array.tabulate[Byte](64)(i => i.toByte)
+      builder.putBytes(data)
+      val result = builder.result()
+      result should ===(ByteString(data))
+      result.isCompact should ===(true)
+    }
+
+    "ByteStringBuilder.result copies when only a small part of the temp buffer 
is used" in {
+      val builder = ByteString.newBuilder
+      builder.sizeHint(64)
+      val data = Array.tabulate[Byte](10)(i => i.toByte)
+      builder.putBytes(data)
+      val result = builder.result()
+      result should ===(ByteString(data))
+      // a small result must not retain the much larger buffer
+      result.isCompact should ===(true)
+      result.toArrayUnsafe().length should ===(10)
+    }
+
+    "ByteStringBuilder.result is not affected by later writes to the builder" 
in {
+      val builder = ByteString.newBuilder
+      builder.sizeHint(64)
+      val data = Array.fill[Byte](40)(1)
+      builder.putBytes(data)
+      val first = builder.result()
+      builder.clear()
+      builder.putBytes(Array.fill[Byte](40)(2))
+      val second = builder.result()
+      first should ===(ByteString(data))
+      second should ===(ByteString(Array.fill[Byte](40)(2)))
+    }
+
+    "ByteStringBuilder.result returns the written bytes for any fill level of 
the temp buffer" in {
+      for (n <- 0 to 200) {
+        val builder = ByteString.newBuilder
+        builder.sizeHint(128)
+        val data = Array.tabulate[Byte](n)(i => (i % 128).toByte)
+        builder.putBytes(data)
+        builder.result() should ===(ByteString(data))
+      }
+    }
+
     "ByteStringBuilder.sizeHint does not shrink existing capacity" in {
       val builder = ByteString.newBuilder
       builder.sizeHint(100)
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 af10c991a9..73ffdc6d53 100644
--- a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
+++ b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
@@ -2853,6 +2853,27 @@ final class ByteStringBuilder extends Builder[Byte, 
ByteString] {
     }
   }
 
+  /**
+   * Like `clearTemp` but for the final flush done by `result()`, where the 
temporary buffer does not
+   * have to be kept around for further writes. When most of the buffer holds 
data, it is handed over
+   * to the resulting `ByteString` instead of being copied; the reference to 
it is dropped here so that
+   * a later write to this builder allocates a fresh buffer rather than 
mutating the handed over bytes.
+   * When only a small part of the buffer is used it is still copied, so that 
a small result does not
+   * retain a much larger array.
+   */
+  private def clearTempForResult(): Unit = {
+    if (_tempLength > 0) {
+      if (_tempLength > (_tempCapacity >> 1)) {
+        _builder += ByteString1(_temp, 0, _tempLength)
+        _temp = null
+        _tempCapacity = 0
+      } else {
+        _builder += ByteString1(java.util.Arrays.copyOf(_temp, _tempLength))
+      }
+      _tempLength = 0
+    }
+  }
+
   private def resizeTemp(size: Int): Unit = {
     val newtemp = if (_temp eq null) new Array[Byte](size) else 
java.util.Arrays.copyOf(_temp, size)
     _temp = newtemp
@@ -3090,7 +3111,7 @@ final class ByteStringBuilder extends Builder[Byte, 
ByteString] {
   def result(): ByteString =
     if (_length == 0) ByteString.empty
     else {
-      clearTemp()
+      clearTempForResult()
       val bytestrings = _builder.result()
       if (bytestrings.size == 1)
         bytestrings.head


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

Reply via email to