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)