From 0129fa053d362d3701284f5106d9c7024d4946b7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 27 Sep 2026 21:54:07 -0400
Subject: [PATCH 1/3] Plan analysis: parallel operator self-time excludes the
coordinator thread and looks through batch subtrees (#4513)
---
Darling/Darling.Tests/PlanSync4513Tests.cs | 169 ++++++++++++++++++
.../PlanAnalyzer.cs | 114 ++++++++++--
2 files changed, 264 insertions(+), 19 deletions(-)
create mode 100644 Darling/Darling.Tests/PlanSync4513Tests.cs
diff --git a/Darling/Darling.Tests/PlanSync4513Tests.cs b/Darling/Darling.Tests/PlanSync4513Tests.cs
new file mode 100644
index 0000000000..a665667017
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4513Tests.cs
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Collections.Generic;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4513 — a parallel operator's own elapsed time (, the
+/// per-thread path) had two faults that both inflate an operator until it outranks the operator that actually
+/// did the work.
+///
+/// (a) Thread 0 is the coordinator in a parallel plan: it carries no rows, and its own
+/// ActualElapsedMs is the wall clock of the whole parallel branch, not a worker's own time. Including it
+/// handed an operator near the top of a parallel branch the branch's entire duration as its own work.
+///
+/// (b) The per-thread child subtraction only summed a direct child's own reported per-thread value. A
+/// batch-mode child reports STANDALONE time (not cumulative), so a grandchild inside the same batch zone kept
+/// its time inside the parent's self-time — while the grandchild also reported that same time as its own self
+/// time, so the time was counted twice across two rows of the same plan.
+///
+/// (c) A Compute Scalar child commonly carries no per-thread runtime stats at all (a pass-through), so the
+/// old code subtracted zero for it and the parent absorbed the whole subtree beneath it.
+///
+/// Synthetic trees rather than full ShowPlan XML fixtures: the property under test
+/// is arithmetic over rows, so each tree is built to have exactly the shape
+/// the assertion needs and the expected numbers are facts of the test, computed by hand below.
+///
+public sealed class PlanSync4513Tests
+{
+ private static PerThreadRuntimeInfo Thread(int id, long elapsedMs) =>
+ new() { ThreadId = id, ActualElapsedMs = elapsedMs };
+
+ /// (a) Thread 0 (the coordinator) is excluded from a parallel operator's self-time. With no
+ /// children, self-time is just the operator's own per-thread elapsed, maxed over WORKER threads only.
+ /// Coordinator elapsed (1000ms, the whole branch's wall clock) must not win the max.
+ /// Expected: max(50, 45) = 50ms. The unfixed code includes thread 0 and returns 1000ms.
+ [Fact]
+ public void CoordinatorThreadZero_IsExcludedFromSelfTime()
+ {
+ var node = new PlanNode
+ {
+ PhysicalOp = "Hash Match",
+ HasActualStats = true,
+ PerThreadStats = new List
+ {
+ Thread(0, 1000),
+ Thread(1, 50),
+ Thread(2, 45),
+ },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(node);
+
+ Assert.Equal(50, self);
+ }
+
+ /// (b) A batch-mode child's subtree is summed, not just its own standalone value, so a grandchild
+ /// in the same batch zone doesn't stay behind inside the parent's self-time (which would double-count it
+ /// against the grandchild's own separately-reported self-time).
+ ///
+ /// Shape: Parent (row mode) -> Child (batch, standalone own time) -> Grandchild (batch, standalone own
+ /// time, no children). Per thread, Parent's cumulative elapsed = its own work + Child's own + Grandchild's
+ /// own:
+ /// thread 1: 5 (parent's own) + 10 (child's own) + 20 (grandchild's own) = 35
+ /// thread 2: 4 + 8 + 15 = 27
+ /// Correct self-time = max(35 - (10+20), 27 - (8+15)) = max(5, 4) = 5ms.
+ /// The unfixed code only subtracts Child's own value (10, 8), leaving Grandchild's time inside Parent's
+ /// self-time: max(35-10, 27-8) = max(25, 19) = 25ms.
+ [Fact]
+ public void BatchModeGrandchild_IsNotLeftInsideParentSelfTime()
+ {
+ var grandchild = new PlanNode
+ {
+ PhysicalOp = "Hash Match",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 20,
+ PerThreadStats = new List
+ {
+ Thread(1, 20),
+ Thread(2, 15),
+ },
+ };
+ var child = new PlanNode
+ {
+ PhysicalOp = "Compute Scalar",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 10,
+ PerThreadStats = new List
+ {
+ Thread(1, 10),
+ Thread(2, 8),
+ },
+ Children = new List { grandchild },
+ };
+ var parent = new PlanNode
+ {
+ PhysicalOp = "Nested Loops",
+ HasActualStats = true,
+ ActualElapsedMs = 35,
+ PerThreadStats = new List
+ {
+ Thread(1, 35),
+ Thread(2, 27),
+ },
+ Children = new List { child },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
+
+ Assert.Equal(5, self);
+ }
+
+ /// (c) A Compute Scalar child with no per-thread runtime stats at all (a pass-through) is looked
+ /// through to its own child, so the real work beneath it still comes off the parent's self-time.
+ ///
+ /// Shape: Parent (row mode) -> Compute Scalar (no PerThreadStats) -> RealWork (row mode, has stats).
+ /// thread 1: 5 (parent's own) + 40 (real work) = 45
+ /// thread 2: 4 + 35 = 39
+ /// Correct self-time = max(45-40, 39-35) = max(5, 4) = 5ms.
+ /// The unfixed code finds no per-thread stats on the Compute Scalar child and subtracts nothing:
+ /// max(45, 39) = 45ms.
+ [Fact]
+ public void ComputeScalarPassThroughChild_IsLookedThrough()
+ {
+ var realWork = new PlanNode
+ {
+ PhysicalOp = "Clustered Index Scan",
+ HasActualStats = true,
+ ActualElapsedMs = 40,
+ PerThreadStats = new List
+ {
+ Thread(1, 40),
+ Thread(2, 35),
+ },
+ };
+ var computeScalar = new PlanNode
+ {
+ PhysicalOp = "Compute Scalar",
+ HasActualStats = false,
+ PerThreadStats = new List(),
+ Children = new List { realWork },
+ };
+ var parent = new PlanNode
+ {
+ PhysicalOp = "Nested Loops",
+ HasActualStats = true,
+ PerThreadStats = new List
+ {
+ Thread(1, 45),
+ Thread(2, 39),
+ },
+ Children = new List { computeScalar },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
+
+ Assert.Equal(5, self);
+ }
+}
diff --git a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
index 2031492e2d..4f67fc38fd 100644
--- a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
+++ b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
@@ -1397,36 +1397,40 @@ internal static long GetOperatorOwnElapsedMs(PlanNode node)
return GetSerialOwnElapsed(node);
}
+ ///
+ /// Threads that actually did work. In a parallel plan thread 0 is the
+ /// coordinator: it carries no rows, and its ActualElapsedMs is the wall clock
+ /// of the whole parallel branch. Including it in a per-thread self-time
+ /// calculation hands the operator the branch's entire duration.
+ /// A serial plan has a single thread numbered 0, which IS a worker, so only
+ /// exclude thread 0 when other threads exist.
+ ///
+ private static List WorkThreads(PlanNode node)
+ {
+ var workers = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList();
+ return workers.Count > 0 ? workers : node.PerThreadStats;
+ }
+
///
/// Per-thread self-time calculation for parallel row mode operators.
- /// For each thread: self = parent_elapsed[t] - sum(children_elapsed[t]).
- /// Returns max across threads.
+ /// For each worker thread: self = parent[t] - sum(effective children[t]).
+ /// Returns max across worker threads. Thread 0 (the coordinator) is excluded
+ /// from the parent side by WorkThreads, and the child side looks through
+ /// batch subtrees and pass-throughs the same way the serial path does.
///
private static long GetPerThreadOwnElapsed(PlanNode node)
{
- // Build lookup: threadId -> parent elapsed for this node
+ // Build lookup: threadId -> parent elapsed for this node (worker threads only)
var parentByThread = new Dictionary();
- foreach (var ts in node.PerThreadStats)
+ foreach (var ts in WorkThreads(node))
parentByThread[ts.ThreadId] = ts.ActualElapsedMs;
- // Build lookup: threadId -> sum of all direct children's elapsed
+ // Build lookup: threadId -> sum of effective children's elapsed
var childSumByThread = new Dictionary();
foreach (var child in node.Children)
- {
- var childNode = child;
-
- // Exchange operators have unreliable times — look through to their child
- if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
- childNode = child.Children.OrderByDescending(c => c.ActualElapsedMs).First();
-
- foreach (var ts in childNode.PerThreadStats)
- {
- childSumByThread.TryGetValue(ts.ThreadId, out var existing);
- childSumByThread[ts.ThreadId] = existing + ts.ActualElapsedMs;
- }
- }
+ AddEffectiveChildElapsedByThread(child, childSumByThread);
- // Self-time per thread = parent - children, take max across threads
+ // Self-time per thread = parent - children, take max across worker threads
var maxSelf = 0L;
foreach (var (threadId, parentMs) in parentByThread)
{
@@ -1438,6 +1442,78 @@ private static long GetPerThreadOwnElapsed(PlanNode node)
return maxSelf;
}
+ ///
+ /// What a child contributes to its parent's per-thread elapsed total. The
+ /// per-thread mirror of the serial path's child look-through, and it must
+ /// look through the same two shapes or the parent absorbs the subtree
+ /// beneath them:
+ ///
+ /// - A batch-mode child reports STANDALONE time, so only its own value
+ /// would come off and the rest of the batch zone would stay in the
+ /// parent.
+ /// - A pass-through child (Compute Scalar) carries no runtime stats at
+ /// all, so zero would come off.
+ ///
+ /// Together these can crown a row-mode operator above a batch subtree as
+ /// the hottest operator in its plan, with the subtree's time double-counted.
+ ///
+ private static void AddEffectiveChildElapsedByThread(PlanNode child, Dictionary acc)
+ {
+ // Exchange operators have unreliable times — look through to their child
+ if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
+ {
+ var dominant = child.Children.OrderByDescending(c => c.ActualElapsedMs).First();
+ AddEffectiveChildElapsedByThread(dominant, acc);
+ return;
+ }
+
+ var mode = child.ActualExecutionMode ?? child.ExecutionMode;
+ if (mode == "Batch" && child.HasActualStats)
+ {
+ AddBatchSubtreeElapsedByThread(child, acc);
+ return;
+ }
+
+ if (child.HasActualStats && child.ActualElapsedMs > 0)
+ {
+ foreach (var ts in WorkThreads(child))
+ {
+ acc.TryGetValue(ts.ThreadId, out var existing);
+ acc[ts.ThreadId] = existing + ts.ActualElapsedMs;
+ }
+ return;
+ }
+
+ // No runtime stats (e.g. a Compute Scalar pass-through): look through
+ // to the descendants that have them.
+ foreach (var grandchild in child.Children)
+ AddEffectiveChildElapsedByThread(grandchild, acc);
+ }
+
+ ///
+ /// Per-thread sum across a contiguous batch-mode zone, stopping at exchanges.
+ /// Batch operators pipeline, so their times add rather than nest.
+ ///
+ private static void AddBatchSubtreeElapsedByThread(PlanNode node, Dictionary acc)
+ {
+ foreach (var ts in WorkThreads(node))
+ {
+ acc.TryGetValue(ts.ThreadId, out var existing);
+ acc[ts.ThreadId] = existing + ts.ActualElapsedMs;
+ }
+
+ foreach (var child in node.Children)
+ {
+ if (child.PhysicalOp == "Parallelism") continue; // zone boundary
+
+ var childMode = child.ActualExecutionMode ?? child.ExecutionMode;
+ if (childMode == "Batch" && child.HasActualStats)
+ AddBatchSubtreeElapsedByThread(child, acc);
+ else
+ AddEffectiveChildElapsedByThread(child, acc);
+ }
+ }
+
///
/// Serial row mode self-time: subtract all direct children's elapsed.
/// Exchange children are skipped through to their real child.
From 3d1725a23faa9470e6dfb3c7b7e569457fb2494b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:11:46 -0400
Subject: [PATCH 2/3] Plan analysis: serial operator self-time looks through
Compute Scalar and batch mode subtrees (#4515)
---
Darling/Darling.Tests/PlanSync4515Tests.cs | 109 ++++++++++++++++++
.../PlanAnalyzer.cs | 70 +++++++++--
2 files changed, 170 insertions(+), 9 deletions(-)
create mode 100644 Darling/Darling.Tests/PlanSync4515Tests.cs
diff --git a/Darling/Darling.Tests/PlanSync4515Tests.cs b/Darling/Darling.Tests/PlanSync4515Tests.cs
new file mode 100644
index 0000000000..ae1c7afba7
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4515Tests.cs
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Collections.Generic;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4515 — a SERIAL operator's own elapsed time (, the
+/// serial row-mode path) only subtracted a direct child's own ActualElapsedMs. That misses the same two
+/// shapes #4513 fixed on the per-thread path, reusing the same two helpers there:
+///
+/// (a) A Compute Scalar child commonly carries no runtime stats at all (ActualElapsedMs is 0), so
+/// the old code subtracted zero for it and the parent absorbed the whole subtree beneath it as its own
+/// self-time.
+///
+/// (b) A batch-mode child reports STANDALONE time (not cumulative), so a grandchild inside the same batch
+/// zone kept its time inside the parent's self-time — double-counted, since the grandchild also reports that
+/// same time as its own self-time on its own row of the plan.
+///
+/// Synthetic trees rather than full ShowPlan XML fixtures, for the same reason as
+/// #4513's tests: the property under test is arithmetic, so each tree is built to have exactly the shape the
+/// assertion needs, with expected numbers worked out by hand below.
+///
+public sealed class PlanSync4515Tests
+{
+ /// (a) A Compute Scalar child with no runtime stats is looked through to its own child, so the
+ /// real work beneath it still comes off the parent's self-time.
+ ///
+ /// Shape: Parent (row mode, ActualElapsedMs=45) -> Compute Scalar (HasActualStats=false,
+ /// ActualElapsedMs=0) -> RealWork (row mode, ActualElapsedMs=40).
+ /// Correct self-time = 45 - 40 = 5ms.
+ /// The unfixed code subtracts the Compute Scalar's own ActualElapsedMs (0) and returns 45ms.
+ [Fact]
+ public void ComputeScalarPassThroughChild_IsLookedThrough()
+ {
+ var realWork = new PlanNode
+ {
+ PhysicalOp = "Clustered Index Scan",
+ HasActualStats = true,
+ ActualElapsedMs = 40,
+ };
+ var computeScalar = new PlanNode
+ {
+ PhysicalOp = "Compute Scalar",
+ HasActualStats = false,
+ ActualElapsedMs = 0,
+ Children = new List { realWork },
+ };
+ var parent = new PlanNode
+ {
+ PhysicalOp = "Nested Loops",
+ HasActualStats = true,
+ ActualElapsedMs = 45,
+ Children = new List { computeScalar },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
+
+ Assert.Equal(5, self);
+ }
+
+ /// (b) A batch-mode child's whole contiguous subtree is summed, not just its own standalone
+ /// value, so a grandchild in the same batch zone doesn't stay behind inside the parent's self-time.
+ ///
+ /// Shape: Parent (row mode, ActualElapsedMs=35) -> Child (batch, ActualElapsedMs=10, standalone own time)
+ /// -> Grandchild (batch, ActualElapsedMs=20, standalone own time, no children).
+ /// Correct effective child elapsed = 10 (child's own) + 20 (grandchild's own) = 30.
+ /// Correct self-time = 35 - 30 = 5ms.
+ /// The unfixed code only subtracts the direct child's own value (10), leaving the grandchild's time
+ /// inside the parent's self-time: 35 - 10 = 25ms.
+ [Fact]
+ public void BatchModeGrandchild_IsSummedIntoSubtree()
+ {
+ var grandchild = new PlanNode
+ {
+ PhysicalOp = "Hash Match",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 20,
+ };
+ var child = new PlanNode
+ {
+ PhysicalOp = "Compute Scalar",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 10,
+ Children = new List { grandchild },
+ };
+ var parent = new PlanNode
+ {
+ PhysicalOp = "Nested Loops",
+ HasActualStats = true,
+ ActualElapsedMs = 35,
+ Children = new List { child },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
+
+ Assert.Equal(5, self);
+ }
+}
diff --git a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
index 4f67fc38fd..ce8a9b96f1 100644
--- a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
+++ b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
@@ -1515,24 +1515,76 @@ private static void AddBatchSubtreeElapsedByThread(PlanNode node, Dictionary
- /// Serial row mode self-time: subtract all direct children's elapsed.
- /// Exchange children are skipped through to their real child.
+ /// Serial row mode self-time: subtract all direct children's effective
+ /// elapsed. The child side looks through the same two shapes as the
+ /// per-thread path above — a pass-through child (Compute Scalar) with no
+ /// runtime stats, and a batch-mode child's whole contiguous subtree —
+ /// or the parent absorbs the subtree beneath them as its own self-time.
///
private static long GetSerialOwnElapsed(PlanNode node)
{
var totalChildElapsed = 0L;
foreach (var child in node.Children)
- {
- var childElapsed = child.ActualElapsedMs;
+ totalChildElapsed += GetEffectiveChildElapsedMs(child);
+
+ return Math.Max(0, node.ActualElapsedMs - totalChildElapsed);
+ }
- // Exchange operators have unreliable times — skip to their child
- if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
- childElapsed = child.Children.Max(c => c.ActualElapsedMs);
+ ///
+ /// What a child contributes to its parent's serial self-time. Exchange
+ /// operators have unreliable times, so this looks through to their
+ /// dominant child. A batch-mode child reports STANDALONE time, so this
+ /// sums the whole contiguous batch zone rather than just the direct
+ /// child. A child with no runtime stats at all (a Compute Scalar
+ /// pass-through) contributes zero directly, so this looks through to the
+ /// descendants that do have stats.
+ ///
+ private static long GetEffectiveChildElapsedMs(PlanNode child)
+ {
+ // Exchange operators have unreliable times — skip to their child
+ if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
+ return child.Children.Max(c => c.ActualElapsedMs);
+
+ var mode = child.ActualExecutionMode ?? child.ExecutionMode;
+ if (mode == "Batch" && child.HasActualStats)
+ return SumBatchSubtreeElapsedMs(child);
+
+ if (child.ActualElapsedMs > 0)
+ return child.ActualElapsedMs;
+
+ // No runtime stats (e.g. a Compute Scalar pass-through): look through
+ // to the descendants that have them.
+ if (child.Children.Count == 0)
+ return 0;
- totalChildElapsed += childElapsed;
+ var sum = 0L;
+ foreach (var grandchild in child.Children)
+ sum += GetEffectiveChildElapsedMs(grandchild);
+ return sum;
+ }
+
+ ///
+ /// Sums ActualElapsedMs across a contiguous batch-mode zone, stopping at
+ /// exchange boundaries. Batch operators pipeline — elapsed times are
+ /// standalone, not cumulative — so summing gives the total work the zone
+ /// did, which is what a row-mode parent above the zone should subtract
+ /// to get its own self-time.
+ ///
+ private static long SumBatchSubtreeElapsedMs(PlanNode node)
+ {
+ var sum = node.ActualElapsedMs;
+ foreach (var child in node.Children)
+ {
+ if (child.PhysicalOp == "Parallelism") continue; // zone boundary
+
+ var childMode = child.ActualExecutionMode ?? child.ExecutionMode;
+ if (childMode == "Batch" && child.HasActualStats)
+ sum += SumBatchSubtreeElapsedMs(child);
+ else
+ sum += GetEffectiveChildElapsedMs(child);
}
- return Math.Max(0, node.ActualElapsedMs - totalChildElapsed);
+ return sum;
}
///
From 8d1f1f735d3c21b9299002d12327e64c438d7686 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:23:22 -0400
Subject: [PATCH 3/3] Look through an exchange to its child's effective time,
as PerformanceStudio does (#4515)
---
Darling/Darling.Tests/PlanSync4515bTests.cs | 72 +++++++++++++++++++
.../PlanAnalyzer.cs | 4 +-
2 files changed, 74 insertions(+), 2 deletions(-)
create mode 100644 Darling/Darling.Tests/PlanSync4515bTests.cs
diff --git a/Darling/Darling.Tests/PlanSync4515bTests.cs b/Darling/Darling.Tests/PlanSync4515bTests.cs
new file mode 100644
index 0000000000..7fa6b77d2f
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4515bTests.cs
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Collections.Generic;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4515 follow-up: 's Parallelism (exchange) branch read
+/// child.Children.Max(c => c.ActualElapsedMs) — the exchange's own direct child's raw elapsed — instead
+/// of recursing through GetEffectiveChildElapsedMs the way every other branch does. That means a
+/// row-mode parent above an exchange above a batch-mode zone (or a no-stats Compute Scalar) only subtracted
+/// the exchange's direct child's OWN elapsed, not that child's effective (look-through) elapsed — the same
+/// under-subtraction #4515 fixed for the non-exchange case, just one hop further down.
+///
+/// Shape: Parent (row mode, ActualElapsedMs=100) -> Parallelism exchange (Children.Count=1) ->
+/// BatchChild (batch mode, ActualElapsedMs=30, own time) -> BatchGrandchild (batch mode, ActualElapsedMs=50,
+/// own time, no children).
+///
+/// Correct effective elapsed for the exchange's child is the whole batch zone: 30 (BatchChild's own) +
+/// 50 (BatchGrandchild's own) = 80. Correct self-time = 100 - 80 = 20ms.
+///
+/// The unfixed code takes Max(c => c.ActualElapsedMs) over the exchange's direct children, i.e.
+/// just BatchChild's own 30, giving self-time = 100 - 30 = 70ms.
+///
+public sealed class PlanSync4515bTests
+{
+ [Fact]
+ public void ParallelismExchange_LooksThroughToChildsEffectiveElapsed()
+ {
+ var batchGrandchild = new PlanNode
+ {
+ PhysicalOp = "Hash Match",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 50,
+ };
+ var batchChild = new PlanNode
+ {
+ PhysicalOp = "Hash Match",
+ ActualExecutionMode = "Batch",
+ HasActualStats = true,
+ ActualElapsedMs = 30,
+ Children = new List { batchGrandchild },
+ };
+ var exchange = new PlanNode
+ {
+ PhysicalOp = "Parallelism",
+ HasActualStats = true,
+ ActualElapsedMs = 80,
+ Children = new List { batchChild },
+ };
+ var parent = new PlanNode
+ {
+ PhysicalOp = "Nested Loops",
+ HasActualStats = true,
+ ActualElapsedMs = 100,
+ Children = new List { exchange },
+ };
+
+ var self = PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
+
+ Assert.Equal(20, self);
+ }
+}
diff --git a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
index ce8a9b96f1..ad174b431a 100644
--- a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
+++ b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs
@@ -1541,9 +1541,9 @@ private static long GetSerialOwnElapsed(PlanNode node)
///
private static long GetEffectiveChildElapsedMs(PlanNode child)
{
- // Exchange operators have unreliable times — skip to their child
+ // Exchange operators: unreliable times, use max child
if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
- return child.Children.Max(c => c.ActualElapsedMs);
+ return child.Children.Max(GetEffectiveChildElapsedMs);
var mode = child.ActualExecutionMode ?? child.ExecutionMode;
if (mode == "Batch" && child.HasActualStats)