Skip to content

optimize: avoid Holder allocation in ordered mapAsync for already-completed futures#3018

Merged
He-Pin merged 1 commit into
apache:mainfrom
He-Pin:optimize-mapasync-sync-completed-fastpath
May 31, 2026
Merged

optimize: avoid Holder allocation in ordered mapAsync for already-completed futures#3018
He-Pin merged 1 commit into
apache:mainfrom
He-Pin:optimize-mapasync-sync-completed-fastpath

Conversation

@He-Pin

@He-Pin He-Pin commented May 31, 2026

Copy link
Copy Markdown
Member

Motivation

Ordered MapAsync allocates a Holder and performs a buffer enqueue/dequeue round-trip for every element, even when the future returned by the user function is already completed successfully and could be emitted immediately. With a synchronously-completing function (caching, Future.successful, conditional async, ...) this is pure per-element garbage.

Note: mapAsync(1) delegates to mapAsyncUnordered(1) (Flow.scala), and MapAsyncUnordered already pushes completed results straight through without a Holder. The allocation only existed in the ordered stage at parallelism >= 2.

Modification

In MapAsync.onPush, add a zero-allocation fast path: when the future is already completed with a non-null element, nothing is buffered ahead of it (so ordering is already satisfied) and downstream demand is present, push the element straight through without allocating a Holder or touching the buffer.

The not-yet-completed (None) case is matched first, so the pending path is unchanged: a single type check that never evaluates the fast-path guard (behaviourally and cost-wise identical to before).

Result

JMH MapAsyncBenchmark (parallelism=4, spawn=false): 18.0M -> 20.3M ops/s (+12%), with the per-element Holder allocation eliminated (confirmed via async-profiler alloc profile: Holder disappears; remaining allocation is the user's Success box from Future.successful). No regression on the asynchronous path (spawn=true, within run-to-run noise).

Tests

  • sbt "stream-tests/testOnly *FlowMapAsyncSpec" — 27/27 green. Existing coverage already exercises the fast path: "produce future elements in order" mixes Future.successful with async futures, and mapAsync(2)(Future.successful(_)) drives ordered + already-completed.
  • sbt stream/Compile/compile, stream/scalafmt, stream/headerCheck, stream/mimaReportBinaryIssues — all green (binary compatible, internal API only).

References

  • bench-jmh MapAsyncBenchmark
  • Relates to the existing already-completed-future optimization (#20217).

…pleted futures

Motivation:
Ordered `MapAsync` allocates a `Holder` and performs a buffer enqueue/dequeue
round-trip for every element, even when the future returned by the user function
is already completed successfully and can be emitted immediately. With a
synchronously-completing function (caching, `Future.successful`, ...) this is
pure per-element garbage.

Modification:
In `MapAsync.onPush`, add a zero-allocation fast path: when the future is already
completed with a non-null element, nothing is buffered ahead of it (so ordering is
already satisfied) and downstream demand is present, push the element straight
through without allocating a `Holder` or touching the buffer. The not-yet-completed
case is matched first so the pending path is unchanged (single type check, never
evaluates the fast-path guard).

Result:
JMH MapAsyncBenchmark (parallelism=4, spawn=false): 18.0M -> 20.3M ops/s (+12%),
with the per-element `Holder` allocation eliminated (confirmed via async-profiler
alloc profile). No regression on the asynchronous path (spawn=true). All 27
FlowMapAsyncSpec tests pass; binary compatible (internal API).

References:
bench-jmh MapAsyncBenchmark; relates to the existing already-completed-future
optimization (#20217).
@He-Pin He-Pin requested a review from Copilot May 31, 2026 08:53
@He-Pin He-Pin added the t:stream Pekko Streams label May 31, 2026
@He-Pin He-Pin added this to the 2.0.0-M3 milestone May 31, 2026
@He-Pin He-Pin requested a review from pjfanning May 31, 2026 08:53

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

This PR optimizes the ordered MapAsync GraphStage to avoid per-element Holder allocation and buffer round-trips when the user function returns an already-successfully-completed Future and ordering is trivially satisfied (no buffered elements ahead, downstream demand present).

Changes:

  • Adds a fast path in MapAsync.onPush to immediately push already-completed successful (non-null) results when buffer.isEmpty and isAvailable(out).
  • Keeps the pending (None) path behavior/cost essentially unchanged by matching it first, and retains existing failure/supervision handling for other completed cases.

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

@He-Pin He-Pin modified the milestones: 2.0.0-M3, 2.0.0-M4, 2.0.0-maybes May 31, 2026

@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 61c3d96 into apache:main May 31, 2026
9 checks passed
@He-Pin He-Pin deleted the optimize-mapasync-sync-completed-fastpath branch May 31, 2026 13:21
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