From b6b0eb7530674d1ade757dd067adce29d1bddc83 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:42:22 -0400 Subject: [PATCH] Add wait stats benefit scoring (Stage 2 of #215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calculate MaxBenefitPercent for each wait type in plan wait stats. Serial plans use a simple ratio (waitMs/elapsedMs). Parallel plans use Joe's proportional allocation formula, mapping wait types to relevant operators (I/O waits to operators with physical reads, CPU waits to operators with CPU work, etc.). Parallelism waits (CXPACKET/CXCONSUMER/CXSYNC) use the efficiency gap formula instead of raw wait time — threads waiting for other threads is a symptom, not directly addressable time. Benefits shown in CLI text, HTML export, JSON API (wait_benefits array), and desktop app wait stats ribbon. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/PlanViewerControl.axaml.cs | 31 ++- src/PlanViewer.Core/Models/PlanModels.cs | 8 + src/PlanViewer.Core/Output/AnalysisResult.cs | 16 ++ src/PlanViewer.Core/Output/HtmlExporter.cs | 10 +- src/PlanViewer.Core/Output/ResultMapper.cs | 11 + src/PlanViewer.Core/Output/TextFormatter.cs | 12 +- src/PlanViewer.Core/Services/BenefitScorer.cs | 215 ++++++++++++++++++ 7 files changed, 296 insertions(+), 7 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index fc8f3a9..a1ebeca 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -377,7 +377,7 @@ private void RenderStatement(PlanStatement statement) // Update banners ShowMissingIndexes(statement.MissingIndexes); ShowParameters(statement); - ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null); + ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null); ShowRuntimeSummary(statement); UpdateInsightsHeader(); @@ -2635,7 +2635,7 @@ private static long GetChildElapsedMsSum(PlanNode node) return sum; } - private void ShowWaitStats(List waits, bool isActualPlan) + private void ShowWaitStats(List waits, List benefits, bool isActualPlan) { WaitStatsContent.Children.Clear(); @@ -2651,6 +2651,11 @@ private void ShowWaitStats(List waits, bool isActualPlan) WaitStatsEmpty.IsVisible = false; + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in benefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); var maxWait = sorted[0].WaitTimeMs; var totalWait = sorted.Sum(w => w.WaitTimeMs); @@ -2659,10 +2664,10 @@ private void ShowWaitStats(List waits, bool isActualPlan) WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; // Build a single Grid for all rows so columns align - // Name and duration auto-size; bar fills remaining space + // Name, bar, duration, and benefit columns var grid = new Grid { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto") + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") }; for (int i = 0; i < sorted.Count; i++) grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); @@ -2709,11 +2714,27 @@ private void ShowWaitStats(List waits, bool isActualPlan) FontSize = 12, Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) + Margin = new Thickness(0, 2, 8, 2) }; Grid.SetRow(durationText, i); Grid.SetColumn(durationText, 2); grid.Children.Add(durationText); + + // Benefit % (if available) + if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) + { + var benefitText = new TextBlock + { + Text = $"up to {benefitPct:N0}%", + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#8b949e")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(benefitText, i); + Grid.SetColumn(benefitText, 3); + grid.Children.Add(benefitText); + } } WaitStatsContent.Children.Add(grid); diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index 3950ba6..c839e35 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -62,6 +62,7 @@ public class PlanStatement public SetOptionsInfo? SetOptions { get; set; } public List Parameters { get; set; } = new(); public List WaitStats { get; set; } = new(); + public List WaitBenefits { get; set; } = new(); public QueryTimeInfo? QueryTimeStats { get; set; } // MaxQueryMemory + QueryPlan-level warnings @@ -447,6 +448,13 @@ public class PlanParameter public string? RuntimeValue { get; set; } } +public class WaitBenefit +{ + public string WaitType { get; set; } = ""; + public double MaxBenefitPercent { get; set; } + public string Category { get; set; } = ""; +} + public class WaitStatInfo { public string WaitType { get; set; } = ""; diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index 60168a1..21da4fc 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -139,6 +139,10 @@ public class StatementResult [JsonPropertyName("wait_stats")] public List WaitStats { get; set; } = new(); + // Wait stats benefit analysis + [JsonPropertyName("wait_benefits")] + public List WaitBenefits { get; set; } = new(); + // Cursor metadata [JsonPropertyName("cursor")] public CursorResult? Cursor { get; set; } @@ -353,6 +357,18 @@ public class WaitStatResult public long WaitCount { get; set; } } +public class WaitBenefitResult +{ + [JsonPropertyName("wait_type")] + public string WaitType { get; set; } = ""; + + [JsonPropertyName("max_benefit_percent")] + public double MaxBenefitPercent { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = ""; +} + public class CursorResult { [JsonPropertyName("name")] diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4757268..a2fe5c0 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -391,14 +391,22 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b sb.AppendLine("
"); if (stmt.WaitStats.Count > 0) { + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in stmt.WaitBenefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + var maxWait = stmt.WaitStats.Max(w => w.WaitTimeMs); foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) { var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; + var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct) + ? $" up to {pct:N0}%" + : ""; sb.AppendLine("
"); sb.AppendLine($"{Encode(w.WaitType)}"); sb.AppendLine($"
"); - sb.AppendLine($"{w.WaitTimeMs:N0} ms"); + sb.AppendLine($"{w.WaitTimeMs:N0} ms{benefitTag}"); sb.AppendLine("
"); } } diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index d61d44e..abfab98 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -129,6 +129,17 @@ private static StatementResult MapStatement(PlanStatement stmt) }); } + // Wait stat benefits + foreach (var wb in stmt.WaitBenefits) + { + result.WaitBenefits.Add(new WaitBenefitResult + { + WaitType = wb.WaitType, + MaxBenefitPercent = wb.MaxBenefitPercent, + Category = wb.Category + }); + } + // Parameters — flag potential sniffing issues foreach (var p in stmt.Parameters) { diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index 02f77a5..da655ad 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -131,8 +131,18 @@ public static void WriteText(AnalysisResult result, TextWriter writer) if (stmt.WaitStats.Count > 0) { writer.WriteLine("Wait stats:"); + // Build a lookup from wait type to benefit % + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in stmt.WaitBenefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) - writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms"); + { + var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct) + ? $" (up to {pct:N0}% benefit)" + : ""; + writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms{benefitTag}"); + } } if (stmt.Parameters.Count > 0) diff --git a/src/PlanViewer.Core/Services/BenefitScorer.cs b/src/PlanViewer.Core/Services/BenefitScorer.cs index 7508294..7fa24a0 100644 --- a/src/PlanViewer.Core/Services/BenefitScorer.cs +++ b/src/PlanViewer.Core/Services/BenefitScorer.cs @@ -35,6 +35,9 @@ public static void Score(ParsedPlan plan) if (stmt.RootNode != null) ScoreNodeTree(stmt.RootNode, stmt); + + if (stmt.WaitStats.Count > 0 && stmt.QueryTimeStats != null) + ScoreWaitStats(stmt); } } } @@ -376,4 +379,216 @@ private static long GetParallelismOperatorElapsedMs(PlanNode node) var maxChildElapsed = node.Children.Max(c => c.ActualElapsedMs); return Math.Max(0, node.ActualElapsedMs - maxChildElapsed); } + + // --------------------------------------------------------------- + // Stage 2: Wait Stats Benefit + // --------------------------------------------------------------- + + /// + /// Calculates MaxBenefitPercent for each wait type in the statement's wait stats. + /// Serial plans: simple ratio of wait time to elapsed time. + /// Parallel plans: proportional allocation across relevant operators (Joe's formula). + /// + private static void ScoreWaitStats(PlanStatement stmt) + { + var elapsedMs = stmt.QueryTimeStats!.ElapsedTimeMs; + if (elapsedMs <= 0) return; + + var isParallel = stmt.DegreeOfParallelism > 1 && stmt.RootNode != null; + + // Collect all operators with per-thread stats for parallel benefit calculation + List? operatorProfiles = null; + if (isParallel) + { + operatorProfiles = new List(); + CollectOperatorWaitProfiles(stmt.RootNode!, operatorProfiles); + } + + foreach (var wait in stmt.WaitStats) + { + if (wait.WaitTimeMs <= 0) continue; + + var category = ClassifyWaitType(wait.WaitType); + double benefitPct; + + if (category == "Parallelism" && isParallel) + { + // CXPACKET/CXCONSUMER/CXSYNC: benefit is the parallelism efficiency gap, + // not the raw wait time. Threads waiting for other threads is a symptom + // of imperfect parallelism, not directly addressable time. + var cpu = stmt.QueryTimeStats!.CpuTimeMs; + var dop = stmt.DegreeOfParallelism; + if (cpu > 0 && dop > 1) + { + var idealElapsed = (double)cpu / dop; + benefitPct = Math.Max(0, (elapsedMs - idealElapsed) / elapsedMs * 100); + } + else + { + benefitPct = (double)wait.WaitTimeMs / elapsedMs * 100; + } + } + else if (!isParallel || operatorProfiles == null || operatorProfiles.Count == 0) + { + // Serial plan or no operator data: simple ratio + benefitPct = (double)wait.WaitTimeMs / elapsedMs * 100; + } + else + { + // Parallel plan: proportional allocation across relevant operators + benefitPct = CalculateParallelWaitBenefit(wait, category, operatorProfiles, elapsedMs); + } + + stmt.WaitBenefits.Add(new WaitBenefit + { + WaitType = wait.WaitType, + MaxBenefitPercent = Math.Round(Math.Min(100, Math.Max(0, benefitPct)), 1), + Category = category + }); + } + } + + /// + /// Parallel wait benefit using Joe's formula: + /// benefit = (SUM relevant operator max waits) * (total_wait_for_type) / (SUM relevant operator total waits) + /// Then convert to % of statement elapsed time. + /// + private static double CalculateParallelWaitBenefit( + WaitStatInfo wait, string category, + List profiles, long stmtElapsedMs) + { + // Filter to operators relevant for this wait category + var relevant = new List(); + foreach (var p in profiles) + { + if (IsOperatorRelevantForCategory(p, category)) + relevant.Add(p); + } + + // If no operators match, fall back to simple ratio + if (relevant.Count == 0) + return (double)wait.WaitTimeMs / stmtElapsedMs * 100; + + // Joe's formula: + // sum_max = SUM of each relevant operator's max per-thread wait time + // sum_total = SUM of each relevant operator's total wait time across all threads + // benefit_ms = sum_max * wait.WaitTimeMs / sum_total + double sumMax = 0; + double sumTotal = 0; + foreach (var p in relevant) + { + sumMax += p.MaxThreadWaitMs; + sumTotal += p.TotalWaitMs; + } + + if (sumTotal <= 0) + return (double)wait.WaitTimeMs / stmtElapsedMs * 100; + + var benefitMs = sumMax * wait.WaitTimeMs / sumTotal; + return benefitMs / stmtElapsedMs * 100; + } + + /// + /// Determines if an operator is relevant for a given wait category. + /// + private static bool IsOperatorRelevantForCategory(OperatorWaitProfile profile, string category) + { + return category switch + { + "I/O" => profile.HasPhysicalReads, + "CPU" => profile.HasCpuWork, + "Parallelism" => profile.IsExchange, + "Hash" => profile.IsHashOperator, + "Sort" => profile.IsSortOperator, + "Latch" => profile.HasTempDbActivity, + "Lock" => true, // any operator can be blocked by locks + "Network" => false, // ASYNC_NETWORK_IO is client-side, not attributable to operators + "Memory" => false, // memory waits are statement-level + _ => true, // unknown category: include all operators + }; + } + + /// + /// Walks the operator tree and collects wait time profiles for each operator. + /// Wait time per thread = max(0, elapsed - cpu) for that thread. + /// + private static void CollectOperatorWaitProfiles(PlanNode node, List profiles) + { + if (node.HasActualStats && node.PerThreadStats.Count > 0) + { + long maxThreadWait = 0; + long totalWait = 0; + + foreach (var ts in node.PerThreadStats) + { + var threadWait = Math.Max(0, ts.ActualElapsedMs - ts.ActualCPUMs); + totalWait += threadWait; + if (threadWait > maxThreadWait) + maxThreadWait = threadWait; + } + + if (totalWait > 0 || maxThreadWait > 0) + { + profiles.Add(new OperatorWaitProfile + { + Node = node, + MaxThreadWaitMs = maxThreadWait, + TotalWaitMs = totalWait, + HasPhysicalReads = node.ActualPhysicalReads > 0, + HasCpuWork = node.ActualCPUMs > 0, + IsExchange = node.PhysicalOp == "Parallelism", + IsHashOperator = node.PhysicalOp.StartsWith("Hash", StringComparison.OrdinalIgnoreCase), + IsSortOperator = node.PhysicalOp.StartsWith("Sort", StringComparison.OrdinalIgnoreCase), + HasTempDbActivity = node.Warnings.Any(w => w.SpillDetails != null) + || node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) + }); + } + } + + foreach (var child in node.Children) + CollectOperatorWaitProfiles(child, profiles); + } + + /// + /// Classifies a wait type into a category for operator-to-wait mapping. + /// + internal static string ClassifyWaitType(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH") => "I/O", + _ when wt.Contains("IO_COMPLETION") => "I/O", + _ when wt.StartsWith("WRITELOG") => "I/O", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU", + _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "Parallelism", + _ when wt.StartsWith("CXSYNC") => "Parallelism", + _ when wt.StartsWith("HT") => "Hash", + _ when wt == "BPSORT" => "Sort", + _ when wt == "BMPBUILD" => "Hash", + _ when wt.StartsWith("PAGELATCH") => "Latch", + _ when wt.StartsWith("LATCH_") => "Latch", + _ when wt.StartsWith("LCK_") => "Lock", + _ when wt == "ASYNC_NETWORK_IO" => "Network", + _ when wt.Contains("MEMORY_ALLOCATION") => "Memory", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "Memory", + _ => "Other" + }; + } + + /// + /// Per-operator wait time profile used for parallel benefit allocation. + /// + private sealed class OperatorWaitProfile + { + public PlanNode Node { get; init; } = null!; + public long MaxThreadWaitMs { get; init; } + public long TotalWaitMs { get; init; } + public bool HasPhysicalReads { get; init; } + public bool HasCpuWork { get; init; } + public bool IsExchange { get; init; } + public bool IsHashOperator { get; init; } + public bool IsSortOperator { get; init; } + public bool HasTempDbActivity { get; init; } + } }