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);
}
// ---------------------------------------------------------------