From 1ec50ce3ff9919ffabfb62f4ca3dbad8e47bc114 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:32:34 -0400 Subject: [PATCH] Plan viewer improvements: edge tooltips, context menu, ManyToMany, DOP-aware parallelism rules - Edge connector tooltips now match SSMS with all 5 fields (actual rows, estimated per-execution, estimated all-executions, row size, data size) - Right-click context menu on plan canvas (zoom, advice, repro, save) - Fixed context menu to use ScrollViewer (Canvas has no hit-test background) - Merge joins always show Many to Many: Yes/No in tooltip - LayoutTransformControl for proper zoom+scroll behavior - Fit zoom scrolls to origin so plan root is immediately visible - Rules 25/31 now use DOP-aware efficiency scoring: efficiency = (CPU/Elapsed - 1) / (DOP - 1) * 100 instead of simple CPU/Elapsed ratio Co-Authored-By: Claude Opus 4.6 --- .../Controls/PlanViewerControl.axaml | 11 +- .../Controls/PlanViewerControl.axaml.cs | 138 ++++++++++++++++-- src/PlanViewer.App/MainWindow.axaml.cs | 5 + src/PlanViewer.Core/Services/PlanAnalyzer.cs | 54 +++---- .../PlanAnalyzerTests.cs | 14 +- 5 files changed, 169 insertions(+), 53 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 503228f..db9d492 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -246,12 +246,13 @@ HorizontalContentAlignment="Left" VerticalContentAlignment="Top" Background="{DynamicResource BackgroundBrush}"> - - + + - - + + + diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 07b6950..8edde32 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -132,8 +132,9 @@ public PlanViewerControl() _splitterColumn = planGrid.ColumnDefinitions[3]; _propertiesColumn = planGrid.ColumnDefinitions[4]; - // ScaleTransform is the RenderTransform of PlanCanvas - _zoomTransform = (ScaleTransform)PlanCanvas.RenderTransform!; + // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas + var layoutTransform = this.FindControl("PlanLayoutTransform")!; + _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!; } @@ -166,6 +167,11 @@ public ServerMetadata? Metadata } } + // Events for MainWindow to wire up advice/repro actions + public event EventHandler? HumanAdviceRequested; + public event EventHandler? RobotAdviceRequested; + public event EventHandler? CopyReproRequested; + public void LoadPlan(string planXml, string label, string? queryText = null) { _label = label; @@ -284,6 +290,10 @@ private void RenderStatement(PlanStatement statement) // Scroll to top-left so the plan root is immediately visible PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); + // Canvas-level context menu (zoom, advice, repro, save) + // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable + PlanScrollViewer.ContextMenu = BuildCanvasContextMenu(); + CostText.Text = ""; } @@ -597,10 +607,6 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) }); geometry.Figures!.Add(figure); - var rowText = child.HasActualStats - ? $"Actual Rows: {child.ActualRows:N0}" - : $"Estimated Rows: {child.EstimateRows:N0}"; - var path = new AvaloniaPath { Data = geometry, @@ -608,10 +614,77 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child) StrokeThickness = thickness, StrokeJoin = PenLineJoin.Round }; - ToolTip.SetTip(path, rowText); + ToolTip.SetTip(path, BuildEdgeTooltipContent(child)); return path; } + private object BuildEdgeTooltipContent(PlanNode child) + { + var panel = new StackPanel { MinWidth = 240 }; + + void AddRow(string label, string value) + { + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + var lbl = new TextBlock + { + Text = label, + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 12, + Margin = new Thickness(0, 1, 12, 1) + }; + var val = new TextBlock + { + Text = value, + Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), + FontSize = 12, + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetColumn(lbl, 0); + Grid.SetColumn(val, 1); + row.Children.Add(lbl); + row.Children.Add(val); + panel.Children.Add(row); + } + + if (child.HasActualStats) + AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}"); + + AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}"); + + var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds; + var estimatedRowsAllExec = child.EstimateRows * executions; + AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}"); + + if (child.EstimatedRowSize > 0) + { + AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize)); + var dataSize = estimatedRowsAllExec * child.EstimatedRowSize; + AddRow("Estimated Data Size", FormatBytes(dataSize)); + } + + return new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 6), + CornerRadius = new CornerRadius(4), + Child = panel + }; + } + + private static string FormatBytes(double bytes) + { + if (bytes < 1024) return $"{bytes:N0} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB"; + return $"{bytes / (1024L * 1024 * 1024):N1} GB"; + } + #endregion #region Node Selection & Properties Panel @@ -694,6 +767,48 @@ private ContextMenu BuildNodeContextMenu(PlanNode node) return menu; } + private ContextMenu BuildCanvasContextMenu() + { + var menu = new ContextMenu(); + + // Zoom + var zoomInItem = new MenuItem { Header = "Zoom In" }; + zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep); + menu.Items.Add(zoomInItem); + + var zoomOutItem = new MenuItem { Header = "Zoom Out" }; + zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep); + menu.Items.Add(zoomOutItem); + + var fitItem = new MenuItem { Header = "Fit to View" }; + fitItem.Click += ZoomFit_Click; + menu.Items.Add(fitItem); + + menu.Items.Add(new Separator()); + + // Advice + var humanAdviceItem = new MenuItem { Header = "Human Advice" }; + humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(humanAdviceItem); + + var robotAdviceItem = new MenuItem { Header = "Robot Advice" }; + robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(robotAdviceItem); + + menu.Items.Add(new Separator()); + + // Repro & Save + var copyReproItem = new MenuItem { Header = "Copy Repro Script" }; + copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(copyReproItem); + + var saveItem = new MenuItem { Header = "Save .sqlplan" }; + saveItem.Click += SavePlan_Click; + menu.Items.Add(saveItem); + + return menu; + } + private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) { var topLevel = TopLevel.GetTopLevel(this); @@ -788,7 +903,7 @@ private void ShowPropertiesPanel(PlanNode node) || !string.IsNullOrEmpty(node.InnerSideJoinColumns) || !string.IsNullOrEmpty(node.OuterSideJoinColumns) || !string.IsNullOrEmpty(node.ActionColumn) - || node.ManyToMany || node.BitmapCreator + || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator || node.SortDistinct || node.StartupExpression || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || node.WithTies || node.Remoting || node.LocalParallelism @@ -853,8 +968,10 @@ private void ShowPropertiesPanel(PlanNode node) AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true); if (!string.IsNullOrEmpty(node.OuterSideJoinColumns)) AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true); - if (node.ManyToMany) - AddPropertyRow("Many to Many", "True"); + if (node.PhysicalOp == "Merge Join") + AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No"); + else if (node.ManyToMany) + AddPropertyRow("Many to Many", "Yes"); if (!string.IsNullOrEmpty(node.ConstantScanValues)) AddPropertyRow("Values", node.ConstantScanValues, isCode: true); if (!string.IsNullOrEmpty(node.UdxUsedColumns)) @@ -2718,6 +2835,7 @@ private void ZoomFit_Click(object? sender, RoutedEventArgs e) var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height); SetZoom(Math.Min(fitZoom, 1.0)); + PlanScrollViewer.Offset = new Avalonia.Vector(0, 0); } private void SetZoom(double level) diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index bc9d8a1..e586162 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -507,6 +507,11 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) } }; + // Wire up context menu events from PlanViewerControl + viewer.HumanAdviceRequested += (_, _) => humanBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + viewer.RobotAdviceRequested += (_, _) => robotBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + viewer.CopyReproRequested += async (_, _) => copyReproBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + var getActualPlanBtn = new Button { Content = "\u25b6 Run Repro", diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 6960a81..81ac926 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -274,54 +274,44 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) }); } - // Rule 25: Ineffective parallelism — parallel plan where CPU ≈ elapsed - // In an effective parallel plan, CPU time should be significantly higher than - // elapsed time because multiple threads are working simultaneously. - // When they're nearly equal (ratio 0.8–1.3), the work ran essentially serially. + // Rule 25: Ineffective parallelism — DOP-aware efficiency scoring + // Efficiency = (speedup - 1) / (DOP - 1) * 100 + // where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%). + // Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working. if (!cfg.IsRuleDisabled(25) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null) { var cpu = stmt.QueryTimeStats.CpuTimeMs; var elapsed = stmt.QueryTimeStats.ElapsedTimeMs; + var dop = stmt.DegreeOfParallelism; if (elapsed >= 1000 && cpu > 0) { - var ratio = (double)cpu / elapsed; - if (ratio >= 0.8 && ratio <= 1.3) + var speedup = (double)cpu / elapsed; + var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0)); + + if (speedup < 0.5 && !cfg.IsRuleDisabled(31)) { + // CPU well below Elapsed: threads are waiting, not doing CPU work + var waitPct = (1.0 - speedup) * 100; stmt.PlanWarnings.Add(new PlanWarning { - WarningType = "Ineffective Parallelism", - Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) but CPU time ({cpu:N0}ms) is nearly equal to elapsed time ({elapsed:N0}ms). " + - $"The work ran essentially serially despite the overhead of parallelism. " + - $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + WarningType = "Parallel Wait Bottleneck", + Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " + + $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + + $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", Severity = PlanWarningSeverity.Warning }); } - } - } - - // Rule 31: Parallel wait bottleneck — elapsed time significantly exceeds CPU time - // When elapsed >> CPU in a parallel plan, threads are spending time waiting rather - // than doing CPU work. Common causes: spills to tempdb, physical I/O, blocking, - // memory grant waits, or other resource contention. - if (!cfg.IsRuleDisabled(31) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null) - { - var cpu = stmt.QueryTimeStats.CpuTimeMs; - var elapsed = stmt.QueryTimeStats.ElapsedTimeMs; - - if (elapsed >= 1000 && cpu > 0) - { - var ratio = (double)cpu / elapsed; - if (ratio < 0.8) + else if (efficiency < 40) { - var waitPct = (1.0 - ratio) * 100; + // CPU >= Elapsed but well below DOP potential — parallelism is ineffective stmt.PlanWarnings.Add(new PlanWarning { - WarningType = "Parallel Wait Bottleneck", - Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) with elapsed time ({elapsed:N0}ms) significantly exceeding CPU time ({cpu:N0}ms). " + - $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + - $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", - Severity = PlanWarningSeverity.Warning + WarningType = "Ineffective Parallelism", + Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " + + $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " + + $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } } diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index dd06547..e8815ab 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -567,13 +567,13 @@ public void Rule29_ImplicitConvertSeekPlan_UpgradedToCritical() [Fact] public void Rule25_IneffectiveParallelism_DetectedWhenCpuEqualsElapsed() { - // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (ratio ~1.0) + // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (efficiency ~0%) var plan = PlanTestHelper.LoadAndAnalyze("serially-parallel.sqlplan"); var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism"); Assert.Single(warnings); Assert.Contains("DOP 8", warnings[0].Message); - Assert.Contains("ran essentially serially", warnings[0].Message); + Assert.Contains("% efficient", warnings[0].Message); } [Fact] @@ -637,13 +637,15 @@ public void Rule30_MissingIndexQuality_DetectsWideOrLowImpact() public void Rule31_ParallelWaitBottleneck_DetectedWhenElapsedExceedsCpu() { // excellent-parallel-spill: DOP 4, CPU 172,222ms vs elapsed 225,870ms - // ratio ~0.76 (< 0.8) — threads are waiting more than working + // speedup ~0.76 — CPU < Elapsed but >= 0.5, so fires as Ineffective Parallelism + // (wait bottleneck only fires when speedup < 0.5 — extreme waiting) var plan = PlanTestHelper.LoadAndAnalyze("excellent-parallel-spill.sqlplan"); - var warnings = PlanTestHelper.WarningsOfType(plan, "Parallel Wait Bottleneck"); - Assert.Single(warnings); + // At DOP 4 with speedup 0.76, efficiency ≈ 0% — fires Ineffective Parallelism + var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism"); + Assert.NotEmpty(warnings); Assert.Contains("DOP 4", warnings[0].Message); - Assert.Contains("waiting", warnings[0].Message); + Assert.Contains("% efficient", warnings[0].Message); } // ---------------------------------------------------------------