Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,13 @@
HorizontalContentAlignment="Left"
VerticalContentAlignment="Top"
Background="{DynamicResource BackgroundBrush}">
<Canvas x:Name="PlanCanvas" ClipToBounds="False"
HorizontalAlignment="Left" VerticalAlignment="Top">
<Canvas.RenderTransform>
<LayoutTransformControl x:Name="PlanLayoutTransform"
HorizontalAlignment="Left" VerticalAlignment="Top">
<LayoutTransformControl.LayoutTransform>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</Canvas.RenderTransform>
</Canvas>
</LayoutTransformControl.LayoutTransform>
<Canvas x:Name="PlanCanvas" ClipToBounds="False"/>
</LayoutTransformControl>
</ScrollViewer>

<!-- Empty State -->
Expand Down
138 changes: 128 additions & 10 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Avalonia.Controls.LayoutTransformControl>("PlanLayoutTransform")!;
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;

}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "";
}

Expand Down Expand Up @@ -597,21 +607,84 @@ 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,
Stroke = EdgeBrush,
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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 22 additions & 32 deletions src/PlanViewer.Core/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,54 +274,44 @@
});
}

// 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
});
}
}
Expand Down Expand Up @@ -949,7 +939,7 @@
// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
if (!cfg.IsRuleDisabled(28) && node.PhysicalOp.Contains("Row Count Spool"))

Check warning on line 942 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.

Check warning on line 942 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
Expand Down
14 changes: 8 additions & 6 deletions tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}

// ---------------------------------------------------------------
Expand Down
Loading