From 42c0fd2cebc4fac9475afcb64756bc5653cc730f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:59:26 -0400 Subject: [PATCH] Runtime card refinements (#215 E7, E8, E9, E10, E11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E7: card title is "Predicted Runtime" for estimated plans ("Runtime" for actual). Applied on HTML export, web viewer, and Avalonia (via new RuntimeSummaryTitle x:Name binding). - E8: used grant % > 100 is rendered as eff-bad (red) — an over-used grant is the opposite of healthy, not green. - E9: any operator in the plan that spilled to tempdb also forces the grant tier to eff-warn at best, regardless of utilization %. A 60%-used grant with a spilled sort underneath isn't "good". - E10: spill indicator appended to the Memory/Used row when any operator warning has a type ending in "Spill". Shows as "⚠ spill" (HTML + web use a styled .spill-tag span; Avalonia uses plain text suffix). - E11: Runtime box reordered to Joe's preferred layout — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory/Used → Optimization → CE Model → Cost Avalonia keeps its extra rows (UDF, Threads, Cached plan size, Grant wait, Early abort) slotted near logical neighbors. Cost moves from first to last across all three surfaces. Also adds a CPU:Elapsed row to the Avalonia panel (previously only on web and HTML). New GetMemoryGrantColorClass / MemoryGrantColor helper centralizes the tiering so the three surfaces stay consistent. HasSpillInTree / HasSpillInPlanTree walks warnings looking for types that end with " Spill". Version bump 1.7.8 -> 1.8.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controls/PlanViewerControl.axaml | 3 +- .../Controls/PlanViewerControl.axaml.cs | 111 ++++++++++++------ src/PlanViewer.App/PlanViewer.App.csproj | 2 +- src/PlanViewer.Core/Output/HtmlExporter.cs | 53 +++++++-- src/PlanViewer.Web/Pages/Index.razor | 71 ++++++++--- src/PlanViewer.Web/wwwroot/css/app.css | 7 ++ 6 files changed, 181 insertions(+), 66 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 0f3acb1..fcd6bd6 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -110,7 +110,8 @@ Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"> - pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; + private static bool HasSpillInPlanTree(PlanNode node) + { + foreach (var w in node.Warnings) + if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInPlanTree(child)) return true; + return false; + } + #endregion #region Node Selection & Properties Panel @@ -2809,37 +2818,42 @@ void AddRow(string label, string value, string? color = null) static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" : pct >= 20 ? "#FFB347" : "#E57373"; - // Runtime stats (actual plans) - if (statement.QueryTimeStats != null) + // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), + // any operator spilled (orange), otherwise tier by utilization. + static string MemoryGrantColor(double pctUsed, bool hasSpill) { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); - if (statement.QueryUdfCpuTimeMs > 0) - AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); - if (statement.QueryUdfElapsedTimeMs > 0) - AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + if (pctUsed > 100) return "#E57373"; + if (hasSpill) return "#FFB347"; + if (pctUsed >= 40) return "#E4E6EB"; + if (pctUsed >= 20) return "#FFB347"; + return "#E57373"; } - // Compile time — plan-level property (category B). Show regardless of - // threshold so it's always visible, not just when Rule 19 fires. - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + // E7: rename the panel title for estimated plans + var isEstimated = statement.QueryTimeStats == null; + RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; - // Memory grant — color by utilization percentage - if (statement.MemoryGrant != null) + var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); + + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. + + if (statement.QueryTimeStats != null) { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = EfficiencyColor(grantPct); - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + if (statement.QueryTimeStats.ElapsedTimeMs > 0) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + AddRow("CPU:Elapsed", ratio.ToString("N2")); + } } - // DOP + parallelism efficiency — color by efficiency + // DOP + parallelism efficiency if (statement.DegreeOfParallelism > 0) { var dopText = statement.DegreeOfParallelism.ToString(); @@ -2849,9 +2863,6 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" statement.QueryTimeStats.CpuTimeMs > 0 && statement.DegreeOfParallelism > 1) { - // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. - // Subtract external/preemptive wait time from CPU — those waits are CPU-busy - // in kernel and inflate the ratio without representing real query work. long externalWaitMs = 0; foreach (var w in statement.WaitStats) if (BenefitScorer.IsExternalWait(w.WaitType)) @@ -2868,7 +2879,37 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" else if (statement.NonParallelPlanReason != null) AddRow("Serial", statement.NonParallelPlanReason); - // Thread stats — color by utilization + if (statement.QueryTimeStats != null) + { + AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); + if (statement.QueryUdfCpuTimeMs > 0) + AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); + if (statement.QueryUdfElapsedTimeMs > 0) + AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + } + + // Compile stats (category B plan-level property) + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Memory grant — color per new tiers, spill indicator if any operator spilled + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); + var spillTag = hasSpillInTree ? " ⚠ spill" : ""; + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // Thread stats if (statement.ThreadStats != null) { var ts = statement.ThreadStats; @@ -2889,21 +2930,13 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" } } - // CE model - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - // Compile stats (always available) - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Optimization level + // Optimization + CE model if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) AddRow("Optimization", statement.StatementOptmLevel); if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 3064690..f6091dd 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -6,7 +6,7 @@ app.manifest EDD.ico true - 1.7.8 + 1.8.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 73e5950..3102c10 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -189,6 +189,7 @@ .card h3 { .warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; } .warn-legacy { font-size: 0.65rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.08); text-transform: uppercase; letter-spacing: 0.05em; } .warn-fix { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; flex-basis: 100%; border-left: 2px solid var(--border); padding-left: 0.5rem; margin-top: 0.15rem; } +.spill-tag { font-size: 0.75rem; font-weight: 600; color: var(--orange); margin-left: 0.4rem; } /* Query text */ details { margin-bottom: 0.75rem; } @@ -294,14 +295,18 @@ private static void WriteStatement(StringBuilder sb, AnalysisResult result, Stat private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) { + var isEstimated = stmt.QueryTime == null; + var hasSpill = HasSpillInTree(stmt.OperatorTree); sb.AppendLine("
"); - sb.AppendLine("

Runtime

"); + sb.AppendLine($"

{(isEstimated ? "Predicted Runtime" : "Runtime")}

"); sb.AppendLine("
"); - WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); + + // Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile → + // Memory → Used → Optimization → CE Model → Cost. Puts the important + // measurements on top and groups related metrics together. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); - WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); if (stmt.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0, stmt.QueryTime.CpuTimeMs - stmt.QueryTime.ExternalWaitMs); @@ -309,27 +314,61 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) WriteRow(sb, "CPU:Elapsed", ratio.ToString("N2")); } } - if (stmt.CompileTimeMs > 0) - WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.DegreeOfParallelism > 0) WriteRow(sb, "DOP", stmt.DegreeOfParallelism.ToString()); if (stmt.NonParallelReason != null) WriteRow(sb, "Serial", Encode(stmt.NonParallelReason)); + if (stmt.QueryTime != null) + WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); + if (stmt.CompileTimeMs > 0) + WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasSpill); WriteRow(sb, "Memory", FormatKB(stmt.MemoryGrant.GrantedKB) + " granted"); - sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)
"); + var spillTag = hasSpill ? " ⚠ spill" : ""; + sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%){spillTag}
"); } if (stmt.OptimizationLevel != null) WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine("
"); } + /// + /// Memory grant color tiers (#215 C1 + E8 + E9): + /// - > 100% used: eff-bad (grant was too small, may have thrashed memory) + /// - any operator spilled: eff-warn (grant was nominally enough but something spilled) + /// - >= 40% used: eff-good (healthy utilization) + /// - 20-39%: eff-warn (some over-grant) + /// - < 20%: eff-bad (significant over-grant) + /// + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + { + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) + return true; + } + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private static void WriteMissingIndexCard(StringBuilder sb, StatementResult stmt) { sb.AppendLine($"
"); diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index d31b574..5d46382 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -144,23 +144,20 @@ else
@* Runtime Summary *@ + @{ + var isEstimatedRuntime = ActiveStmt!.QueryTime == null; + var hasAnySpill = HasSpillInTree(ActiveStmt!.OperatorTree); + }
-

Runtime

+

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

-
- Cost - @ActiveStmt!.EstimatedCost.ToString("N2") -
+ @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ @if (ActiveStmt!.QueryTime != null) {
Elapsed @ActiveStmt!.QueryTime.ElapsedTimeMs.ToString("N0") ms
-
- CPU - @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms -
@if (ActiveStmt!.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0L, ActiveStmt!.QueryTime.CpuTimeMs - ActiveStmt!.QueryTime.ExternalWaitMs); @@ -171,13 +168,6 @@ else
} } - @if (ActiveStmt!.CompileTimeMs > 0) - { -
- Compile - @ActiveStmt!.CompileTimeMs.ToString("N0") ms -
- } @if (ActiveStmt!.DegreeOfParallelism > 0) {
@@ -192,17 +182,37 @@ else @ActiveStmt!.NonParallelReason
} + @if (ActiveStmt!.QueryTime != null) + { +
+ CPU + @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms +
+ } + @if (ActiveStmt!.CompileTimeMs > 0) + { +
+ Compile + @ActiveStmt!.CompileTimeMs.ToString("N0") ms +
+ } @if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasAnySpill);
Memory @FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted
Used - @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + + @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + @if (hasAnySpill) + { + ⚠ spill + } +
} @if (ActiveStmt!.OptimizationLevel != null) @@ -219,6 +229,10 @@ else @ActiveStmt!.CardinalityEstimationModel
} +
+ Cost + @ActiveStmt!.EstimatedCost.ToString("N2") +
@@ -1684,6 +1698,27 @@ else return v == null ? "?" : $"v{v.Major}.{v.Minor}.{v.Build}"; } + // Memory grant color tiers (#215 C1 + E8 + E9): + // > 100%: over-used grant (red). Spill in plan: orange. Otherwise: tier by utilization. + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private string activeTab = "paste"; private string planXml = ""; private string? errorMessage; diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 7ab9dfd..8c13d50 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -834,6 +834,13 @@ textarea::placeholder { margin-right: 0.4rem; } +.spill-tag { + font-size: 0.7rem; + font-weight: 600; + color: var(--warning-color); + margin-left: 0.4rem; +} + .warn-legacy { font-size: 0.65rem; font-weight: 600;