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
80 changes: 58 additions & 22 deletions Dashboard/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
private static void AnalyzeStatement(PlanStatement stmt)
{
// Rule 3: Serial plan with reason
if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason))
// Skip trivial statements (e.g., variable assignments, constant scans) — not worth warning about
if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason)
&& stmt.StatementSubTreeCost >= 0.01)
{
var reason = stmt.NonParallelPlanReason switch
{
Expand Down Expand Up @@ -140,15 +142,16 @@
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}

// Rule 20: Local variables without RECOMPILE
// Parameters with no CompiledValue are likely local variables — the optimizer
// cannot sniff their values and uses density-based ("unknown") estimates.
if (stmt.Parameters.Count > 0)
// Skip trivial statements (simple variable assignments) where estimate quality doesn't matter.
if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01)
{
var unsnifffedParams = stmt.Parameters
.Where(p => string.IsNullOrEmpty(p.CompiledValue))
Expand Down Expand Up @@ -352,21 +355,42 @@
{
// Rule 1: Filter operators — rows survived the tree just to be discarded
// Quantify the impact by summing child subtree cost (reads, CPU, time).
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
// Suppress when the filter's child subtree is trivial (low I/O, fast, cheap).
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)
&& node.Children.Count > 0)
{
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";
// Gate: skip trivial filters based on actual stats or estimated cost
bool isTrivial;
if (node.HasActualStats)
{
long childReads = 0;
foreach (var child in node.Children)
childReads += SumSubtreeReads(child);
var childElapsed = node.Children.Max(c => c.ActualElapsedMs);
isTrivial = childReads < 128 && childElapsed < 10;
}
else
{
var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost);
isTrivial = childCost < 1.0;
}

node.Warnings.Add(new PlanWarning
if (!isTrivial)
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";

node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
}
}

// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
Expand All @@ -391,7 +415,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump the query results to a #temp table first and apply the UDF only to the final result set.",
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -451,7 +475,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "Scalar UDF",
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -830,18 +854,23 @@
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
node.Warnings.Add(new PlanWarning
// Require at least a 2x reduction to be worth mentioning — "1 to 1" or
// tiny floating-point differences that display identically are noise
if (reduction >= 2.0)
{
WarningType = "Row Goal",
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
Severity = PlanWarningSeverity.Info
});
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Goal",
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
Severity = PlanWarningSeverity.Info
});
}
}

// 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 (node.PhysicalOp.Contains("Row Count Spool"))

Check warning on line 873 in Dashboard/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 873 in Dashboard/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 873 in Dashboard/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 873 in Dashboard/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 873 in Dashboard/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
Expand Down Expand Up @@ -1067,6 +1096,13 @@
if (parent == null || parent.PhysicalOp != "Nested Loops")
return false;

// If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN
// subquery pattern (Merge Interval optimizing range lookups), not an OR expansion
var nlParent = parent.Parent;
if (nlParent != null && nlParent.LogicalOp != null &&
nlParent.LogicalOp.Contains("Semi"))
return false;

return true;
}

Expand Down
18 changes: 12 additions & 6 deletions Dashboard/Services/ShowPlanParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1632,20 +1632,26 @@ private static List<PlanWarning> ParseWarningsFromElement(XElement warningsEl)
});
}

// Memory grant warning
// Memory grant warning (from plan XML) — gate at 1 GB to avoid noise on small grants
// All values are in KB, consistent with MemoryGrantInfo element
var memWarnEl = warningsEl.Element(Ns + "MemoryGrantWarning");
if (memWarnEl != null)
{
var kind = memWarnEl.Attribute("GrantWarningKind")?.Value ?? "Unknown";
var requested = ParseLong(memWarnEl.Attribute("RequestedMemory")?.Value);
var granted = ParseLong(memWarnEl.Attribute("GrantedMemory")?.Value);
var maxUsed = ParseLong(memWarnEl.Attribute("MaxUsedMemory")?.Value);
result.Add(new PlanWarning
if (granted >= 1048576) // 1 GB in KB
{
WarningType = "Memory Grant",
Message = $"{kind}: Requested {requested:N0} KB, Granted {granted:N0} KB, Used {maxUsed:N0} KB",
Severity = PlanWarningSeverity.Warning
});
var grantedMB = granted / 1024.0;
var usedMB = maxUsed / 1024.0;
result.Add(new PlanWarning
{
WarningType = "Memory Grant",
Message = $"{kind}: Granted {grantedMB:N0} MB, Used {usedMB:N0} MB",
Severity = PlanWarningSeverity.Warning
});
}
}

// Implicit conversions
Expand Down
80 changes: 58 additions & 22 deletions Lite/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
private static void AnalyzeStatement(PlanStatement stmt)
{
// Rule 3: Serial plan with reason
if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason))
// Skip trivial statements (e.g., variable assignments, constant scans) — not worth warning about
if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason)
&& stmt.StatementSubTreeCost >= 0.01)
{
var reason = stmt.NonParallelPlanReason switch
{
Expand Down Expand Up @@ -140,15 +142,16 @@
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}

// Rule 20: Local variables without RECOMPILE
// Parameters with no CompiledValue are likely local variables — the optimizer
// cannot sniff their values and uses density-based ("unknown") estimates.
if (stmt.Parameters.Count > 0)
// Skip trivial statements (simple variable assignments) where estimate quality doesn't matter.
if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01)
{
var unsnifffedParams = stmt.Parameters
.Where(p => string.IsNullOrEmpty(p.CompiledValue))
Expand Down Expand Up @@ -352,21 +355,42 @@
{
// Rule 1: Filter operators — rows survived the tree just to be discarded
// Quantify the impact by summing child subtree cost (reads, CPU, time).
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
// Suppress when the filter's child subtree is trivial (low I/O, fast, cheap).
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)
&& node.Children.Count > 0)
{
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";
// Gate: skip trivial filters based on actual stats or estimated cost
bool isTrivial;
if (node.HasActualStats)
{
long childReads = 0;
foreach (var child in node.Children)
childReads += SumSubtreeReads(child);
var childElapsed = node.Children.Max(c => c.ActualElapsedMs);
isTrivial = childReads < 128 && childElapsed < 10;
}
else
{
var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost);
isTrivial = childCost < 1.0;
}

node.Warnings.Add(new PlanWarning
if (!isTrivial)
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";

node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
}
}

// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
Expand All @@ -391,7 +415,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump the query results to a #temp table first and apply the UDF only to the final result set.",
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -451,7 +475,7 @@
node.Warnings.Add(new PlanWarning
{
WarningType = "Scalar UDF",
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.",
Severity = PlanWarningSeverity.Warning
});
}
Expand Down Expand Up @@ -829,18 +853,23 @@
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
node.Warnings.Add(new PlanWarning
// Require at least a 2x reduction to be worth mentioning — "1 to 1" or
// tiny floating-point differences that display identically are noise
if (reduction >= 2.0)
{
WarningType = "Row Goal",
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
Severity = PlanWarningSeverity.Info
});
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Goal",
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
Severity = PlanWarningSeverity.Info
});
}
}

// 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 (node.PhysicalOp.Contains("Row Count Spool"))

Check warning on line 872 in Lite/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
Expand Down Expand Up @@ -1066,6 +1095,13 @@
if (parent == null || parent.PhysicalOp != "Nested Loops")
return false;

// If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN
// subquery pattern (Merge Interval optimizing range lookups), not an OR expansion
var nlParent = parent.Parent;
if (nlParent != null && nlParent.LogicalOp != null &&
nlParent.LogicalOp.Contains("Semi"))
return false;

return true;
}

Expand Down
Loading
Loading