perf: faster ByteString equality and fragment lookup - #3463
Open
pjfanning wants to merge 3 commits into
Open
Conversation
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.
`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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related performance fixes in
ByteString, in separate commits.1.
equalscompared bytes through the genericSeqpathByteStringinherited equality fromSeq, which walks both sides throughiteratorand boxes everyByte. Comparing two 64KB ByteStrings ran about 60x slower thanjava.util.Arrays.equalsover the same bytes.equalsnow compares the underlying arrays. The comparison is driven from whichever side is a single compacted array so it can reuse the existing SWAR-basedmatchesAt; when neither side is, a newcompareBytesTowalks the fragments in place instead of compacting. Two fragmented ByteStrings are compared with independent cursors, so neither side re-locates a fragment for each run of bytes.ByteStringsalso gains amatchesAtoverride, so the existingindexOfSlice/startsWithpaths no longer fall back to the per-byte implementation inherited fromByteString.hashCodeis untouched. It is content based and independent of the internal layout, so it still agrees with the newequals— the tests assert this across every representation.One caller inside Pekko is artery's
TcpFraming, which validates the 4-byte connection preamble with aSet[ByteString]lookup (TcpFraming.scala:93, againstArterySettings.TcpMagicValues). That is ahashCodeplus anequals, both of which went through the generic path before this change.I want to be precise about the size of that particular win rather than oversell it, because two things limit it.
ReadMagictransitions toReadStreamIdand then toReadFrameand never returns, so the lookup happens once per inbound connection, not per frame. And the magic is only 4 bytes, where the generic path's per-element overhead barely registers — measured at 41ns and essentially zero allocation for the wholeSet.contains, of whichhashCodeis 22ns andequals17ns.hashCodeis unchanged by this PR in any case, so only theequalshalf is affected.So this is a correctness-of-implementation fix that happens to cover that call site, not a fix to a measured framing bottleneck. The framing improvement that actually matters is in commit 2 below, on the per-byte read path.
2.
ByteStringsresolved every index by scanning from the startByteStrings.applyscanned the fragment vector from index 0 on every call, andbyteAtUncheckedwas not overridden, so it fell through toapply. Walking a fragmented ByteString by index was quadratic in the number of fragments.byteAtUncheckednow remembers the fragment resolved by the previous call, so sequential access either stays inside that fragment or resumes the scan from it.applybecomes a bounds check in front ofbyteAtUnchecked, so both share the memoised lookup and the duplicated scan is gone.The remembered triple is held in one immutable object published by a single reference write, so a reader can never observe the start of one fragment paired with the index of another — which three separate
intfields would allow. The field is deliberately notvolatile: it is only a hint, a reader that misses another thread's update just rescans, andByteStringis immutable so a resolved mapping never becomes wrong. There is a test that hammers it from 8 threads.Effect on artery TCP framing
ByteStringParser'sByteReaderreads sequentially viaapply, and theread*Uncheckedhelpers thatByteStringsdoes not override are built fromapply. Simulating that read pattern over a ByteString assembled from 1024 TCP chunks:readByte-stylereadIntLE-styleI looked at adding a
ByteStrings.readIntLEUncheckedoverride on top of this. With the memoised lookup in place it lands within about 2x of the compacted SWAR path, so the remaining headroom is small and I left it out rather than adding an override for a modest gain.Tests
Added to
ByteStringSpec(actor-tests), 10 new tests:hashCodeagreement between all internal representations of the same content — compacted, sliced, two-fragment and multi-fragment — at 17 sizes from 0 to 1000 bytesSeq[Byte]implementations (Vector,List) in both directions, andhashCodeagreement with themSetmembership across representationsbyteAtUncheckedunder forward, backward, repeated and alternating access; agreement withtoArrayand the iterator; out of range indices; and concurrent reads from 8 threadsI verified the new tests actually fail against the un-fixed code by reintroducing a sentinel bug in the fragment hint: 2 of the new tests plus 4 pre-existing ones fail, and all pass once corrected.
actor-tests/testOnly org.apache.pekko.util.*passes (483 tests).sbt actor/mimaReportBinaryIssuesis clean — no public signatures change; the new members areprivateorprivate[pekko].scalafmtrun on all three modules; headers generated withsbt headerCreateAll. I did not run the full stream/remote suites locally and am relying on CI for those.Benchmarks
Two new JMH benchmarks,
ByteString_equals_BenchmarkandByteString_byteAtUnchecked_Benchmark, each carrying measured before/after numbers in a comment in the file, in the style of the existing ByteString benchmarks. Both were run on the same machine with-f1 -wi 3 -i 3; the error bars are wide at that iteration count, and the comments say so. Headline numbers:flat_equal_flat17772 → 70767 ops/s,rope_equal_rope960 → 25819 ops/smanyFragments_sequential651 → 54839 ops/s (~84x)The
byteAtUncheckedbenchmark also records the cases that do not improve — random access is unchanged because the hint never hits, and reverse access falls back to a scan each step. Both stay within noise of the previous numbers.References
actor/src/main/scala/org/apache/pekko/util/ByteString.scalaactor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scalaByteStringParser.ByteReaderinstream/src/main/scala/org/apache/pekko/stream/impl/io/ByteStringParser.scala(the framing read path)