[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Conversation
… durations On .NET 8+, add a fast path that uses string.Create with a stackalloc buffer for the most common case (duration < 1 hour, no milliseconds). Before: each Render call allocated a StringBuilder + 1-2 intermediate strings from GetFormattedPart + the final result string (3-4 allocations). After (fast path): only the final result string is allocated (1 allocation). This method is called on every progress-frame render tick (roughly 5 times per frame) to format durations for each visible test-worker line. The savings accumulate quickly during long-running parallel test runs. All progress-frame callers (AnsiTerminalTestProgressFrame, SimpleTerminalBase) use the default parameters (wrapInParentheses=true, showMilliseconds=false), so they all benefit from the fast path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes HumanReadableDurationFormatter.Render in Microsoft.Testing.Platform’s terminal output path by adding a .NET 8+ fast path for the common “sub-hour, no milliseconds” case, reducing per-call allocations during frequent progress-frame rendering.
Changes:
- Added a
#if NET8_0_OR_GREATERfast path usingstring.Createwith astackallocscratch buffer for durations whereDays == 0 && Hours == 0andshowMilliseconds == false. - Kept the existing
StringBuilder-based formatting as the unchanged slow path for longer durations or when milliseconds are requested.
Show a summary per file
| File | Description |
|---|---|
| src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.cs | Adds a .NET 8+ allocation-reducing formatting fast path for the most common progress-duration rendering scenario. |
Copilot's findings
- Files reviewed: 1/1 changed files
- Comments generated: 0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| #if NET8_0_OR_GREATER | ||
| // Fast path for the common non-negative progress-frame case: duration < 1 hour with no milliseconds. | ||
| if (!showMilliseconds && duration.Value.Ticks >= 0 && duration.Value.Days == 0 && duration.Value.Hours == 0) | ||
| { | ||
| int seconds = duration.Value.Seconds; | ||
| int minutes = duration.Value.Minutes; | ||
|
|
||
| return (wrapInParentheses, minutes) switch | ||
| { | ||
| (true, 0) => $"({seconds}s)", | ||
| (true, _) => $"({minutes}m {seconds:D2}s)", | ||
| (false, 0) => $"{seconds}s", | ||
| _ => $"{minutes}m {seconds:D2}s", | ||
| }; | ||
| } | ||
| #endif |
There was a problem hiding this comment.
Good catch — fixed in 1b25ddc. Each fast-path arm is now wrapped with string.Create(CultureInfo.InvariantCulture, $"...") so the NET8 fast path matches the slow path's invariant numeric formatting in locales with non-ASCII digits.
| [DataRow(0, true, "(0s)")] | ||
| [DataRow(5, true, "(5s)")] | ||
| [DataRow(65, true, "(1m 05s)")] | ||
| [DataRow(3599, true, "(59m 59s)")] | ||
| [DataRow(65, false, "1m 05s")] |
There was a problem hiding this comment.
Good catch — fixed in 1b25ddc. Added DataRows for (false, 0) with pure-second durations (0s, 5s, 59s) and the parentheses-less 59m 59s boundary so the previously-uncovered arm now has direct coverage.
…e + expand DataRows Address review feedback on PR #8861: - Wrap each fast-path interpolated string with string.Create(CultureInfo.InvariantCulture, ...) so the NET8 fast path produces the same numeric output as the slow path (which already uses InvariantCulture for numeric parts via GetFormattedPart). Without this, locales with non-ASCII digits (e.g., ar-SA, fa-IR) would see different output between the fast and slow paths. - Cover the previously-untested (wrapInParentheses: false, minutes == 0) and pure-second arms with DataRows: 0s, 5s, 59s, and the parentheses-less 59m 59s edge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🤖 This is an automated contribution from Perf Improver.
Goal and Rationale
HumanReadableDurationFormatter.Renderis called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more insideAppendTestWorkerProgress/AppendTestWorkerDetailwhen the line needs a full re-render. For a 4-assembly run refreshing at ~5 fps, this adds up to ~40+ calls per second over the lifetime of the test run.Each call currently allocates:
new StringBuilder()GetFormattedPart(e.g."5s","59s"," 05s")stringBuilder.ToString()resultThat is 3–4 heap allocations per call — all for a tiny string like
"(5s)"or"(2m 30s)".Approach
On .NET 8+, use
string.Create(IFormatProvider, Span<char>, ref DefaultInterpolatedStringHandler)with astackallocbuffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — noStringBuilder, no intermediateGetFormattedPartstrings.The fast path activates when:
Days == 0 && Hours == 0(covers virtually all test runs)showMilliseconds == false(the default for all progress-frame callers)Both conditions are true for every caller in
AnsiTerminalTestProgressFrameandSimpleTerminalBase.The slow path (days, hours, or
showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.Performance Evidence
Rendercall (typical: < 1 min)Rendercall (> 1 hour)string.Createheap allocationsMethodology: code inspection + allocation analysis.
HumanReadableDurationFormatter.Renderis called ~5× per render frame; at 5 fps over a 5-minute run that is ~7 500 calls, saving ~15 000–22 500 small string allocations.The change is
#if NET8_0_OR_GREATER-guarded, so netstandard2.0 behaviour is completely unchanged.Trade-offs
wrapInParentheses ? (minutes == 0 ? ... : ...) : (minutes == 0 ? ... : ...)) is slightly dense but self-contained. The logic is simple and the four resulting strings are easy to validate visually.showMilliseconds=true, durations with hours, or durations with days.netstandard2.0uses the existing slow path as before.Test Status
Microsoft.Testing.Platform.UnitTests(net8.0): 1086 passed, 0 failed, 3 skipped ✅Reproducibility
Add this agentic workflows to your repo
To install this agentic workflow, run