Skip to content

Optimize two-part ByteString concatenation#2924

Merged
pjfanning merged 7 commits into
mainfrom
optimize-bytestring-two-part
May 9, 2026
Merged

Optimize two-part ByteString concatenation#2924
pjfanning merged 7 commits into
mainfrom
optimize-bytestring-two-part

Conversation

@He-Pin

@He-Pin He-Pin commented Apr 27, 2026

Copy link
Copy Markdown
Member

Motivation:
Small ByteString concatenation currently promotes two fragments into ByteStrings backed by a Vector. This is expensive for hot paths that build a frame from a header and payload before reading or copying it.

Modification:

  • Add an internal two-fragment ByteString representation for simple non-empty fragments.
  • Keep ByteStrings for three or more fragments by flattening ByteString2 only when needed.
  • Add unchecked internal byte access for already bounds-checked reads.
  • Extend ByteString append benchmarks to cover append, read, copy, and cross-boundary header reads.

Result:

  • ByteStringSpec: 203 tests passed.
  • git diff --check: passed.
  • actor / scalafmtCheck and bench-jmh / scalafmtCheck: passed.
  • mimaReportBinaryIssues: passed.
  • +mimaReportBinaryIssues: passed for Scala 2.13 and Scala 3.3.7.

Focused JMH, JDK 25, short local run with -prof gc:

Benchmark main this branch
appendTwo 67,579 ops/ms, 160 B/op about 503k ops/ms, 32 B/op
appendTwoAndReadHeader 150,154 ops/ms about 482k ops/ms
readTwoPartHeader 249,745 ops/ms 545k-556k ops/ms
appendTwoAndCopyToBuffer 8,596 ops/ms, 112 B/op 12,604 ops/ms, about 0 B/op

Additional A/B on this branch showed byteAtUnchecked improves cross-fragment reads:

Benchmark without unchecked access with unchecked access
readTwoPartCrossBoundaryHeader 244,277 ops/ms 285,612 ops/ms
appendTwoAndReadCrossBoundaryHeader 306,544 ops/ms 338,764 ops/ms

@He-Pin

He-Pin commented Apr 27, 2026

Copy link
Copy Markdown
Member Author

@pjfanning I think this will help the pekko-grpc case

Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala Outdated
@pjfanning

Copy link
Copy Markdown
Member

@hepin this code does not appear to support JDK Serialization of the new class

@pjfanning

pjfanning commented Apr 27, 2026

Copy link
Copy Markdown
Member

@He-Pin there are probably a few cases where we use ++ to append 2 ByteStrings but where we could build up the binary data ourselves in a byte array and create a ByteString.fromArrayUnsafe.
Might help us avoid the complications of adding a ByteString2 class.
Or we could look at having the ++ method overridden on byte array backed ByteString impls so that it tries to return a new byte array backed ByteString where possible instead of ByteStrings instance.

@He-Pin He-Pin marked this pull request as draft April 27, 2026 12:00
Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala
Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala Outdated
@He-Pin He-Pin requested a review from Copilot April 28, 2026 05:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR introduces a specialized internal ByteString representation for the common case of concatenating exactly two non-empty fragments, reducing allocations/flattening on hot paths and extending benchmarks and tests to validate cross-fragment reads.

Changes:

  • Add ByteString2 (two-fragment) to avoid promoting 2-part concatenations into Vector-backed ropes.
  • Add unchecked byte access + new SWAR helpers to speed up already-bounds-checked primitive reads across fragment boundaries.
  • Extend JMH benchmarks and add a targeted spec for cross-boundary primitive reads.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
bench-jmh/src/main/scala/org/apache/pekko/util/ByteString_append_Benchmark.scala Adds benchmarks for 2-part append, header reads (including cross-boundary), and copy-to-buffer/array.
actor/src/main/scala/org/apache/pekko/util/SWARUtil.scala Adds byte-parameter SWAR helpers for assembling primitives without array access.
actor/src/main/scala/org/apache/pekko/util/ByteString.scala Introduces ByteString2, updates concatenation logic, and adds byteAtUnchecked for optimized internal access.
actor/src/main/resources/reference.conf Registers ByteString2 in serialization bindings.
actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala Adds a test for primitive reads across ByteString2 fragment boundaries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala Outdated
Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala
Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala Outdated
Comment thread actor/src/main/scala/org/apache/pekko/util/ByteString.scala
Comment thread actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala Outdated
@He-Pin

He-Pin commented Apr 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the review comments in 3a2bca8:

  • Added a dedicated ByteString2 Java serialization identity/read path and serializer-binding coverage.
  • Enforced ByteString2 as exactly two non-empty simple fragments through the assert-based factory path.
  • Reused the supplied precomputed length in ByteStrings.apply(..., length), including the 0/1/2 fragment fast paths.
  • Cached firstLength on ByteString2 hot paths and delegated primitive reads to the underlying fragment when the value is contiguous; cross-fragment reads still use SWARUtil byte helpers to avoid copying.
  • Replaced the bare ByteString2 cast in the test with an explicit expectation helper.

Local verification passed:

  • sbt "actor-tests/testOnly org.apache.pekko.util.ByteStringSpec" "actor-tests/testOnly org.apache.pekko.serialization.PrimitivesSerializationSpec org.apache.pekko.serialization.ReferenceSerializationSpec org.apache.pekko.serialization.AllowJavaSerializationSpec"
  • sbt "++3.3.7 actor-tests/testOnly org.apache.pekko.util.ByteStringSpec org.apache.pekko.serialization.PrimitivesSerializationSpec org.apache.pekko.serialization.ReferenceSerializationSpec org.apache.pekko.serialization.AllowJavaSerializationSpec"
  • sbt "bench-jmh/compile"
  • sbt scalafmtCheckAll
  • git diff --check

@He-Pin

He-Pin commented Apr 28, 2026

Copy link
Copy Markdown
Member Author

Follow-up pushed in 147572b:

  • Moved the ByteString2 simple-fragment/non-empty/length assertions to the private constructor, so the invariant is checked at the construction point while remaining assertion-only.
  • Reused known result lengths in ByteString2 take/drop/dropRight paths.
  • Resolved the review threads that are covered by the current implementation.

Local verification passed:

  • sbt "actor-tests/testOnly org.apache.pekko.util.ByteStringSpec" "actor-tests/testOnly org.apache.pekko.serialization.PrimitivesSerializationSpec org.apache.pekko.serialization.ReferenceSerializationSpec org.apache.pekko.serialization.AllowJavaSerializationSpec" "++3.3.7 actor-tests/testOnly org.apache.pekko.util.ByteStringSpec org.apache.pekko.serialization.PrimitivesSerializationSpec org.apache.pekko.serialization.ReferenceSerializationSpec org.apache.pekko.serialization.AllowJavaSerializationSpec" "bench-jmh/compile" scalafmtCheckAll
  • git diff --check

@He-Pin He-Pin marked this pull request as ready for review April 28, 2026 14:57
@pjfanning

Copy link
Copy Markdown
Member

For me, this is a lot of new code to suit one or 2 use cases where we build a ByteString from precisely 2 underlying byte strings.
I only know of this potential use case:
https://github.com/apache/pekko-grpc/pull/686/changes#diff-8c921b2d5583d7729d19bc797839f0f5e811a008becb746926a33003ea6a50e6R106
In the end of the day, what this saves us in Vector overhead is probably dwarved many times by the I/O time.

@He-Pin He-Pin marked this pull request as draft May 7, 2026 03:04
@He-Pin

He-Pin commented May 7, 2026

Copy link
Copy Markdown
Member Author

Merged current main into this branch in e6e9f56 and resolved the two conflicts:

  • MetricsBasedResizerSpec: kept the second async send stabilization and retained main's awaitAssert guard before recording the performance log.
  • ByteStringSpec: kept the ByteString2 serialization coverage and main's SerializationProxy round-trip coverage.
  • Also removed one trailing whitespace issue from the merged ClusterShardingCoordinatorRoleSpec so diff checks stay clean.

On the design concern: I left the ByteString2 implementation in place for this draft because the simpler copy-to-compact fast path would trade the Vector allocation for copying payload bytes on header+payload appends. The current approach keeps that use case zero-copy while promoting to ByteStrings as soon as a third fragment is added. If the preferred direction is to keep ByteString smaller and accept the copy for two-fragment appends, I can rework the PR that way.

Local verification passed:

  • sbt "actor-tests / Test / testOnly org.apache.pekko.util.ByteStringSpec org.apache.pekko.routing.MetricsBasedResizerSpec"
  • sbt "actor-tests / Test / testOnly org.apache.pekko.serialization.PrimitivesSerializationSpec org.apache.pekko.serialization.ReferenceSerializationSpec org.apache.pekko.serialization.AllowJavaSerializationSpec"
  • sbt "bench-jmh / Compile / compile"
  • sbt "actor-tests / Test / scalafmt" "actor / Compile / scalafmt" "bench-jmh / Compile / scalafmt"
  • git diff --check
  • git diff --cached --check

All review threads are currently resolved.

@He-Pin He-Pin marked this pull request as ready for review May 7, 2026 03:35
@pjfanning

Copy link
Copy Markdown
Member

Having an extra, 4th, ByteString impl comes with too large a maintenance overhead. Lots more code and lots more tests and easy to miss having test coverage. I don't think there are too many places where we benefit - instances where we concat exactly 2 byte strings that we don't later concat or that have been previously concatenated.

@He-Pin

He-Pin commented May 9, 2026

Copy link
Copy Markdown
Member Author

This is for the case pekko-grpc, where body + headers

@pjfanning

pjfanning commented May 9, 2026

Copy link
Copy Markdown
Member

This is for the case pekko-grpc, where body + headers

I know the use case and it don't think this is worth it. I documented my reasoning in #2924 (comment)

@He-Pin

He-Pin commented May 9, 2026

Copy link
Copy Markdown
Member Author

In Scala stdlib there are seq1234 ,map1234 and set1234, we need make it been well tested covered.

@He-Pin

He-Pin commented May 9, 2026

Copy link
Copy Markdown
Member Author

@pjfanning Fair maintenance concern — let me put numbers behind the use case so we can decide on data, not vibes.

Pushed in c962213:

  1. Removed a LazyList allocation in ByteString2.iterator — replaced LazyList(...) with a 2-element List. That alone ~30× the read-side throughput on iterator.getInt / readIntBE paths and was the reason the pre-built downstream numbers looked unconvincing earlier.
  2. Added a directional JMH benchmark (ByteString_concat3way_Benchmark) that mirrors the gRPC frame shape (5-byte header ++ payload, 64 B / 1 KB / 16 KB / 64 KB / 256 KB) and compares two strategies:
  3. Added a cross-check property test in ByteStringSpec — the same logical bytes built as ByteString1C (reference) and as ByteString2 with every possible split point, asserting equivalence across apply, full byte-by-byte iteration, iterator.getShort/Int/Long (BE+LE) at every offset, readShortBE/LE/readIntBE/LE/readLongBE/LE at every offset, take/drop/takeRight/dropRight/slice at every cut, copyToBuffer, copyToArray, indexOf, compact, equality and hashCode. That should cover the "easy to miss having test coverage" concern — a regression in any read/transform path that diverges from ByteString1C will be caught.

JDK 25, G1GC, single fork, 5×1s measurement. Numbers are ops/μs (higher is better).

Build the frame (the operation pekko-grpc actually does per outgoing message):

size copy bs2
64 B 163.60 501.90 (3.07×)
1 KB 22.82 527.13 (23.10×)
16 KB 1.67 516.72 (308×)
64 KB 0.63 510.53 (815×)
256 KB 0.15 444.32 (3038×)

Build the frame and read the header (parser hot path):

size copy bs2
64 B 147.03 616.02 (4.19×)
1 KB 23.67 609.47 (25.75×)
16 KB 1.97 608.79 (308×)
64 KB 0.59 616.68 (1053×)
256 KB 0.14 612.79 (4367×)

Build the frame and write to a ByteBuffer (Netty / NIO sink):

size copy bs2
64 B 102.93 154.73 (1.50×)
1 KB 17.23 49.49 (2.87×)
16 KB 1.29 2.99 (2.31×)
64 KB 0.34 0.65 (1.95×)
256 KB 0.08 0.25 (2.93×)

Build the frame and drop(5) (strip header) — the inbound side:

size copy bs2
64 B 150.22 909.84 (6.06×)
1 KB 23.67 917.58 (38.76×)
256 KB 0.14 913.26 (6607×)

Where bs2 is slower than copy — pre-built downstream reads on the merged result:

benchmark size copy bs2 bs2 / copy
readIntBE(0) + apply(4) 1 KB 1196.56 730.76 0.61×
iterator.getInt + apply(4) 1 KB 909.99 400.68 0.44×
asByteBuffers 1 KB 200.44 98.90 0.49×

This is the honest cost: when every other op is amortized away, contiguous wins because the bounds check / vtable dispatch is cheaper. But the "build, then do one downstream op" workload — which is what real frame producers and consumers do — wins on bs2 by 2–4000× because we never pay the System.arraycopy. And per pekko-grpc#686, that arraycopy is the alternative we're being compared against.

Re: "I/O dwarfs the difference" — for a streaming RPC at ~1 M small messages/s, the per-frame System.arraycopy(payload) is exactly proportional to throughput in CPU and allocation. On a 16 KB payload that's 16 GB/s of additional memory traffic at 1 M frames/s; on a 64 KB payload, 64 GB/s. That's not nothing even when an I/O wait sits on top.

Happy to drop this if you still don't think the maintenance is worth it — but I wanted to make sure the decision was informed by the post-fix numbers and a reproducible benchmark, since the pre-fix iterator regression made the pre-built ops look much worse than they actually are.

@pjfanning pjfanning force-pushed the optimize-bytestring-two-part branch from c962213 to ac2a6a7 Compare May 9, 2026 13:06
@pjfanning

Copy link
Copy Markdown
Member

@hepin there are commits in this PR for 'stabilising' unrelated tests - can you remove any commits not related to ByteString changes

@He-Pin

He-Pin commented May 9, 2026

Copy link
Copy Markdown
Member Author

Yes, will do

He-Pin added 5 commits May 9, 2026 21:28
Motivation:
Small ByteString concatenation currently promotes two fragments into ByteStrings backed by a Vector, which adds allocation and hurts hot paths that append a header and payload before reading or copying.

Modification:
Add an internal two-fragment ByteString representation for simple non-empty fragments, flatten it when appending beyond two fragments, and add focused JMH coverage for append, read, copy, and cross-boundary header reads.

Result:
JMH shows appendTwo allocation dropping from 160 B/op on main to 32 B/op on this branch, with improved read and copy throughput while ByteStringSpec and MiMa pass.
@pjfanning

Copy link
Copy Markdown
Member

the code is a bit outdated as it doesn't take into account some recent ByteString changes for copyToBuffer, endsWith/startsWith and map. pjfanning#51 has suggested changes.

Motivation:
ByteString2.iterator built MultiByteArrayIterator from `LazyList(...)`. That
allocates two cons cells plus two thunk closures per iterator, and each call to
MultiByteArrayIterator.normalize() walks the LinearSeq and forces a thunk.
For the gRPC-shaped "5-byte header ++ payload" hot path the iterator+read
overhead dominated, so reading via `iterator.getInt` on the result was
~30x slower than the equivalent `ByteString1C` baseline.

Modification:
* ByteString2.iterator now uses a plain two-element `List` -- same shape that
  MultiByteArrayIterator already accepts (LinearSeq[ByteArrayIterator]) but
  with no thunks and half the allocations.
* Add ByteString_concat3way_Benchmark: directional comparison of the two
  candidate strategies for the hot path (`copy` via fromArrayUnsafe vs
  `bs2` via ByteString2.apply) across realistic gRPC payload sizes
  (64B / 1KB / 16KB / 64KB / 256KB), covering concat alone, concat+iterator
  reads, concat+readIntBE, copyToBuffer, asByteBuffers, drop(5)/take(5),
  and the same operations on pre-built results.
* Add a cross-check test in ByteStringSpec that builds the same logical bytes
  as `ByteString1C` (reference) and as `ByteString2` with every possible
  split point, then asserts equivalence across `apply`, full byte-by-byte
  iteration, `iterator.getShort/Int/Long` (BE+LE) at every offset,
  `readShortBE/LE`, `readIntBE/LE`, `readLongBE/LE`, `take/drop/takeRight/
  dropRight/slice`, `copyToBuffer`, `copyToArray`, `indexOf`, `compact`,
  equality and hashCode.

Result:
JMH on JDK 25 G1GC, single fork, 5x1s measurement (ops/us, higher is better):

  benchmark             size      before     after     speedup
  downRead_bs2          64        16.4      730.8      44x
  downRead_bs2          1024      27.2      730.8      27x
  downRead_bs2          16384     25.7      727.5      28x
  downRead_bs2          65536     24.4      731.0      30x
  downRead_bs2          262144    18.4      730.6      40x
  concatRead_bs2        64        27.2      616.0      23x
  concatRead_bs2        1024      16.2      609.5      38x
  concatRead_bs2        16384     21.5      608.8      28x
  concatRead_bs2        65536     22.1      616.7      28x
  concatRead_bs2        262144    25.7      612.8      24x

The previously dominant iterator overhead is gone: ByteString2 read paths
are now within ~1.6x of the contiguous `ByteString1C` baseline (down from
30-50x slower), while concat-and-read is several times faster than the
copy path because we avoid the System.arraycopy. All 212 ByteStringSpec
tests pass including the new cross-check.
@He-Pin He-Pin force-pushed the optimize-bytestring-two-part branch from ae2ae64 to 05fdf87 Compare May 9, 2026 13:48
…tartsWith/endsWith (#2950)

- Add private[pekko] matchesAt to abstract ByteString class (default byte-by-byte impl)
- Override matchesAt in ByteString1C and ByteString1 with SWAR 8-byte comparisons
- Add optimized copyToBuffer(buffer, offset) to ByteString2 (avoids drop() allocation)
- Improve startsWith in ByteString2 to use matchesAt (SWAR-based) instead of byte-by-byte byteAt
- Improve endsWith in ByteString2 to use matchesAt (SWAR-based) instead of byte-by-byte byteAt
- Add optimized map to ByteString2 (pre-allocated array + byteAtUnchecked while-loop)
- foreach in ByteString2 already optimal (delegates to first/second fragments)

Agent-Logs-Url: https://github.com/pjfanning/incubator-pekko/sessions/cf258ced-c5f8-4bef-8ec4-3de15df750d3

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pjfanning <11783444+pjfanning@users.noreply.github.com>

@pjfanning pjfanning left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@pjfanning pjfanning added this to the 2.0.0-M2 milestone May 9, 2026
@pjfanning pjfanning merged commit 29d74ec into main May 9, 2026
9 checks passed
@pjfanning pjfanning deleted the optimize-bytestring-two-part branch May 9, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants