[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations - #8861

Merged
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84
Jun 7, 2026
Merged

[perf-improver] perf: fast path in HumanReadableDurationFormatter.Render for sub-hour durations#8861
Amaury Levé (Evangelink) merged 6 commits into
mainfrom
perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84

Conversation

@Evangelink

Copy link
Copy Markdown
Member

🤖 This is an automated contribution from Perf Improver.

Goal and Rationale

HumanReadableDurationFormatter.Render is called multiple times per terminal progress-frame render tick — once per visible test-worker line for the "duration unchanged" fast-check, and once more inside AppendTestWorkerProgress/AppendTestWorkerDetail when 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:

  1. A new StringBuilder()
  2. One or two intermediate strings via GetFormattedPart (e.g. "5s", "59s", " 05s")
  3. The final stringBuilder.ToString() result

That 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 a stackalloc buffer. This overload uses the span as a scratch buffer and produces the final heap string in a single allocation — no StringBuilder, no intermediate GetFormattedPart strings.

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 AnsiTerminalTestProgressFrame and SimpleTerminalBase.

The slow path (days, hours, or showMilliseconds=true) is unchanged; it is rarely reached and is not in the render hot path.

Performance Evidence

ScenarioBeforeAfter
Allocations per Render call (typical: < 1 min)3–4 (StringBuilder + 1–2 GetFormattedPart strings + result)1 (result only)
Allocations per Render call (> 1 hour)3–43–4 (unchanged, slow path)
string.Create heap allocationsn/a1 (final string only; scratch buffer is on stack)

Methodology: code inspection + allocation analysis. HumanReadableDurationFormatter.Render is 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

  • The nested ternary (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.
  • No behaviour change for showMilliseconds=true, durations with hours, or durations with days.
  • netstandard2.0 uses the existing slow path as before.

Test Status

  • Microsoft.Testing.Platform.UnitTests (net8.0): 1086 passed, 0 failed, 3 skipped
  • Build (all TFMs: net8.0, net9.0, netstandard2.0): 0 warnings, 0 errors

Reproducibility

./build.sh
artifacts/bin/Microsoft.Testing.Platform.UnitTests/Debug/net8.0/Microsoft.Testing.Platform.UnitTests

Generated by Perf Improver

Generated by Perf Improver · sonnet46 8.8M ·

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

… 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>
CopilotAI review requested due to automatic review settings June 5, 2026 14:40
@EvangelinkAmaury Levé (Evangelink) added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 5, 2026

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_GREATER fast path using string.Create with a stackalloc scratch buffer for durations where Days == 0 && Hours == 0 and showMilliseconds == 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
FileDescription
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.csAdds 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

@Evangelink
Amaury Levé (Evangelink) marked this pull request as ready for review June 5, 2026 15:01
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>
CopilotAI review requested due to automatic review settings June 7, 2026 08:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

…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>
@Evangelink
Amaury Levé (Evangelink) merged commit ac8c61a into mainJun 7, 2026
47 checks passed
@Evangelink
Amaury Levé (Evangelink) deleted the perf-assist/duration-formatter-fast-path-fb8645e5dcd1ce84 branch June 7, 2026 19:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Evangelink@Youssef1313@azat-msft