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
3 changes: 2 additions & 1 deletion src/PlanViewer.App/Controls/PlanViewerControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
Background="{DynamicResource BackgroundDarkBrush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
<StackPanel>
<TextBlock Text="Runtime Summary"
<TextBlock x:Name="RuntimeSummaryTitle"
Text="Runtime Summary"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource ForegroundBrush}"
Expand Down
111 changes: 72 additions & 39 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,15 @@ private static string FormatBytes(double bytes)
private static string FormatBenefitPercent(double pct) =>
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
Expand Down Expand Up @@ -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();
Expand All @@ -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))
Expand All @@ -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;
Expand All @@ -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)
{
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>EDD.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Version>1.7.8</Version>
<Version>1.8.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
14 changes: 14 additions & 0 deletions src/PlanViewer.Core/Output/AnalysisResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ public class MemoryGrantResult

[JsonPropertyName("estimated_available_memory_grant_kb")]
public long EstimatedAvailableMemoryGrantKB { get; set; }

/// <summary>
/// Optimizer's pre-execution "desired" grant (parallel-adjusted).
/// Non-zero on estimated plans; pairs with DesiredKB serial-required as fallback
/// when no runtime-granted memory exists (#215 E6).
/// </summary>
[JsonPropertyName("desired_kb")]
public long DesiredKB { get; set; }

/// <summary>
/// Optimizer's pre-execution serial-required grant (memory minimum before DOP scaling).
/// </summary>
[JsonPropertyName("serial_required_kb")]
public long SerialRequiredKB { get; set; }
}

public class QueryTimeResult
Expand Down
76 changes: 64 additions & 12 deletions src/PlanViewer.Core/Output/HtmlExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,15 @@ .statement h2 {
/* Insights grid */
.insights { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
.card { border-radius: 6px; border: 1px solid var(--border); overflow: hidden; }
.card h3 {
.card h3, .card > summary {
padding: 0.4rem 0.75rem; font-size: 0.8rem; font-weight: 500;
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
list-style: none; cursor: pointer;
}
.card > summary::-webkit-details-marker { display: none; }
.card > summary::before { content: ""\25B8""; font-size: 0.7rem; color: var(--text-muted); width: 0.7rem; }
details.card[open] > summary::before { content: ""\25BE""; }
.card.waits summary { color: #2a4365; }
.card-body { padding: 0.5rem 0.75rem; font-size: 0.8rem; }
.card.runtime { background: var(--card-runtime); border-color: var(--card-runtime-border); }
.card.runtime h3 { color: #2c5282; }
Expand Down Expand Up @@ -189,6 +194,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; }
Expand Down Expand Up @@ -294,42 +300,87 @@ 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("<div class=\"card runtime\">");
sb.AppendLine("<h3>Runtime</h3>");
sb.AppendLine($"<h3>{(isEstimated ? "Predicted Runtime" : "Runtime")}</h3>");
sb.AppendLine("<div class=\"card-body\">");
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);
var ratio = (double)effectiveCpu / stmt.QueryTime.ElapsedTimeMs;
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($"<div class=\"row\"><span class=\"label\">Used</span><span class=\"value {effClass}\">{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)</span></div>");
var spillTag = hasSpill ? " <span class=\"spill-tag\" title=\"Operators spilled to tempdb\">⚠ spill</span>" : "";
sb.AppendLine($"<div class=\"row\"><span class=\"label\">Used</span><span class=\"value {effClass}\">{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%){spillTag}</span></div>");
}
else if (isEstimated && stmt.MemoryGrant != null && stmt.MemoryGrant.DesiredKB > 0)
{
// #215 E6: estimated plans — show the optimizer's pre-execution desired grant
WriteRow(sb, "Memory (estimated)", FormatKB(stmt.MemoryGrant.DesiredKB) + " desired");
if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB)
WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB));
}
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("</div>");
sb.AppendLine("</div>");
}

/// <summary>
/// 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)
/// </summary>
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($"<div class=\"card indexes\">");
Expand Down Expand Up @@ -393,11 +444,12 @@ private static void WriteParametersCard(StringBuilder sb, StatementResult stmt)

private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, bool hasActualStats)
{
sb.AppendLine("<div class=\"card waits\">");
sb.Append("<h3>Wait Stats");
// Collapsible (#215 E12): default-closed so improvement items aren't pushed below the fold.
sb.AppendLine("<details class=\"card waits\">");
sb.Append("<summary>Wait Stats");
if (stmt.WaitStats.Count > 0)
sb.Append($" <span class=\"card-count\">{stmt.WaitStats.Sum(w => w.WaitTimeMs):N0} ms</span>");
sb.AppendLine("</h3>");
sb.AppendLine("</summary>");
sb.AppendLine("<div class=\"card-body\">");
if (stmt.WaitStats.Count > 0)
{
Expand Down Expand Up @@ -425,7 +477,7 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b
sb.AppendLine($"<div class=\"card-empty\">{(hasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")}</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</div>");
sb.AppendLine("</details>");
}

private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
Expand Down
4 changes: 3 additions & 1 deletion src/PlanViewer.Core/Output/ResultMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ private static StatementResult MapStatement(PlanStatement stmt)
MaxUsedKB = stmt.MemoryGrant.MaxUsedMemoryKB,
GrantWaitMs = stmt.MemoryGrant.GrantWaitTimeMs,
FeedbackAdjusted = stmt.MemoryGrant.IsMemoryGrantFeedbackAdjusted,
EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0
EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0,
DesiredKB = stmt.MemoryGrant.DesiredMemoryKB,
SerialRequiredKB = stmt.MemoryGrant.SerialRequiredMemoryKB
};
}

Expand Down
Loading
Loading