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 ba4e950edb Optimize lazy stage actor dispatch (#3035)
ba4e950edb is described below
commit ba4e950edb5407eb5912571bbbf5cab78b0b654e
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Tue Jun 16 01:04:57 2026 +0800
Optimize lazy stage actor dispatch (#3035)
* fix: optimize lazy stage actor dispatch via MPSC drain coalescing
Motivation:
Lazy `getStageActor` refs paid one actor mailbox enqueue per external tell:
sender -> FunctionRef -> ConcurrentAsyncCallback.invokeWithPromise ->
interpreter
self ! AsyncInput. Under high tell rate to a single stage actor the
bottleneck
is mailbox traffic (envelope alloc, cross-thread wakeup, dequeue), not the
dispatch lambda. Each tell also allocated a Tuple2, an AsyncInput, and a
mailbox Envelope.
Modification:
Lazy `getStageActor` now installs an MPSC dispatch (`LazyDispatch`) that:
- enqueues (sender, msg) into a Vyukov MPSC queue (`AbstractNodeQueue`)
- elects a single drain via IDLE -> SCHEDULED CAS; only the elected
producer pays a mailbox enqueue
- drains on the interpreter thread in a tight loop bounded by
`stage-actor-drain-batch` (default 16), then either publishes IDLE
(with the canonical recheck race fix) or re-schedules another envelope
so other BoundaryEvents interleave naturally via the actor mailbox
- preserves `isStageCompleted` semantics: items added after completion are
dropped exactly as the old per-tell path silently skipped them.
The eager construction path (used before stream demand) is unchanged and
still routes through the materializer supervisor + `AsyncCallback`.
JIT/GC notes:
- `LazyDispatch` is a `final class` and extends `AbstractNodeQueue`
directly so it is its own queue (one fewer allocation and field deref).
- `scheduledState` is a plain `@volatile var Int` driven by a static
`VarHandle` (created via `MethodHandles.privateLookupIn`), avoiding the
per-instance `AtomicBoolean` wrapper. Same pattern as
`AbstractNodeQueue` itself.
- The dispatch `apply` is monomorphic per StageActor instance; the drain
callback is allocated once and reused. The FunctionRef lambda is
rewritten as `(sender, msg) =>` to skip the Tuple2 allocation on the
PoisonPill / Kill warning path.
- Per-tell allocation is now 1 Node + 1 Tuple2 (the Tuple2 is forced by
the public `StageActorRef.Receive` type); AsyncInput and Envelope are
amortized across the batch.
Configuration:
`pekko.stream.materializer.stage-actor-drain-batch` (default 16) bounds the
per-envelope drain. The default aligns with `InputBuffer.max` and keeps the
per-actor-wakeup work in the same order of magnitude as the dispatcher
throughput; smaller values trade tell throughput for tighter interleaving
with upstream/downstream events, larger values do the opposite.
Binary compatibility:
The original 4-arg `private[pekko] StageActor` constructor
(`materializer, getAsyncCallback, initialReceive, name`) is preserved as
an auxiliary constructor and continues to use the eager
`AsyncCallback` path. A new 5-arg `private[pekko]` constructor
(`materializer, interpreter, logic, initialReceive, name`) is added for the
lazy path. `sbt stream/mimaReportBinaryIssues` passes clean.
Result:
`StageActorRefBenchmark.lazy_stage_actor_ref_tell_10k` (JMH 2 forks x 10
iter x 2s, macOS) - throughput is now bounded by Vyukov enqueue + drain
loop rather than per-tell mailbox traffic:
| Variant | Throughput (ops/s) | vs main |
|----------------------------------|----------------------|---------|
| main | 6,587,561 +- 616,243 | 1.00x |
| MPSC + drain coalescing (cap=16) | 13,044,829 +- 1,525K | 1.98x |
| MPSC + drain coalescing (cap=8) | 13,589,612 +- 2,114K | 2.06x |
BroadcastHubBenchmark is unchanged in this measurement (its bottleneck is
fan-out broadcasting, not stage-actor tell traffic).
Tests:
- sbt "stream / compile" "stream / mimaReportBinaryIssues"
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.scaladsl.StageActorRefSpec"
(11/11)
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.scaladsl.ActorRefSinkSpec
org.apache.pekko.stream.scaladsl.ActorRefSourceSpec
org.apache.pekko.stream.scaladsl.ActorRefBackpressureSinkSpec
org.apache.pekko.stream.scaladsl.ActorRefBackpressureSourceSpec" (42/42)
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.scaladsl.QueueSinkSpec
org.apache.pekko.stream.scaladsl.QueueSourceSpec
org.apache.pekko.stream.scaladsl.HubSpec" (94/94)
- sbt scalafmt headerCheck
- sbt "bench-jmh / Jmh / run -i 10 -wi 5 -f 2 -r 2s -w 2s
.*StageActorRefBenchmark.*"
References:
Refs https://github.com/akka/akka-core/issues/26857 (public issue only;
clean-room implementation)
* fix: keep lazy stage actor refs under supervisor
Motivation:
PR #3035 moved lazy stage actor FunctionRefs under the graph interpreter
actor. During ActorGraphInterpreter preStart the interpreter context can still
be backed by an UnstartedCell, causing CI failures in TCP/TLS stream stages
that create stage actors during preStart.
Modification:
Register lazy stage actor FunctionRefs under the stream supervisor again,
while keeping the new LazyDispatch path that drains messages through the graph
interpreter. Update StageActorRefSpec to assert that the FunctionRef parent
remains the supervisor and that message handling still runs in the interpreter.
Result:
TCP/TLS stages can create stage actors during interpreter startup without
hitting UnstartedCell, while lazy stage actor messages still execute on the
graph interpreter thread.
Tests:
- scalafmt --mode diff-ref=e04e721ab67d3418621ba568601bea2c5483c475
--non-interactive
- scalafmt --list --mode diff-ref=e04e721ab67d3418621ba568601bea2c5483c475
--non-interactive
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.scaladsl.StageActorRefSpec"
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.io.TlsGraphStageSpec"
- git diff --check
References:
Refs #3035
* fix: guard LazyDispatch producer path against post-completion enqueue
Motivation:
Once a lazy stage actor's underlying stage is completed,
`GraphInterpreter.runAsyncInput`
short-circuits the drain handler. Without a producer-side check the
CAS-winning producer
left `state=SCHEDULED` forever, subsequent producers skipped the mailbox
push, and the
MPSC queue could grow unbounded until the stage actor was GC'd.
Modification:
- `LazyDispatch.apply` pre-checks `interpreter.isStageCompleted(logic)` and
drops the
message, matching the original per-tell behaviour where `runAsyncInput`
silently
ignored post-completion sends.
- After winning the IDLE -> SCHEDULED CAS, re-check completion before
scheduling: if
completion landed in between, reset state to IDLE rather than posting an
envelope
that the interpreter would skip.
- Fix missing space in the PoisonPill / Kill warning so the concatenated
message reads
"...not a real Actor. Use ..." instead of "...Actor.Use ...".
Result:
- Post-completion sends are silently dropped and the MPSC queue cannot grow
unbounded.
- StageActorRefSpec, ActorRefSink/Source, ActorRefBackpressureSink/Source,
QueueSink/
Source and HubSpec all pass (125/125).
- `sbt stream/mimaReportBinaryIssues` clean.
References:
Refs #3035 (Copilot review comments).
* fix: drop queue and skip drain re-schedule when stage completes mid-batch
Motivation:
After processing `drainBatchSize` items, `LazyDispatch.drain`
unconditionally posted
another drain envelope so other BoundaryEvents could interleave. If the last
`handler(item)` call in the batch completed the stage (e.g. user code called
`completeStage()`), `GraphInterpreter.runAsyncInput` would skip the freshly
scheduled envelope, leaving `state=SCHEDULED` forever and retaining any
already-enqueued items until the FunctionRef was stopped.
Modification:
Mirror the existing mid-loop completion branch at the post-batch tail: check
`interpreter.isStageCompleted(logic)`, and if true, drain the remainder,
publish
IDLE and return without re-scheduling.
Result:
The MPSC queue is always drained (or cleared) before the dispatch goes
idle, even
when completion lands on the last handled item of a batch.
StageActorRefSpec,
stream/compile, scalafmt and stream/mimaReportBinaryIssues all clean.
References:
Refs #3035 (Copilot review on da487e3c74).
* fix: replace AtomicInteger with VarHandle and remove drainCallback in
LazyDispatch
Motivation:
Per-instance AtomicInteger wrapper costs ~16 bytes per StageActor.
Separate drainCallback lambda is an unnecessary allocation.
Modification:
- Replace AtomicInteger state field with @volatile var Int driven by
static VarHandle in companion object (MethodHandles.privateLookupIn),
same pattern as AbstractNodeQueue._tailDoNotCallMeDirectly.
- LazyDispatch now extends Any => Unit directly, serving as both
producer callback and drain callback, eliminating the drainCallback
lambda allocation.
- Remove private[this] (deprecated in Scala 3).
Result:
Each LazyDispatch instance saves ~16 bytes (AtomicInteger) + one
lambda allocation (drainCallback). VarHandle lives on the companion
object (true JVM static), shared across all instances.
Tests:
- sbt "stream / compile" "stream / mimaReportBinaryIssues"
- sbt "stream-tests / Test / testOnly
org.apache.pekko.stream.scaladsl.StageActorRefSpec" (11/11)
Refs: #3035
* fix: type-witness VarHandle.get for Scala 3 cross-compile
Motivation:
The previous commit re-introduced VarHandle in LazyDispatch to avoid
per-instance
AtomicInteger overhead, but Scala 3's strict inference cannot pick the
Int-returning
signature-polymorphic overload of VarHandle.get without explicit
return-type context,
breaking the Binary Compatibility and Tests (3.3.x) jobs on PR #3035 with
"Values of types Object and Int cannot be compared with == or !=" at
GraphStage.scala:391.
Modification:
Read the state through a typed local (val cur: Int = u.get(this)) so the
call site
witnesses the Int-returning overload. Compiles on both Scala 2.13 and Scala
3.3.
Keeps the VarHandle (no per-instance AtomicInteger) and the double-checked
plain-read
fast path under producer contention.
Result:
Scala 2.13 + Scala 3.3 compile clean, stream/mimaReportBinaryIssues passes,
scalafmt
unchanged, StageActorRefSpec 11/11 green.
* fix: encapsulate VarHandle access and clean up LazyDispatch style
Motivation:
PR review feedback from pjfanning:
1. `apply` method used `asInstanceOf[AnyRef] eq null`, semicolons on
one line, and early `return` — non-idiomatic Scala style.
2. VarHandle accessed directly via companion `stateHandle` field from
call sites — should be encapsulated behind methods on LazyDispatch.
Modification:
- Add private `getState()/setState()/casState()` methods on LazyDispatch
that encapsulate all VarHandle access. The typed local in `getState()`
witnesses the Int-returning signature-polymorphic overload for Scala 3.
- Restructure `apply` to use `if/else` instead of early `return`,
`msg == null` instead of `msg.asInstanceOf[AnyRef] eq null`, and no
semicolons.
- Replace all `val u = LazyDispatch.stateHandle; u.xxx(this, ...)`
patterns in `drain()` with the new accessor methods.
Result:
VarHandle is retained (saves ~16 bytes/instance vs AtomicInteger) but
fully encapsulated. Code style aligned with project conventions.
Compilation clean on Scala 2.13.
References:
Refs #3035 (pjfanning review comments).
---
.../pekko/stream/StageActorRefBenchmark.scala | 136 +++++++++++++
.../pekko/stream/scaladsl/StageActorRefSpec.scala | 118 ++++++++++-
stream/src/main/resources/reference.conf | 7 +
.../org/apache/pekko/stream/stage/GraphStage.scala | 222 ++++++++++++++++++---
4 files changed, 456 insertions(+), 27 deletions(-)
diff --git
a/bench-jmh/src/main/scala/org/apache/pekko/stream/StageActorRefBenchmark.scala
b/bench-jmh/src/main/scala/org/apache/pekko/stream/StageActorRefBenchmark.scala
new file mode 100644
index 0000000000..007253e275
--- /dev/null
+++
b/bench-jmh/src/main/scala/org/apache/pekko/stream/StageActorRefBenchmark.scala
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.stream
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+import scala.concurrent.Await
+import scala.concurrent.Promise
+import scala.concurrent.duration._
+
+import org.openjdk.jmh.annotations._
+
+import org.apache.pekko
+import pekko.actor.ActorRef
+import pekko.actor.ActorSystem
+import pekko.actor.NoSerializationVerificationNeeded
+import pekko.stream.scaladsl.Keep
+import pekko.stream.scaladsl.Sink
+import pekko.stream.scaladsl.Source
+import pekko.stream.stage.GraphStageLogic
+import pekko.stream.stage.GraphStageWithMaterializedValue
+import pekko.stream.stage.InHandler
+
+object StageActorRefBenchmark {
+ final val OperationsPerInvocation = 10000
+ private case object CountDown extends NoSerializationVerificationNeeded
+
+ private final class Control {
+ private val ready = new CountDownLatch(1)
+ @volatile private var ref: ActorRef = _
+ @volatile private var latch: CountDownLatch = _
+
+ def init(ref: ActorRef): Unit = {
+ this.ref = ref
+ ready.countDown()
+ }
+
+ def stageActorRef: ActorRef = {
+ if (!ready.await(10, TimeUnit.SECONDS))
+ throw new RuntimeException("Stage actor ref was not initialized")
+ ref
+ }
+
+ def reset(expectedMessages: Int): Unit =
+ latch = new CountDownLatch(expectedMessages)
+
+ def countDown(): Unit =
+ latch.countDown()
+
+ def awaitDone(): Unit =
+ if (!latch.await(10, TimeUnit.SECONDS))
+ throw new RuntimeException("Stage actor ref benchmark messages timed
out")
+ }
+
+ private final class StageActorSink extends
GraphStageWithMaterializedValue[SinkShape[Any], Control] {
+ val in: Inlet[Any] = Inlet("StageActorSink.in")
+ override val shape: SinkShape[Any] = SinkShape(in)
+
+ override def createLogicAndMaterializedValue(inheritedAttributes:
Attributes): (GraphStageLogic, Control) = {
+ val control = new Control
+
+ val logic = new GraphStageLogic(shape) {
+ override def preStart(): Unit = {
+ control.init(getStageActor {
+ case (_, CountDown) => control.countDown()
+ }.ref)
+ pull(in)
+ }
+
+ setHandler(
+ in,
+ new InHandler {
+ override def onPush(): Unit = pull(in)
+ })
+ }
+
+ logic -> control
+ }
+ }
+}
+
+@State(Scope.Benchmark)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@BenchmarkMode(Array(Mode.Throughput))
+class StageActorRefBenchmark {
+ import StageActorRefBenchmark._
+
+ implicit val system: ActorSystem = ActorSystem("StageActorRefBenchmark")
+
+ private var completion: Promise[Option[Any]] = _
+ private var control: Control = _
+ private var stageActorRef: ActorRef = _
+
+ @Setup
+ def setup(): Unit = {
+ SystemMaterializer(system).materializer
+ val materialized = Source.maybe[Any].toMat(Sink.fromGraph(new
StageActorSink))(Keep.both).run()
+ completion = materialized._1
+ control = materialized._2
+ stageActorRef = control.stageActorRef
+ }
+
+ @TearDown
+ def shutdown(): Unit = {
+ completion.trySuccess(None)
+ Await.result(system.terminate(), 5.seconds)
+ }
+
+ @Benchmark
+ @OperationsPerInvocation(OperationsPerInvocation)
+ def lazy_stage_actor_ref_tell_10k(): Unit = {
+ control.reset(OperationsPerInvocation)
+ var remaining = OperationsPerInvocation
+ while (remaining > 0) {
+ stageActorRef ! CountDown
+ remaining -= 1
+ }
+ control.awaitDone()
+ }
+}
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StageActorRefSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StageActorRefSpec.scala
index c57068397d..3c7f2cd265 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StageActorRefSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/StageActorRefSpec.scala
@@ -18,16 +18,19 @@ import scala.concurrent.Promise
import scala.concurrent.duration._
import org.apache.pekko
+import pekko.actor.ActorPath
import pekko.actor.ActorRef
import pekko.actor.Kill
import pekko.actor.NoSerializationVerificationNeeded
import pekko.actor.PoisonPill
import pekko.event.Logging
import pekko.stream._
+import pekko.stream.impl.fusing.GraphInterpreter
import pekko.stream.stage.GraphStageLogic
import pekko.stream.stage.GraphStageWithMaterializedValue
import pekko.stream.stage.InHandler
import pekko.stream.testkit.StreamSpec
+import pekko.stream.testkit.scaladsl.TestSink
import pekko.testkit.EventFilter
import pekko.testkit.ImplicitSender
import pekko.testkit.TestEvent
@@ -181,6 +184,51 @@ class StageActorRefSpec extends StreamSpec with
ImplicitSender {
res.futureValue should ===(42)
}
+ "run non-eager stage actor messages in the graph interpreter" in {
+ val (_, res) =
Source.maybe[Int].toMat(sumStage(testActor))(Keep.both).run()
+
+ val stageRef = expectMsgType[ActorRef]
+ stageRef ! AddAndTell(1)
+ expectMsg(1)
+
+ stageRef ! ReportStageActorInterpreter
+ val location = expectMsgType[StageActorLocation]
+
+ location.stageActorParent should ===(location.supervisor)
+ location.interpreter should !==(location.supervisor)
+
+ stageRef ! StopNow
+ res.futureValue should ===(1)
+ }
+
+ "keep eagerly materialized stage actors usable before stream demand" in {
+ val (ref, probe) = Source
+ .actorRef[Int]({
+ case CompleteNow => CompletionStrategy.Immediately
+ }, PartialFunction.empty, bufferSize = 8, OverflowStrategy.fail)
+ .toMat(TestSink[Int]())(Keep.both)
+ .run()
+
+ ref ! 1
+ probe.request(1).expectNext(1)
+ ref ! CompleteNow
+ probe.expectComplete()
+ }
+
+ "keep eagerly materialized stage actors attached to the stream supervisor"
in {
+ val (source, res) =
Source.maybe[Int].toMat(eagerLocationStage(testActor))(Keep.both).run()
+
+ val stageRef = expectMsgType[ActorRef]
+ stageRef ! ReportEagerStageActorInterpreter
+ val location = expectMsgType[EagerStageActorLocation]
+
+ location.stageActorParent should ===(location.supervisor)
+ location.stageActorParent should !==(location.interpreter)
+
+ source.success(None)
+ res.futureValue should ===(0)
+ }
+
}
}
@@ -194,10 +242,19 @@ object StageActorRefSpec {
case object BecomeStringEcho extends NoSerializationVerificationNeeded
case object PullNow extends NoSerializationVerificationNeeded
case object StopNow extends NoSerializationVerificationNeeded
+ case object ReportStageActorInterpreter extends
NoSerializationVerificationNeeded
+ case object ReportEagerStageActorInterpreter extends
NoSerializationVerificationNeeded
+ case object CompleteNow extends NoSerializationVerificationNeeded
+ final case class StageActorLocation(stageActorParent: ActorPath,
supervisor: ActorPath, interpreter: ActorPath)
+ extends NoSerializationVerificationNeeded
+ final case class EagerStageActorLocation(stageActorParent: ActorPath,
supervisor: ActorPath, interpreter: ActorPath)
+ extends NoSerializationVerificationNeeded
}
import ControlProtocol._
+ def eagerLocationStage(probe: ActorRef) = EagerLocationStage(probe)
+
case class SumTestStage(probe: ActorRef) extends
GraphStageWithMaterializedValue[SinkShape[Int], Future[Int]] {
val in = Inlet[Int]("IntSum.in")
override val shape: SinkShape[Int] = SinkShape.of(in)
@@ -216,10 +273,15 @@ object StageActorRefSpec {
def behavior(m: (ActorRef, Any)): Unit = {
m match {
- case (_, Add(n)) => sum += n
- case (_, PullNow) => pull(in)
- case (sender, CallInitStageActorRef) => sender !
getStageActor(behavior).ref
- case (_, BecomeStringEcho) =>
+ case (_, Add(n)) => sum += n
+ case (_, PullNow) => pull(in)
+ case (sender, CallInitStageActorRef) => sender !
getStageActor(behavior).ref
+ case (sender, ReportStageActorInterpreter) =>
+ sender ! StageActorLocation(
+ stageActor.ref.path.parent,
+ interpreter.materializer.supervisor.path,
+ GraphInterpreter.currentInterpreter.context.path)
+ case (_, BecomeStringEcho) =>
getStageActor {
case (theSender, msg) => theSender ! msg.toString
}
@@ -258,4 +320,52 @@ object StageActorRefSpec {
}
}
+ case class EagerLocationStage(probe: ActorRef) extends
GraphStageWithMaterializedValue[SinkShape[Int], Future[Int]] {
+ val in = Inlet[Int]("EagerLocation.in")
+ override val shape: SinkShape[Int] = SinkShape.of(in)
+
+ override def createLogicAndMaterializedValue(inheritedAttributes:
Attributes): (GraphStageLogic, Future[Int]) = {
+ val p: Promise[Int] = Promise()
+
+ val logic = new GraphStageLogic(shape) {
+ var stageRef: ActorRef = _
+ var interpreterPath: ActorPath = _
+ var supervisorPath: ActorPath = _
+
+ override def preStart(): Unit = {
+ interpreterPath = interpreter.context.path
+ supervisorPath = interpreter.materializer.supervisor.path
+ stageRef = getEagerStageActor(interpreter.materializer) {
+ case (sender, ReportEagerStageActorInterpreter) =>
+ sender ! EagerStageActorLocation(stageRef.path.parent,
supervisorPath, interpreterPath)
+ case _ => throw new RuntimeException("unexpected message")
+ }.ref
+ pull(in)
+ probe ! stageRef
+ }
+
+ setHandler(
+ in,
+ new InHandler {
+ override def onPush(): Unit = {
+ p.trySuccess(grab(in))
+ completeStage()
+ }
+
+ override def onUpstreamFinish(): Unit = {
+ p.trySuccess(0)
+ completeStage()
+ }
+
+ override def onUpstreamFailure(ex: Throwable): Unit = {
+ p.tryFailure(ex)
+ failStage(ex)
+ }
+ })
+ }
+
+ logic -> p.future
+ }
+ }
+
}
diff --git a/stream/src/main/resources/reference.conf
b/stream/src/main/resources/reference.conf
index f4e4bd3193..059d28079a 100644
--- a/stream/src/main/resources/reference.conf
+++ b/stream/src/main/resources/reference.conf
@@ -83,6 +83,13 @@ pekko {
# Allows to accelerate message processing that happening within same
actor but keep system responsive.
sync-processing-limit = 1000
+ # Upper bound on stage-actor messages drained per envelope for non-eager
`getStageActor` refs. Lazy
+ # stage actors batch external `tell` deliveries into a MPSC queue and
elect a single drain envelope;
+ # this cap bounds the burst so that other BoundaryEvents
(pull/push/complete) can still interleave
+ # naturally via the actor mailbox. Smaller = better fairness for
upstream/downstream events;
+ # larger = better tell throughput. Must be >= 1.
+ stage-actor-drain-batch = 16
+
debug {
# Enables the fuzzing mode which increases the chance of race
conditions
# by aggressively reordering events and making certain operations more
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
b/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
index 8e97e88540..8a8168ff26 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/stage/GraphStage.scala
@@ -14,6 +14,7 @@
package org.apache.pekko.stream.stage
import java.util.Spliterator
+import java.lang.invoke.{ MethodHandles, VarHandle }
import java.util.concurrent.{ CompletionStage, ConcurrentHashMap }
import java.util.concurrent.atomic.AtomicReference
@@ -27,6 +28,7 @@ import org.apache.pekko
import pekko.{ Done, NotUsed }
import pekko.actor._
import pekko.annotation.InternalApi
+import pekko.dispatch.AbstractNodeQueue
import pekko.japi.function.{ Effect, Procedure }
import pekko.stream._
import pekko.stream.Attributes.SourceLocation
@@ -206,29 +208,61 @@ object GraphStageLogic {
*
* Not for user instantiation, use [[GraphStageLogic.getStageActor]].
*/
- final class StageActor @InternalApi() private[pekko] (
+ final class StageActor @InternalApi() private (
materializer: Materializer,
- getAsyncCallback: StageActorRef.Receive => AsyncCallback[(ActorRef,
Any)],
initialReceive: StageActorRef.Receive,
- name: String) {
+ name: String,
+ cell: ActorCell,
+ buildDispatch: StageActorRef.Receive => ((ActorRef, Any)) => Unit) {
+
+ @InternalApi private[pekko] def this(
+ materializer: Materializer,
+ getAsyncCallback: StageActorRef.Receive => AsyncCallback[(ActorRef,
Any)],
+ initialReceive: StageActorRef.Receive,
+ name: String) =
+ this(
+ materializer,
+ initialReceive,
+ name,
+ StageActor.localCell(materializer.supervisor, "Stream supervisor"),
+ receive => getAsyncCallback(receive).invoke)
+
+ @InternalApi private[pekko] def this(
+ materializer: Materializer,
+ interpreter: GraphInterpreter,
+ logic: GraphStageLogic,
+ initialReceive: StageActorRef.Receive,
+ name: String) =
+ this(
+ materializer,
+ initialReceive,
+ name,
+ StageActor.localCell(materializer.supervisor, "Stream supervisor"),
+ // Coalesce per-tell mailbox traffic: N tells produce 1 AsyncInput
envelope (amortized).
+ receive =>
+ new StageActor.LazyDispatch(
+ interpreter,
+ logic,
+ receive.asInstanceOf[Any => Unit],
+ StageActor.drainBatchSize(materializer)))
+
+ // Monomorphic Function1 captured once; JIT can inline the apply at the
FunctionRef call site.
+ private val dispatch: ((ActorRef, Any)) => Unit =
buildDispatch(internalReceive)
- private val callback = getAsyncCallback(internalReceive)
-
- private def cell = materializer.supervisor match {
- case ref: LocalActorRef => ref.underlying
- case unknown =>
- throw new IllegalStateException(s"Stream supervisor must be a local
actor, was [${unknown.getClass.getName}]")
- }
private val functionRef: FunctionRef = {
- val f: (ActorRef, Any) => Unit = {
- case (_, m @ (PoisonPill | Kill)) =>
- materializer.logger.warning(
- "{} message sent to StageActor({}) will be ignored, since it is
not a real Actor. " +
- "Use a custom message type to communicate with it instead.",
- m,
- functionRef.path)
- case pair => callback.invoke(pair)
- }
+ // Explicit (sender, msg) lambda (not a pattern-match Function2 literal)
so the PoisonPill / Kill
+ // branch matches on `msg` directly and does not allocate a Tuple2. The
regular branch still
+ // constructs one tuple per tell, as required by the `((ActorRef, Any))
=> Unit` public Receive type.
+ val f: (ActorRef, Any) => Unit = (sender, msg) =>
+ msg match {
+ case PoisonPill | Kill =>
+ materializer.logger.warning(
+ "{} message sent to StageActor({}) will be ignored, since it is
not a real Actor. " +
+ "Use a custom message type to communicate with it instead.",
+ msg,
+ functionRef.path)
+ case _ => dispatch((sender, msg))
+ }
cell.addFunctionRef(f, name)
}
@@ -275,6 +309,135 @@ object GraphStageLogic {
type Receive = ((ActorRef, Any)) => Unit
}
+ private object StageActor {
+ def localCell(ref: ActorRef, description: String): ActorCell =
+ ref match {
+ case ref: LocalActorRef => ref.underlying
+ case ref: RepointableActorRef =>
+ ref.underlying match {
+ case cell: ActorCell => cell
+ case unknown =>
+ throw new IllegalStateException(s"$description must be a local
actor, was [${unknown.getClass.getName}]")
+ }
+ case unknown =>
+ throw new IllegalStateException(s"$description must be a local
actor, was [${unknown.getClass.getName}]")
+ }
+
+ /**
+ * Reads `pekko.stream.materializer.stage-actor-drain-batch` from the
materializer's ActorSystem config.
+ * Called once per lazy StageActor construction (never on the hot path).
Bounded to `>= 1`.
+ */
+ def drainBatchSize(materializer: Materializer): Int =
+ Math.max(1,
materializer.system.settings.config.getInt("pekko.stream.materializer.stage-actor-drain-batch"))
+
+ private final val SchedStateIdle: Int = 0
+ private final val SchedStateScheduled: Int = 1
+
+ /**
+ * Lazy-path dispatch: producers enqueue into a Vyukov MPSC queue and
elect a single drain via
+ * IDLE -> SCHEDULED CAS; only the elected producer pays a mailbox
enqueue. The drain runs on the
+ * interpreter thread, polls in a tight loop bounded by `drainBatchSize`,
then either publishes IDLE
+ * (with a recheck for the publish-window race) or re-schedules another
envelope to yield to other
+ * BoundaryEvents.
+ *
+ * JIT/GC notes:
+ * - `final class` + monomorphic per-StageActor instance → JIT
devirtualizes the apply at the
+ * FunctionRef call site.
+ * - Extends `AbstractNodeQueue` directly so the queue head atomic and
the dispatch function share one
+ * object (one allocation per StageActor, one fewer field deref on the
producer hot path).
+ * - Implements `Any => Unit` directly — serves as both the producer
callback and the drain callback,
+ * eliminating the separate `drainCallback` lambda allocation.
+ * - `state` is a plain `@volatile var Int` driven by a static
`VarHandle` in the companion object
+ * (via `MethodHandles.privateLookupIn`), same pattern as
`AbstractNodeQueue` itself;
+ * avoids per-instance `AtomicInteger`.
+ * - `drainBatchSize` is read once into a stack-local at the top of
`drain` so the JIT can treat the loop
+ * bound as a constant.
+ * - Per-tell allocation = 1 Node (`AbstractNodeQueue.Node`, ~24 bytes) +
1 Tuple2 (~24 bytes). The
+ * Tuple2 is forced by the public `StageActorRef.Receive` type. No
AsyncInput / Envelope per tell —
+ * those are amortized across the batch.
+ */
+ // Not marked `private` so that `class StageActor`'s aux constructor
(compiled outside of the companion
+ // object on Scala 3) can reference it; the enclosing `object StageActor`
is itself private.
+ final class LazyDispatch(
+ interpreter: GraphInterpreter,
+ logic: GraphStageLogic,
+ handler: Any => Unit,
+ drainBatchSize: Int)
+ extends AbstractNodeQueue[(ActorRef, Any)]
+ with (Any => Unit) {
+
+ // IDLE/SCHEDULED election state. VarHandle avoids per-instance
AtomicInteger;
+ // the handle lives in the companion object (true JVM static), same
pattern as
+ // AbstractNodeQueue._tailDoNotCallMeDirectly.
+ @volatile var state: Int = SchedStateIdle
+
+ // Typed local witnesses the Int-returning signature-polymorphic
VarHandle.get overload
+ // (required for Scala 3 cross-compile; on Scala 2 this is a no-op).
+ private def getState(): Int = {
+ val v: Int = LazyDispatch.stateHandle.get(this)
+ v
+ }
+ private def setState(v: Int): Unit = LazyDispatch.stateHandle.set(this,
v)
+ private def casState(expect: Int, update: Int): Boolean =
+ LazyDispatch.stateHandle.compareAndSet(this, expect, update)
+
+ // null msg = drain signal from onAsyncInput; non-null = (ActorRef, Any)
tuple from FunctionRef.
+ override def apply(msg: Any): Unit =
+ if (msg == null) drain()
+ else {
+ val pair = msg.asInstanceOf[(ActorRef, Any)]
+ if (!interpreter.isStageCompleted(logic)) {
+ add(pair)
+ if (getState() == SchedStateIdle && casState(SchedStateIdle,
SchedStateScheduled)) {
+ if (interpreter.isStageCompleted(logic)) setState(SchedStateIdle)
+ else scheduleDrain()
+ }
+ }
+ }
+
+ private def scheduleDrain(): Unit =
+ // 1 AsyncInput + 1 Envelope per drain batch (amortized across up to
drainBatchSize tells).
+ // `this` serves as the drain callback (Any => Unit); onAsyncInput
calls apply(null).
+ interpreter.onAsyncInput(logic, null, NoPromise, this)
+
+ private def drain(): Unit = {
+ val limit = drainBatchSize
+ var processed = 0
+ while (processed < limit) {
+ if (interpreter.isStageCompleted(logic)) {
+ while (poll() ne null) ()
+ setState(SchedStateIdle)
+ return
+ }
+ val item = poll()
+ if (item eq null) {
+ setState(SchedStateIdle)
+ // Recheck race: a producer may have added between `poll == null`
and the IDLE publish above.
+ if (!isEmpty && casState(SchedStateIdle, SchedStateScheduled))
+ scheduleDrain()
+ return
+ }
+ handler(item)
+ processed += 1
+ }
+ // The last handler(item) may have completed the stage; check before
re-scheduling.
+ if (interpreter.isStageCompleted(logic)) {
+ while (poll() ne null) ()
+ setState(SchedStateIdle)
+ return
+ }
+ scheduleDrain()
+ }
+ }
+
+ object LazyDispatch {
+ private val stateHandle: VarHandle = {
+ val lookup = MethodHandles.privateLookupIn(classOf[LazyDispatch],
MethodHandles.lookup())
+ lookup.findVarHandle(classOf[LazyDispatch], "state", Integer.TYPE)
+ }
+ }
+ }
+
/**
* Internal API
*
@@ -1339,8 +1502,8 @@ abstract class GraphStageLogic private[stream] (val
inCount: Int, val outCount:
/**
* Initialize a [[GraphStageLogic.StageActorRef]] which can be used to
interact with from the outside world "as-if" a [[pekko.actor.Actor]].
- * The messages are looped through the [[getAsyncCallback]] mechanism of
[[GraphStage]] so they are safe to modify
- * internal state of this operator.
+ * The messages are delivered through the owning stream interpreter so they
are safe to modify internal state of this
+ * operator.
*
* This method must (the earliest) be called after the [[GraphStageLogic]]
constructor has finished running,
* for example from the [[preStart]] callback the graph operator logic
provides.
@@ -1358,7 +1521,20 @@ abstract class GraphStageLogic private[stream] (val
inCount: Int, val outCount:
* @return minimal actor with watch method
*/
final protected def getStageActor(receive: ((ActorRef, Any)) => Unit):
StageActor =
- getEagerStageActor(interpreter.materializer)(receive)
+ _stageActor match {
+ case null =>
+ val currentInterpreter = interpreter
+ _stageActor = new StageActor(
+ currentInterpreter.materializer,
+ currentInterpreter,
+ this,
+ receive,
+ stageActorName)
+ _stageActor
+ case existing =>
+ existing.become(receive)
+ existing
+ }
/**
* INTERNAL API
@@ -1382,7 +1558,7 @@ abstract class GraphStageLogic private[stream] (val
inCount: Int, val outCount:
* Override and return a name to be given to the StageActor of this operator.
*
* This method will be only invoked and used once, during the first
[[getStageActor]]
- * invocation whichc reates the actor, since subsequent `getStageActors`
calls function
+ * invocation which creates the actor, since subsequent `getStageActor`
calls function
* like `become`, rather than creating new actors.
*
* Returns an empty string by default, which means that the name will a
unique generated String (e.g. "$$a").
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]