Skip to content

Avoid quadratic cursor scans in fanout publishers - #3730

Merged
He-Pin merged 1 commit into
apache:mainfrom
He-Pin:perf/fanout-publisher-cursor-scan
Oct 30, 2026
Merged

He-Pin merged 1 commit into
apache:mainfrom
He-Pin:perf/fanout-publisher-cursor-scan

Conversation

@He-Pin

@He-Pin He-Pin commented Oct 29, 2026

Copy link
Copy Markdown
Member

Motivation

The GraphStage fanout publisher introduced in #2874 still uses ResizableMultiReaderRingBuffer. When N subscribers advance together, each read of the oldest element scans all N cursors, making cursor maintenance O(N²) per published element.

Modification

  • Track the number of readers at the oldest buffer position and scan only when the last one advances or cancels. Discover the initial count lazily to preserve SubscriberManagement construction order.
  • Maintain the count when a reader joins; preserve buffer growth, element reclamation, backpressure and existing terminal signals.
  • Add a directional complexity regression, cursor lifecycle coverage, a publisher-level joining/cancellation/backpressure test, and JMH micro/end-to-end benchmarks.
  • Document the existing shared-buffer and late-subscriber behavior. No public signatures or dependencies change.

Result

Cursor maintenance for equally paced readers is linear. With 32 readers, the regression reports 1088 cursor reads on the original code and passes the 128-read upper bound with this change.

Local JMH on macOS ARM64, Temurin 17.0.21, Scala 2.13.18; 2 forks, 3 × 1 s warmups and 5 × 1 s measurements per fork. Throughput below is input elements per second, with each element delivered to every subscriber:

Subscribers Publisher before Publisher after Ratio
1 2,341,840 2,259,025 0.96×
2 2,219,048 2,271,793 1.02×
8 1,813,387 2,082,464 1.15×
32 440,255 1,543,406 3.51×
128 28,539 758,668 26.58×

The 1- and 2-subscriber confidence intervals overlap. The ring-buffer microbenchmark improves 13.95× at 32 readers and 62.15× at 128; its single-reader mean is 5% lower, with overlapping confidence intervals. These benchmarks use continuous demand (Long.MaxValue); they do not establish the same gains for per-element requests or slow subscribers. Demand accounting still scans subscriptions.

Reproduce on both revisions with the same benchmark source:

sbt "bench-jmh/Jmh/run -wi 3 -w 1s -i 5 -r 1s -f 2 -t 1 -rf json -rff /tmp/fanout.json .*Fanout.*Benchmark.*"

Tests

  • Original implementation: new complexity regression fails as intended (1088 > 128); other 20 focused tests pass.
  • Updated implementation: stream-tests / Test / testOnly org.apache.pekko.stream.impl.ResizableMultiReaderRingBufferSpec org.apache.pekko.stream.impl.FanoutPublisherBehaviorSpec — 22 passed on JDK 17.
  • Before/after JMH runs — passed, including exact subscriber element-count checks.
  • Expanded Scala/Java stream regression suites (FlowSpec, SinkSpec, PublisherSinkSpec, FlowPublisherSinkSpec, SinkTest, and the two focused specs) — 115 passed.
  • stream-tests-tck / Test / testOnly org.apache.pekko.stream.tck.FanoutPublisherTest — 31 passed; 7 TCK cases marked untested were skipped.
  • headerCreateAll, native scalafmt, sortImports in bench-jmh, checkCodeStyle (including Java formatting and +headerCheckAll), and git diff --check — passed.
  • +mimaReportBinaryIssues — passed across the repository for Scala 2.13.18 and 3.3.9.
  • Fresh independent adversarial review — no must-fix findings. A separate probe against the compiled ring buffer passed 9,928,224 randomized operations, including 103 integer wraparounds and 100 completion scenarios.
  • docs/paradox — passed (existing duplicate-anchor warnings in release notes).
  • Qoder CLI stdout review — no must-fix production-code findings. Its requested sbt +bench-jmh/compile check passed on Scala 2.13.18 and 3.3.9.
  • validatePullRequest — stopped locally; full validation is delegated to GitHub CI. The initial attempt encountered an unrelated macOS ARM64 limitation: leveldbjni-all:1.8 contains an x86_64/i386 native library, causing JournalNoCompactionSpec to exit with UnsatisfiedLinkError.

References

Refs #2874. Related prior BroadcastHub exploration: #1705.

Motivation:
The GraphStage fanout publisher retains a shared ring buffer that scans all
cursors for every equally paced reader, requiring O(N^2) cursor work per element.

Modification:
Count readers at the oldest position and rescan only when the last reader
advances or cancels. Preserve lazy cursor initialization and registration,
add complexity and lifecycle regressions plus JMH coverage, and document
the shared-buffer behavior.

Result:
Cursor maintenance becomes linear for equally paced readers without changing
public APIs or terminal signals. Local JDK 17 publisher benchmarks improve
3.51x with 32 subscribers and 26.58x with 128 under continuous demand.

Tests:
- Original implementation: complexity regression fails as intended, 1088 > 128.
- Updated ring buffer and fanout publisher focused tests: 22 passed.
- Expanded Scala/Java stream regression tests: 115 passed.
- FanoutPublisherTest Reactive Streams TCK: 31 passed, 7 untested cases skipped.
- Native scalafmt, sortImports, checkCodeStyle and git diff --check: passed.
- +mimaReportBinaryIssues (Scala 2.13.18 and 3.3.9) and docs/paradox: passed.
- Before/after JMH: 2 forks, 3 x 1 s warmup, 5 x 1 s measurement, passed.

References:
Refs apache#2874; related prior BroadcastHub exploration apache#1705.
@He-Pin
He-Pin requested review from pjfanning and a balanced review from Copilot and removed request for pjfanning October 29, 2026 18:50
@He-Pin He-Pin added this to the 2.0.0-M5 milestone Oct 29, 2026

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@He-Pin He-Pin added the t:stream Pekko Streams label Oct 29, 2026
@He-Pin
He-Pin requested review from pjfanning and a balanced review from Copilot October 30, 2026 05:27

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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.

🟢 Approval recommended

No active review issues are reported for the change.

No open issues 🎉

Review effort: Lite

pjfanning pushed a commit that referenced this pull request Oct 30, 2026
Motivation:
ActorsLeakSpec failed in PR #3730 CI with Address already in use because
its systems bind the default port 7355 and a hardcoded restart port 7356.

Modification:
Allocate an available port for the first incarnation, reuse its bound
address after termination, and assert that restarts retain the address
with a new UID. Verify that independent systems receive different ports.

Result:
The leak test runs while the former fixed ports are occupied and retains
its same-address quarantine and actor cleanup assertions.

Tests:
- Original implementation fails with BindException while port 7356 is held.
- Scala 2.13.18 and 3.3.9 on JDK 17: both ActorsLeakSpec tests pass while
  ports 7355 and 7356 are held.
- headerCreateAll, checkCodeStyle and git diff --check pass.
- Scala next and validatePullRequest are delegated to GitHub CI.

References:
Refs #3730 - CI failure in ActorsLeakSpec.

@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

@He-Pin
He-Pin merged commit 5b96b83 into apache:main Oct 30, 2026
13 of 16 checks passed
@He-Pin
He-Pin deleted the perf/fanout-publisher-cursor-scan branch October 30, 2026 07:36
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher
performance) and apache#3733 (shard lease release logging).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t:stream Pekko Streams

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants