Skip to content

Commit a1f8362

Browse files
Merge pull request #237 from erikdarlingdata/dev
Merge dev to main — Joe #215 feedback through b1/b7
2 parents 32fea96 + 4626476 commit a1f8362

7 files changed

Lines changed: 328 additions & 240 deletions

File tree

src/PlanViewer.Core/Output/HtmlExporter.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt)
300300
{
301301
WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms");
302302
WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms");
303+
if (stmt.QueryTime.ElapsedTimeMs > 0)
304+
{
305+
var ratio = (double)stmt.QueryTime.CpuTimeMs / stmt.QueryTime.ElapsedTimeMs;
306+
WriteRow(sb, "CPU:Elapsed", ratio.ToString("N2"));
307+
}
303308
}
304309
if (stmt.DegreeOfParallelism > 0)
305310
WriteRow(sb, "DOP", stmt.DegreeOfParallelism.ToString());

src/PlanViewer.Core/Output/ResultMapper.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ private static OperatorResult MapNode(PlanNode node)
271271
Type = w.WarningType,
272272
Severity = w.Severity.ToString(),
273273
Message = w.Message,
274-
Operator = $"{node.PhysicalOp} (Node {node.NodeId})",
274+
Operator = FormatOperatorLabel(node),
275275
NodeId = node.NodeId,
276276
MaxBenefitPercent = w.MaxBenefitPercent,
277277
ActionableFix = w.ActionableFix
@@ -316,4 +316,22 @@ private static void CollectNodeWarnings(OperatorResult? node, List<WarningResult
316316
foreach (var child in node.Children)
317317
CollectNodeWarnings(child, warnings);
318318
}
319+
320+
/// <summary>
321+
/// Formats an operator label for the Operator field on warnings.
322+
/// Includes object name for data access operators (scans, seeks, lookups)
323+
/// where it helps identify which table/index is involved.
324+
/// </summary>
325+
private static string FormatOperatorLabel(PlanNode node)
326+
{
327+
if (!string.IsNullOrEmpty(node.ObjectName))
328+
{
329+
var objRef = !string.IsNullOrEmpty(node.DatabaseName)
330+
? $"{node.DatabaseName}.{node.ObjectName}"
331+
: node.ObjectName;
332+
return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})";
333+
}
334+
335+
return $"{node.PhysicalOp} (Node {node.NodeId})";
336+
}
319337
}

src/PlanViewer.Core/Services/BenefitScorer.cs

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ public static class BenefitScorer
1818
"Filter Operator", // Rule 1
1919
"Eager Index Spool", // Rule 2
2020
"Spill", // Rule 7
21-
"Key Lookup", // Rule 10
22-
"RID Lookup", // Rule 10 variant
21+
// Key Lookup / RID Lookup (Rule 10) handled separately by ScoreKeyLookupWarning
2322
"Scan With Predicate", // Rule 11
2423
"Non-SARGable Predicate", // Rule 12
2524
"Scan Cardinality Misestimate", // Rule 32
25+
"Bare Scan", // Rule 34
2626
};
2727

2828
public static void Score(ParsedPlan plan)
@@ -50,44 +50,18 @@ private static void ScoreStatementWarnings(PlanStatement stmt)
5050
{
5151
switch (warning.WarningType)
5252
{
53-
case "Ineffective Parallelism": // Rule 25
54-
case "Parallel Wait Bottleneck": // Rule 31
55-
// These are meta-findings about parallelism efficiency.
56-
// The benefit is the gap between actual and ideal elapsed time.
57-
if (elapsedMs > 0 && stmt.QueryTimeStats != null)
58-
{
59-
var cpu = stmt.QueryTimeStats.CpuTimeMs;
60-
var dop = stmt.DegreeOfParallelism;
61-
if (dop > 1 && cpu > 0)
62-
{
63-
// Ideal elapsed = CPU / DOP. Benefit = (actual - ideal) / actual
64-
var idealElapsed = (double)cpu / dop;
65-
var benefit = Math.Max(0, (elapsedMs - idealElapsed) / elapsedMs * 100);
66-
warning.MaxBenefitPercent = Math.Min(100, Math.Round(benefit, 1));
67-
}
68-
}
69-
break;
70-
7153
case "Serial Plan": // Rule 3
72-
// Can't know how fast a parallel plan would be, but estimate:
73-
// CPU-bound: benefit up to (1 - 1/maxDOP) * 100%
74-
if (elapsedMs > 0 && stmt.QueryTimeStats != null)
54+
// Per Joe's formula: (cpu * (DOP - 1) / DOP) / elapsed * 100
55+
// Assumes DOP 4 when the plan doesn't tell us. No benefit when cost < 1
56+
// (trivial plans don't gain from parallelism).
57+
if (elapsedMs > 0 && stmt.QueryTimeStats != null && stmt.StatementSubTreeCost >= 1.0)
7558
{
7659
var cpu = stmt.QueryTimeStats.CpuTimeMs;
77-
// Assume server max DOP — use a conservative 4 if unknown
7860
var potentialDop = 4;
79-
if (cpu >= elapsedMs)
80-
{
81-
// CPU-bound: parallelism could help significantly
82-
var benefit = (1.0 - 1.0 / potentialDop) * 100;
83-
warning.MaxBenefitPercent = Math.Round(benefit, 1);
84-
}
85-
else
61+
if (cpu > 0)
8662
{
87-
// Not CPU-bound: parallelism helps less
88-
var cpuRatio = (double)cpu / elapsedMs;
89-
var benefit = cpuRatio * (1.0 - 1.0 / potentialDop) * 100;
90-
warning.MaxBenefitPercent = Math.Round(Math.Min(50, benefit), 1);
63+
var benefit = ((double)cpu * (potentialDop - 1) / potentialDop) / elapsedMs * 100;
64+
warning.MaxBenefitPercent = Math.Round(Math.Min(100, benefit), 1);
9165
}
9266
}
9367
break;
@@ -150,6 +124,10 @@ private static void ScoreNodeWarnings(PlanNode node, PlanStatement stmt)
150124
{
151125
ScoreSpillWarning(warning, node, stmt);
152126
}
127+
else if (warning.WarningType is "Key Lookup" or "RID Lookup") // Rule 10
128+
{
129+
ScoreKeyLookupWarning(warning, node, stmt);
130+
}
153131
else if (OperatorTimeRules.Contains(warning.WarningType))
154132
{
155133
ScoreByOperatorTime(warning, node, stmt);
@@ -159,7 +137,8 @@ private static void ScoreNodeWarnings(PlanNode node, PlanStatement stmt)
159137
ScoreEstimateMismatchWarning(warning, node, stmt);
160138
}
161139
// Rules that stay null: Scalar UDF (Rule 6, informational reference),
162-
// Parallel Skew (Rule 8), Data Type Mismatch (Rule 13),
140+
// Parallel Skew (Rule 8 — will be integrated per-operator later),
141+
// Data Type Mismatch (Rule 13),
163142
// Lazy Spool Ineffective (Rule 14), Join OR Clause (Rule 15),
164143
// Many-to-Many Merge Join (Rule 17), CTE Multiple References (Rule 21),
165144
// Table Variable (Rule 22), Table-Valued Function (Rule 23),
@@ -282,6 +261,61 @@ private static void ScoreByOperatorTime(PlanWarning warning, PlanNode node, Plan
282261
}
283262
}
284263

264+
/// <summary>
265+
/// Rule 10: Key Lookup / RID Lookup — benefit includes the lookup operator's time,
266+
/// plus the parent Nested Loops join when the NL only exists to drive the lookup
267+
/// (inner child is the lookup, outer child is a seek/scan with no subtree).
268+
/// </summary>
269+
private static void ScoreKeyLookupWarning(PlanWarning warning, PlanNode node, PlanStatement stmt)
270+
{
271+
var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0;
272+
273+
if (node.HasActualStats && stmtMs > 0)
274+
{
275+
var operatorMs = PlanAnalyzer.GetOperatorOwnElapsedMs(node);
276+
277+
// Check if the parent NL join is purely a lookup driver:
278+
// - Parent is Nested Loops
279+
// - Has exactly 2 children
280+
// - This node (the lookup) is the inner child (index 1)
281+
// - The outer child (index 0) is a simple seek/scan with no children
282+
var parent = node.Parent;
283+
if (parent != null
284+
&& parent.PhysicalOp == "Nested Loops"
285+
&& parent.Children.Count == 2
286+
&& parent.Children[1] == node
287+
&& parent.Children[0].Children.Count == 0)
288+
{
289+
operatorMs += PlanAnalyzer.GetOperatorOwnElapsedMs(parent);
290+
}
291+
292+
if (operatorMs > 0)
293+
{
294+
var benefit = (double)operatorMs / stmtMs * 100;
295+
warning.MaxBenefitPercent = Math.Round(Math.Min(100, benefit), 1);
296+
}
297+
else
298+
{
299+
warning.MaxBenefitPercent = 0;
300+
}
301+
}
302+
else if (!node.HasActualStats && stmt.StatementSubTreeCost > 0)
303+
{
304+
var benefit = (double)node.CostPercent;
305+
// Same parent-NL logic for estimated plans
306+
var parent = node.Parent;
307+
if (parent != null
308+
&& parent.PhysicalOp == "Nested Loops"
309+
&& parent.Children.Count == 2
310+
&& parent.Children[1] == node
311+
&& parent.Children[0].Children.Count == 0)
312+
{
313+
benefit += parent.CostPercent;
314+
}
315+
warning.MaxBenefitPercent = Math.Round(Math.Min(100, benefit), 1);
316+
}
317+
}
318+
285319
/// <summary>
286320
/// Rule 5: Row Estimate Mismatch — benefit is the harmed operator's time.
287321
/// If the mismatch caused a spill, benefit = spilling operator time.

0 commit comments

Comments
 (0)