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
8 changes: 3 additions & 5 deletions src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,13 @@ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int total
// to compare fairly against the summed ActualRows. Everywhere else — including a
// parallel zone, where ActualExecutions just counts threads — the estimate stays
// per-execution. RowEstimateHelper is the one place that decides which applies.
var expectedRows = RowEstimateHelper.GetExpectedRows(node);
// #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict
// the percentage ("1 of 1 (89%)").
var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(node);
IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush;
var accuracy = expectedRows > 0
? $" ({accuracyRatio * 100:F0}%)"
: "";
stack.Children.Add(new TextBlock
{
Text = $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}",
Text = PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node)),
FontSize = 10,
Foreground = rowBrush,
TextAlignment = TextAlignment.Center,
Expand Down
8 changes: 5 additions & 3 deletions src/PlanViewer.Core/Output/HtmlExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Web;
using PlanViewer.Core.Services;

namespace PlanViewer.Core.Output;

Expand Down Expand Up @@ -573,10 +574,11 @@ private static void WriteOperatorLine(StringBuilder sb, OperatorResult node)
if (node.ActualRows.HasValue)
{
// #594: the same execution-aware estimate the plan viewer's node label shows.
// #611: and the same printed numbers, so they agree with the percentage.
var est = node.ExpectedRows ?? node.EstimatedRows;
var ratio = est > 0 ? (double)node.ActualRows.Value / est : 0;
var accuracy = est > 0 ? $" ({ratio * 100:F0}%)" : "";
sb.Append($" <span class=\"op-rows\">{node.ActualRows.Value:N0} of {est:N0} rows{accuracy}</span>");
var (actualText, expectedText, percent) = PlanRowAccuracy.PrintActualOfExpected(node.ActualRows.Value, est);
var accuracy = percent != null ? $" ({percent}%)" : "";
sb.Append($" <span class=\"op-rows\">{actualText} of {expectedText} rows{accuracy}</span>");
}
else
{
Expand Down
110 changes: 110 additions & 0 deletions src/PlanViewer.Core/Services/PlanRowAccuracy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System;
using System.Globalization;

namespace PlanViewer.Core.Services;

/// <summary>
/// The plan viewer's node row line: the rows an operator actually returned against the rows it was
/// expected to, printed as <c>"{actual} of {expected} ({percent}%)"</c>. The expected figure is
/// <see cref="RowEstimateHelper.GetExpectedRows"/>, a total like <c>ActualRows</c> (#594). Shared by
/// the App and Web node labels and the HTML export (#611). PerformanceMonitor's plan viewer ports
/// the same rule (its <c>PlanRowAccuracy</c>, #4684), so both apps print the same string for the
/// same plan.
/// </summary>
public static class PlanRowAccuracy
{
/// <summary>The most decimals <see cref="PrintActualOfExpected"/> adds to make a label agree with its percentage.</summary>
private const int MaxAgreementDecimals = 4;

/// <summary>
/// The node row line: <c>"{actual} of {expected} ({percent}%)"</c>, or just <c>"{actual} of {expected}"</c>
/// when <paramref name="expectedRows"/> is not above zero. See <see cref="PrintActualOfExpected"/> for the rule.
/// A Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each) reads <c>1 of 1.128 (89%)</c>, not the
/// <c>1 of 1 (89%)</c> that printing both counts <c>N0</c> gave.
/// </summary>
public static string FormatActualOfExpected(double actualRows, double expectedRows, IFormatProvider? provider = null)
{
var (actual, expected, percent) = PrintActualOfExpected(actualRows, expectedRows, provider);
return percent == null
? string.Concat(actual, " of ", expected)
: string.Concat(actual, " of ", expected, " (", percent, "%)");
}

/// <summary>
/// The two row counts and the whole percentage that <see cref="FormatActualOfExpected"/> joins, for a surface
/// that words the line its own way (the HTML export adds "rows"). The rule, word for word:
/// <i>Print N0. If the whole percent computed from the printed numbers differs from the printed percentage, add the
/// fewest decimals (fixed-point, never scientific, capped at 4) at which they agree. A non-zero value never prints as
/// zero. If it would, print it fixed-point to its first significant digit.</i>
/// <list type="bullet">
/// <item>The percentage is <c>actualRows / expectedRows * 100</c> to a whole number, from the unrounded values. It is
/// null when <paramref name="expectedRows"/> is not above zero (there is nothing to divide by).</item>
/// <item>The agreement search runs from 0 to 4 decimals. At each count a whole number prints <c>N0</c> and any other
/// number prints <c>N</c> plus that count. The text is parsed back (group separator allowed, so "2,983" reads as
/// 2983) and the search stops at the first count where the whole percent of the printed numbers equals the printed
/// percentage. A printed divisor of zero never agrees. At 4 it stops either way, so a value like 0.00012345 can
/// print a line that still contradicts its percentage: the cap wins.</item>
/// <item>A non-zero value whose text would read as zero is reprinted with as many decimals as it takes to show its
/// first significant digit (0.000005 prints "0.000005"); the cap does not apply to that.</item>
/// <item>Only <c>N</c> formats print the row counts, so no magnitude prints an exponent. Culture follows
/// <paramref name="provider"/>, the current culture by default.</item>
/// </list>
/// </summary>
public static (string Actual, string Expected, string? Percent) PrintActualOfExpected(
double actualRows, double expectedRows, IFormatProvider? provider = null)
{
provider ??= CultureInfo.CurrentCulture;

var percent = expectedRows > 0 ? WholePercent(actualRows, expectedRows, provider) : null;

var actualText = "";
var expectedText = "";
for (var decimals = 0; decimals <= MaxAgreementDecimals; decimals++)
{
actualText = PrintRows(actualRows, decimals, provider);
expectedText = PrintRows(expectedRows, decimals, provider);

if (percent == null || PrintedNumbersGive(percent, actualText, expectedText, provider))
break;
}

return (actualText, expectedText, percent);
}

private static string WholePercent(double actualRows, double expectedRows, IFormatProvider provider)
=> (actualRows / expectedRows * 100).ToString("F0", provider);

/// <summary>
/// One row count as text: a whole number is always <c>N0</c>, any other number <c>N{decimals}</c>. A non-zero
/// value that would print as zero is reprinted to its first significant digit instead.
/// </summary>
private static string PrintRows(double value, int decimals, IFormatProvider provider)
{
var text = value.ToString(Math.Floor(value) == value ? "N0" : NFormat(decimals), provider);

if (value != 0 && double.IsFinite(value)
&& double.TryParse(text, NumberStyles.Number, provider, out var printed) && printed == 0)
{
var firstSignificantDecimals = -(int)Math.Floor(Math.Log10(Math.Abs(value)));
text = value.ToString(NFormat(firstSignificantDecimals), provider);
}

return text;
}

/// <summary>True when the whole percent of the two printed numbers is the printed percentage.</summary>
private static bool PrintedNumbersGive(string percent, string actualText, string expectedText, IFormatProvider provider)
{
if (!double.TryParse(actualText, NumberStyles.Number, provider, out var printedActual)
|| !double.TryParse(expectedText, NumberStyles.Number, provider, out var printedExpected))
{
return false;
}

// A printed divisor of zero gives NaN or Infinity, which never equals a percentage.
var printedPercent = printedActual / printedExpected * 100;
return double.IsFinite(printedPercent) && printedPercent.ToString("F0", provider) == percent;
}

private static string NFormat(int decimals) => string.Create(CultureInfo.InvariantCulture, $"N{decimals}");
}
6 changes: 3 additions & 3 deletions src/PlanViewer.Web/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -538,13 +538,13 @@ else
// #594: EstimateRows is per execution — RowEstimateHelper scales it by
// ActualExecutions only on the inner side of a Nested Loops join, where that count is
// real rather than a parallel zone's thread count.
var expectedRows = RowEstimateHelper.GetExpectedRows(node);
// #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict
// the percentage ("1 of 1 (89%)").
var ratio = RowEstimateHelper.GetRowAccuracyRatio(node);
var rowClass = (ratio < 0.1 || ratio > 10.0) ? " rows-skewed" : "";
var accuracy = expectedRows > 0 ? $" ({ratio * 100:F0}%)" : "";
builder.OpenElement(28, "div");
builder.AddAttribute(29, "class", $"node-rows{rowClass}");
builder.AddContent(30, $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}");
builder.AddContent(30, PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node)));
builder.CloseElement();
}
else
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.Web/PlanViewer.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Compile Include="..\PlanViewer.Core\Services\PlanAnalyzer.Timing.cs" Link="Core\Services\PlanAnalyzer.Timing.cs" />
<Compile Include="..\PlanViewer.Core\Services\PlanAnalyzer.Helpers.cs" Link="Core\Services\PlanAnalyzer.Helpers.cs" />
<Compile Include="..\PlanViewer.Core\Services\RowEstimateHelper.cs" Link="Core\Services\RowEstimateHelper.cs" />
<Compile Include="..\PlanViewer.Core\Services\PlanRowAccuracy.cs" Link="Core\Services\PlanRowAccuracy.cs" />
<Compile Include="..\PlanViewer.Core\Services\BenefitScorer.cs" Link="Core\Services\BenefitScorer.cs" />
<Compile Include="..\PlanViewer.Core\Services\NodeTimeAttribution.cs" Link="Core\Services\NodeTimeAttribution.cs" />
<Compile Include="..\PlanViewer.Core\Services\WaitStatsConfig.cs" Link="Core\Services\WaitStatsConfig.cs" />
Expand Down
Loading
Loading