This is an automated email from the ASF dual-hosted git repository.
He-Pin 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 5fd306e27a perf: resume the ByteStrings fragment hint backward as well
as forward (#3526)
5fd306e27a is described below
commit 5fd306e27a2ed9a7af2ff396ba10188aa370d8fe
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Oct 9 08:39:37 2026 +0100
perf: resume the ByteStrings fragment hint backward as well as forward
(#3526)
Motivation:
`ByteStrings.resolveFragment` resumes the fragment scan from the remembered
fragment only
when the requested offset is *past* it; an offset before the remembered
fragment rescans
the fragment vector from index 0. Byte-wise backward traversal therefore
pays a full
prefix scan at every fragment boundary it crosses — O(fragments²) steps
over the whole
rope. That is the access pattern of `reverseIterator` (the inherited
IndexedSeq
implementation drives `apply` with descending indices) and of
`lastIndexOfSlice`
candidate verification, whose candidate windows move backward through the
rope.
The benchmark comment in `ByteString_byteAtUnchecked_Benchmark` has
documented this
asymmetry since the hint was introduced in #3463: "reverse access does not
benefit
either, since each step lands before the remembered fragment and falls back
to a scan
from the start".
Review turned up a second problem, in the hint itself. `fragmentHint` packs
a fragment
index and that fragment's start offset into one non-volatile long. JLS 17.7
permits a
non-volatile 64-bit field to be read as two 32-bit halves, so the index can
come from one
write and the start from another. `byteAtUnchecked`'s fast path then tests
the offset
against the wrong fragment's length, and that test can pass: the call
returns a byte from
the wrong fragment. Concurrent readers of one `ByteString` are ordinary in
Pekko — a
broadcast or pub-sub fan-out has several consumers reading the same
instance, and every
read updates the hint — so this is a wrong-answer bug, not a theoretical
one. The packing
was introduced to keep the pair together; its comment claimed that made the
pair atomic,
which packing alone does not do.
Modification:
When a valid hint exists and the offset is before the remembered fragment's
start,
`resolveFragment` now walks backward from that fragment instead of scanning
from index 0.
The walk keeps the invariant that `seen` is the start of fragment `pos`,
and terminates at
fragment 0 at the latest, since fragment 0 starts at 0 and `offset >= 0`.
The walk costs at most `hintIdx` steps — the same O(fragments) bound per
call as the
from-zero scan it replaces, but *not* always fewer steps than it: a hint
near the end
paired with an offset near the start walks further than a from-zero scan
would. No distance
heuristic guards against that, because it is the random-access pattern that
neither
strategy serves, while backward *sequential* access — what this branch is
for — costs one
step whatever the fragment count.
`fragmentHint` is now volatile, which is what actually makes a 64-bit read
atomic. Volatile
is for that atomicity, not for ordering: the value is still 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. The cost lands on the write, which happens
only when a lookup
misses — once per fragment crossed, not once per byte — while the hit path
stays a plain
read on x86.
The hit test (already direction-agnostic) and the forward resume are
unchanged.
Result:
Backward traversal is O(1) amortised per boundary crossing, matching
forward, and no reader
can pair one fragment's index with another's start.
Measured with the existing benchmark on one machine (short run, wide error
bars, recorded in
the benchmark file per its convention):
| benchmark | before | after |
| --- | --- | --- |
| `manyFragments_reverse` | 386 ops/s | 39,969 ops/s |
| `manyFragments_sequential` | 43,415 ops/s | 44,624 ops/s |
Reverse access is roughly 100x faster and on par with sequential;
sequential is unchanged
within the noise.
Making the hint volatile costs nothing measurable outside the
one-byte-fragment sequential
case. Paired run toggling only the `@volatile` keyword, `-f2 -wi 5 -i 5`,
with
`manyFragments_map` — which walks the fragments directly and never consults
the hint — as an
in-run control:
| benchmark | plain long | volatile |
| --- | --- | --- |
| `manyFragments_map` *(control)* | 143,209 ± 8,645 ops/s | 139,764 ± 9,328
ops/s |
| `manyFragments_random` | 912 ± 76 ops/s | 902 ± 155 ops/s |
| `manyFragments_reverse` | 43,962 ± 9,780 ops/s | 42,858 ± 949 ops/s |
| `manyFragments_sequential` | 48,990 ± 6,973 ops/s | 41,100 ± 3,683 ops/s |
Random and reverse move by about as much as the control (~2%); only
sequential shows a
possible cost, and its intervals barely separate. Those 1024 one-byte
fragments are the
worst case for it, since the hint is then written on every single access —
at realistic
fragment sizes the write is amortised over the whole fragment.
Tests:
- `sbt "actor-tests/testOnly org.apache.pekko.util.ByteStringSpec"` — 244
passed
- The existing `ByteStrings.byteAtUnchecked` block already exercises
backward, alternating
and multi-threaded access. One new test lands exactly on the first and
last byte of every
fragment from a far-end hint — the boundary arithmetic the backward walk
recomputes,
including its longest case, a far-end hint followed by offset 0.
- No directional test for the torn read: `fragmentHint` is `private[this]`,
so a spec cannot
poison it deterministically, and the tear does not reproduce on a 64-bit
VM. The
multi-threaded block above remains the only cover.
- `manyFragments_reverse` / `manyFragments_sequential` from
`ByteString_byteAtUnchecked_Benchmark` run before and after on the same
machine, plus the
paired volatile comparison above; the benchmark file's results comment
records both.
- `sbt "actor/scalafmtCheckAll" "actor-tests/scalafmtCheckAll"
"bench-jmh/scalafmtCheckAll"` — clean
- MiMa left to the `Check / Binary Compatibility` job: `resolveFragment` is
a `private`
method and `fragmentHint` a `private[this]` field on an `@InternalApi`
class, and
`ByteStrings` serialises through `SerializationProxy`, so the added
modifier cannot reach
the wire.
References:
Refs #3463 — extends the fragment hint introduced there.
---
.../org/apache/pekko/util/ByteStringSpec.scala | 16 ++++++
.../scala/org/apache/pekko/util/ByteString.scala | 60 ++++++++++++++++------
.../ByteString_byteAtUnchecked_Benchmark.scala | 33 +++++++++++-
3 files changed, 90 insertions(+), 19 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 6cb0118ab3..271d982c0e 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
@@ -2088,6 +2088,22 @@ class ByteStringSpec extends AnyWordSpec with Matchers
with Checkers {
rope.iterator.toArray should ===(expected)
}
+ "return the right byte when stepping backward onto fragment boundaries" in
{
+ // the backward resume recomputes fragment starts by subtracting
lengths, so land exactly
+ // on the first and last byte of every fragment, from a hint planted at
the far end
+ val freshRope = fragmentLengths
+ .foldLeft((ByteString.empty, 0)) { case ((acc, offset), len) =>
+ (acc ++ ByteString(expected.slice(offset, offset + len)), offset +
len)
+ }
+ ._1
+ val starts = fragmentLengths.scanLeft(0)(_ + _).init
+ val lasts = fragmentLengths.scanLeft(0)(_ + _).tail.map(_ - 1)
+ freshRope(expected.length - 1) should ===(expected(expected.length - 1))
+ for (s <- starts.reverse) withClue(s"fragment start $s: ")(freshRope(s)
should ===(expected(s)))
+ freshRope(expected.length - 1) should ===(expected(expected.length - 1))
+ for (e <- lasts.reverse) withClue(s"fragment last byte $e:
")(freshRope(e) should ===(expected(e)))
+ }
+
"still reject out of range indices" in {
an[IndexOutOfBoundsException] should be thrownBy rope(-1)
an[IndexOutOfBoundsException] should be thrownBy rope(expected.length)
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 c65ffaa056..ee94979efe 100644
--- a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
+++ b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
@@ -1535,20 +1535,25 @@ object ByteString {
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.
+ // the dominant pattern -- stays on the same fragment or steps to the
neighbouring one, in
+ // either direction, 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
+ // Packed into a single long -- the fragment index in the high 32 bits,
its start offset in the
+ // low 32 -- and volatile so that the two always come from the same write.
JLS 17.7 lets a
+ // non-volatile 64-bit field be read as two 32-bit halves, which would
pair the index of one
+ // fragment with the start of another; the fast path below tests that pair
against the wrong
+ // fragment's length and can pass, returning a byte from the wrong
fragment.
+ //
+ // Volatile is for that atomicity, not for ordering: the value remains
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
+ // resolved mapping never becomes wrong. Reads are the hot path; the write
happens only when a
+ // lookup misses, which is once per fragment crossed rather than once per
byte.
+ //
+ // The end offset is not stored; it is recomputed as start +
fragment.length, a cheap array read.
+ @volatile 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 hint = fragmentHint // single read: index and start below come from
the same write
val hintIdx = (hint >>> 32).toInt
val hintStart = hint.toInt
if (hintIdx >= 0) {
@@ -1566,7 +1571,7 @@ object ByteString {
* the hint uses. `offset` must be within this ByteString.
*/
private def locateFragment(offset: Int): Long = {
- val hint = fragmentHint // single read: index and start below are
mutually consistent
+ val hint = fragmentHint // single read: index and start below come from
the same write
val hintIdx = (hint >>> 32).toInt
val hintStart = hint.toInt
if (hintIdx >= 0 && offset >= hintStart && offset - hintStart <
bytestrings(hintIdx).length) hint
@@ -1576,17 +1581,38 @@ object ByteString {
/**
* Scans for the fragment containing `offset` and records it as the hint.
`hintIdx` and
* `hintStart` are a previously read hint that missed, used to resume the
scan from that
- * fragment rather than from the start.
+ * fragment -- forward or backward, whichever side of it `offset` is on --
rather than
+ * from the start.
*/
private def resolveFragment(offset: Int, hintIdx: Int, hintStart: Int):
Long = {
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
+ if (offset < hintStart) {
+ // Moving backward before the remembered fragment: walk back from
it, keeping `seen` at the
+ // start of fragment `pos`. The hint pair is atomic (see above), so
`seen` is a real
+ // fragment start; fragment 0 starts at 0 and `offset >= 0`, so the
walk stops at fragment
+ // 0 at the latest. At most `hintIdx` steps -- the same O(fragments)
bound per call as the
+ // from-zero scan it replaces, but not always fewer steps than it,
since a far-end hint
+ // with a near-zero offset walks the whole way back. That case is
random access, which
+ // neither strategy serves; backward sequential access, the case
this is for, costs one
+ // step. Hence no distance heuristic.
+ pos = hintIdx
+ seen = hintStart
+ while (offset < seen) {
+ pos -= 1
+ seen -= bytestrings(pos).length
+ }
+ val located = (pos.toLong << 32) | (seen.toLong & 0xFFFFFFFFL)
+ fragmentHint = located
+ return located
+ } else {
+ 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
+ }
}
}
var frag = bytestrings(pos)
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
index 95e2e89325..cbb787552c 100644
---
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
@@ -64,8 +64,37 @@ class ByteString_byteAtUnchecked_Benchmark {
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.
+ reverse access did not benefit at that point, since each step landed before
the remembered
+ fragment and fell back to a scan from the start -- both stayed within the
noise of the
+ previous numbers.
+
+ After resolveFragment also resumes backward from the remembered fragment
(same short run,
+ same wide error bars):
+
+ manyFragments_reverse thrpt 3 386.052 ± 550.638 ops/s (before,
on this machine)
+ manyFragments_reverse thrpt 3 39969.362 ± 49806.718 ops/s (after)
+ manyFragments_sequential thrpt 3 43415.035 ± 45223.244 ops/s (before,
on this machine)
+ manyFragments_sequential thrpt 3 44624.286 ± 42770.609 ops/s (after)
+
+ Reverse access is roughly 100x faster and on par with sequential; sequential
is unchanged
+ within the noise.
+
+ Making the hint volatile, so that its index and start cannot be read as two
halves of
+ different writes (JLS 17.7), costs nothing measurable outside the
one-byte-fragment
+ sequential case. Paired run toggling only the `@volatile` keyword, -f2 -wi 5
-i 5, with
+ manyFragments_map -- which walks the fragments directly and never consults
the hint -- as
+ an in-run control:
+
+ plain long volatile
+ manyFragments_map 143209.374 ± 8645.210 139763.871 ±
9327.740 ops/s
+ manyFragments_random 911.739 ± 76.206 901.787 ±
154.965 ops/s
+ manyFragments_reverse 43961.948 ± 9780.313 42858.330 ±
949.351 ops/s
+ manyFragments_sequential 48989.773 ± 6972.720 41100.395 ±
3682.974 ops/s
+
+ Random and reverse move by about as much as the control (~2%); only
sequential shows a
+ possible cost, and its intervals barely separate. These 1024 one-byte
fragments are the
+ worst case for it, since the hint is then written on every single access --
at realistic
+ fragment sizes the write is amortised over the whole fragment.
*/
private val randomIndices: Array[Int] = {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]