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 85bc2aa1d3 perf(stream): extract reportStageError out of 
GraphInterpreter.execute hot loop (#3119)
85bc2aa1d3 is described below

commit 85bc2aa1d32a4a27f4046dd6b4ddb1c191a8e7ab
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Mon Jun 22 17:38:08 2026 +0800

    perf(stream): extract reportStageError out of GraphInterpreter.execute hot 
loop (#3119)
    
    * perf(stream): extract reportStageError out of GraphInterpreter.execute 
hot loop
    
    Motivation:
    GraphInterpreter.execute is invoked once per event in the stream engine and 
shows up as
    a ~10% CPU hotspot in flamegraphs of high-throughput gRPC workloads. The 
nested
    `def reportStageError` closure inside the while-loop forced the Scala 
compiler to emit
    closure-capture bytecode (loading activeStage, chasedPush, chasedPull, 
chaseCounter,
    log, logics) on every iteration, even though every captured name is already 
a class field
    and the closure is only invoked on the error path.
    
    Modification:
    Lift `reportStageError` to a private[stream] instance method of 
GraphInterpreter, placed
    between `shutdownCounters` and `execute`. The three try/catch sites in 
execute (normal
    dispatch, chased PUSH, chased PULL) now call the method directly. Rename 
the outer
    `catch case NonFatal(e)` local to `ex` to remove name-shadowing with the 
method parameter.
    
    Result:
    - The hot while-loop body in `execute` shrinks by ~30 lines of bytecode, 
giving the JIT
      a tighter compile target and a better chance of inlining 
`processEvent`/`processPush`.
    - Zero per-iteration closure-capture overhead (previously the nested `def` 
produced a
      fresh closure allocation each loop pass before JIT escape analysis could 
eliminate it).
    - Behavior is preserved: `GraphInterpreterSpec` 11/11 tests pass 
(identity/chained/
      detacher/zip/broadcast/merge/balance/cycle).
    - Incidental benefit: error-path logging is now declared once and easier to 
audit.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.impl.fusing.GraphInterpreterSpec"
      -> 11 passed, 0 failed.
    
    References:
    None - internal stream engine refactor.
    
    * refactor(stream): address PR review on reportStageError lift
    
    Motivation:
    An independent review of #3119 identified three inaccuracies in the original
    submission:
    
    1. The claim that the nested `def reportStageError` inside `execute` 
allocated
       a fresh closure per loop iteration was incorrect. Scala 2.13 compiles the
       nested `def` as a private synthetic method on the enclosing class when 
only
       class fields are referenced — verified via `javap` against the baseline
       bytecode (`private final void reportStageError$1(java.lang.Throwable)`).
    2. The claim that `ActorGraphInterpreter` referenced the lifted method was
       false — a search of the file yields zero references.
    3. The `private[stream]` visibility granted package-wide access that no 
caller
       actually uses.
    
    Modification:
    - Rewrite the scaladoc on `reportStageError` to describe the real benefit:
      smaller `execute` bytecode for a tighter JIT compile target on the hot
      `processPush`/`processPull` paths that follow each try/catch. No 
allocation
      reduction is claimed.
    - Narrow visibility from `private[stream]` to `private` (all three call 
sites
      live in `GraphInterpreter.execute` itself).
    - Rename the catch-bound local `e` to `ex` at the remaining two call sites
      (chased PUSH and chased PULL) so all three try/catch blocks use the same
      naming convention. The main-dispatch call site was already renamed in the
      previous commit.
    
    Result:
    - Semantics preserved character-for-character (same log-level dispatch, same
      `failStage`, same chasing-abort branch).
    - `GraphInterpreterSpec` 11/11 and `GraphInterpreterFailureModesSpec` 9/9
      pass locally; the existing failure spec already provides directional
      coverage of the refactored method through every failure hook.
    - PR description updated in #3119 to reflect the corrected justification
      and to flag the JMH variance caveat.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.impl.fusing.GraphInterpreterSpec
      org.apache.pekko.stream.impl.fusing.GraphInterpreterFailureModesSpec"
      -> 20 passed, 0 failed.
    
    References:
    Refs #3119
    
    * refactor(stream): revert cosmetic ex rename per maintainer nit
    
    Motivation:
    A maintainer nit on #3119 flagged the catch-bound local rename `e` -> `ex`
    introduced in the previous commit as unnecessary. There is no real 
shadowing:
    the catch-bound `e` and the method parameter `e` on `reportStageError` live
    in completely different lexical scopes, so the rename adds no clarity and
    diverges from the surrounding codebase convention.
    
    Modification:
    Revert `case NonFatal(ex) => reportStageError(ex)` back to
    `case NonFatal(e) => reportStageError(e)` at all three call sites in
    `GraphInterpreter.execute` (normal dispatch, chased PUSH, chased PULL).
    No other changes.
    
    Result:
    - Code matches the surrounding convention in the stream engine.
    - Semantics identical (catch-bound `e` still bound per call site, method
      parameter `e` still bound per invocation).
    - `GraphInterpreterSpec` 11/11 and `GraphInterpreterFailureModesSpec` 9/9
      pass locally.
    - PR description updated to record the revert as reviewer-driven revision 
#5.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.impl.fusing.GraphInterpreterSpec
      org.apache.pekko.stream.impl.fusing.GraphInterpreterFailureModesSpec"
      -> 20 passed, 0 failed.
    
    References:
    Refs #3119
    
    * refactor(stream): drop redundant @InternalApi on private def
    
    Motivation:
    A maintainer comment on #3119 noted that `@InternalApi` on a class-scoped
    `private def` is meaningless — the annotation documents that a *non-private*
    method is part of Pekko's internal API and may change without notice. When
    the method is already `private` to the enclosing class, no external caller
    can see it regardless, so the annotation adds no information.
    
    Modification:
    Remove `@InternalApi` from `reportStageError`. The `InternalApi` import is
    retained because it is still used on the enclosing `GraphInterpreter`
    companion object, the class itself, and on `activeStage` / `nonNull` which
    are `private[stream]` (package-visible) and therefore still warrant the
    annotation.
    
    Result:
    - No behavioral change; the method remains class-private and its semantics
      are preserved character-for-character.
    - `GraphInterpreterSpec` 11/11 and `GraphInterpreterFailureModesSpec` 9/9
      pass locally.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.impl.fusing.GraphInterpreterSpec
      org.apache.pekko.stream.impl.fusing.GraphInterpreterFailureModesSpec"
      -> 20 passed, 0 failed.
    
    References:
    Refs #3119
---
 .../stream/impl/fusing/GraphInterpreter.scala      | 76 ++++++++++++----------
 1 file changed, 43 insertions(+), 33 deletions(-)

diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
 
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
index edaa45f31f..7c7db1bca0 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
@@ -365,6 +365,49 @@ import pekko.stream.stage._
   private def shutdownCounters: String =
     shutdownCounter.map(x => if (x >= KeepGoingFlag) s"${x & 
KeepGoingMask}(KeepGoing)" else x.toString).mkString(",")
 
+  /**
+   * INTERNAL API
+   *
+   * Reports a stage error: logs it according to the configured level, fails 
the active stage, and aborts any
+   * in-flight event chasing by re-enqueuing chased events. Lifted out of 
[[execute]] to shrink the hot loop's
+   * bytecode and give the JIT a tighter compile target for inlining 
`processPush`/`processPull` — the nested
+   * `def` that previously lived inside the loop was already compiled as a 
synthetic method on this class (Scala
+   * 2.13 does not allocate a fresh closure per iteration when the body only 
references class fields), so the
+   * gain here is purely in method-body size, not in allocation reduction.
+   */
+  private def reportStageError(e: Throwable): Unit = {
+    if (activeStage eq null) throw e
+    else {
+      val logAt: Logging.LogLevel = activeStage.attributes.get[LogLevels] 
match {
+        case Some(levels) => levels.onFailure
+        case None         => defaultErrorReportingLogLevel
+      }
+      logAt match {
+        case Logging.ErrorLevel =>
+          log.error(e, "Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
+        case Logging.WarningLevel =>
+          log.warning(e, "Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
+        case Logging.InfoLevel =>
+          log.info("Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
+        case Logging.DebugLevel =>
+          log.debug("Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
+        case _ => // Off, nop
+      }
+      activeStage.failStage(e)
+
+      // Abort chasing
+      chaseCounter = 0
+      if (chasedPush ne NoEvent) {
+        enqueue(chasedPush)
+        chasedPush = NoEvent
+      }
+      if (chasedPull ne NoEvent) {
+        enqueue(chasedPull)
+        chasedPull = NoEvent
+      }
+    }
+  }
+
   /**
    * Executes pending events until the given limit is met. If there were 
remaining events, isSuspended will return
    * true.
@@ -382,39 +425,6 @@ import pekko.stream.stage._
         eventsRemaining -= 1
         chaseCounter = math.min(ChaseLimit, eventsRemaining)
 
-        def reportStageError(e: Throwable): Unit = {
-          if (activeStage eq null) throw e
-          else {
-            val logAt: Logging.LogLevel = 
activeStage.attributes.get[LogLevels] match {
-              case Some(levels) => levels.onFailure
-              case None         => defaultErrorReportingLogLevel
-            }
-            logAt match {
-              case Logging.ErrorLevel =>
-                log.error(e, "Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
-              case Logging.WarningLevel =>
-                log.warning(e, "Error in stage [{}]: {}", 
activeStage.toString, e.getMessage)
-              case Logging.InfoLevel =>
-                log.info("Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
-              case Logging.DebugLevel =>
-                log.debug("Error in stage [{}]: {}", activeStage.toString, 
e.getMessage)
-              case _ => // Off, nop
-            }
-            activeStage.failStage(e)
-
-            // Abort chasing
-            chaseCounter = 0
-            if (chasedPush ne NoEvent) {
-              enqueue(chasedPush)
-              chasedPush = NoEvent
-            }
-            if (chasedPull ne NoEvent) {
-              enqueue(chasedPull)
-              chasedPull = NoEvent
-            }
-          }
-        }
-
         /*
          * This is the "normal" event processing code which dequeues directly 
from the internal event queue. Since
          * most execution paths tend to produce either a Push that will be 
propagated along a longer chain we take


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to