From a1d79dff2e9520b70442ecedd1b97ae50b9b6858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 16:57:31 +0200 Subject: [PATCH 1/2] perf: eliminate per-tick allocations in GenerateLinesToRender via cached buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache the four working buffers (List, TestProgressState[], int[], List?[]) and the sort comparer as instance fields on AnsiTerminalTestProgressFrame so they are allocated once per frame object rather than on every render tick. - _linesToRenderBuffer: reused List (was: new list each tick) - _progressItemsBuffer: grown-only array (was: new array each tick) - _sortedIndicesBuffer: grown-only array (was: new array each tick) - _detailItemsBuffer: grown-only array (was: new array each tick) - _progressCountComparer: cached IComparer instance used with Array.Sort(array, offset, count, comparer) so no closure is captured (was: Array.Sort(array, Comparison lambda) → 1 closure/tick) At ~2 fps with N assemblies this removes ~5N allocations per second. For a typical run with 4 assemblies over 5 minutes, this is roughly ~12 000 allocations eliminated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Terminal/AnsiTerminalTestProgressFrame.cs | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs index 32a5fe33b2..6b8c7e73a7 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs @@ -30,6 +30,14 @@ private static string[] CreateMoveCursorBackwardCache() return cache; } + // Reusable working buffers for GenerateLinesToRender, cached across render ticks on the same frame + // object to eliminate 4+ per-tick heap allocations (3 arrays + 1 List, plus the sort comparer). + private readonly List _linesToRenderBuffer = []; + private readonly ProgressCountComparer _progressCountComparer = new(); + private TestProgressState[] _progressItemsBuffer = []; + private int[] _sortedIndicesBuffer = []; + private List?[] _detailItemsBuffer = []; + public int Width { get; private set; } public int Height { get; private set; } @@ -50,6 +58,7 @@ internal void Reset(int width, int height) Width = Math.Min(width, MaxColumn); Height = height; RenderedLines?.Clear(); + _linesToRenderBuffer.Clear(); } public void AppendTestWorkerProgress(TestProgressState progress, RenderedProgressItem currentLine, AnsiTerminal terminal) @@ -360,8 +369,6 @@ public void Render(AnsiTerminalTestProgressFrame previousFrame, TestProgressStat private List GenerateLinesToRender(TestProgressState?[] progress) { - var linesToRender = new List(progress.Length); - // Note: We want to render the list of active tests, but this can easily fill up the full screen. // As such, we should balance the number of active tests shown per project. // We do this by distributing the remaining lines for each projects. @@ -376,47 +383,71 @@ private List GenerateLinesToRender(TestProgressState?[] progress) } } - var progressItems = new TestProgressState[itemCount]; + // Grow cached working buffers when more capacity is needed; never shrink to avoid churn. + if (_progressItemsBuffer.Length < itemCount) + { + _progressItemsBuffer = new TestProgressState[itemCount]; + _sortedIndicesBuffer = new int[itemCount]; + _detailItemsBuffer = new List?[itemCount]; + } + int idx = 0; for (int j = 0; j < progress.Length; j++) { if (progress[j] is not null) { - progressItems[idx++] = progress[j]!; + _progressItemsBuffer[idx++] = progress[j]!; } } - int linesToDistribute = (int)(Height * 0.7) - 1 - progressItems.Length; - var detailItems = new List[progressItems.Length]; + int linesToDistribute = (int)(Height * 0.7) - 1 - itemCount; // Sort indices by detail count ascending to distribute lines fairly, // without LINQ Enumerable.Range + OrderBy allocation. - int[] sortedItemsIndices = new int[progressItems.Length]; - for (int j = 0; j < progressItems.Length; j++) + for (int j = 0; j < itemCount; j++) { - sortedItemsIndices[j] = j; + _sortedIndicesBuffer[j] = j; } - Array.Sort(sortedItemsIndices, (a, b) => (progressItems[a].TestNodeResultsState?.Count ?? 0).CompareTo(progressItems[b].TestNodeResultsState?.Count ?? 0)); + // _progressCountComparer is a cached instance — no per-tick allocation. + _progressCountComparer.Buffer = _progressItemsBuffer; + Array.Sort(_sortedIndicesBuffer, 0, itemCount, _progressCountComparer); - foreach (int sortedItemIndex in sortedItemsIndices) + int linesPerItem = itemCount > 0 ? linesToDistribute / itemCount : 0; + for (int j = 0; j < itemCount; j++) { - detailItems[sortedItemIndex] = progressItems[sortedItemIndex].TestNodeResultsState?.GetRunningTasks( - linesToDistribute / progressItems.Length) - ?? []; + int sortedItemIndex = _sortedIndicesBuffer[j]; + _detailItemsBuffer[sortedItemIndex] = _progressItemsBuffer[sortedItemIndex].TestNodeResultsState?.GetRunningTasks(linesPerItem) ?? []; } - for (int progressI = 0; progressI < progressItems.Length; progressI++) + for (int progressI = 0; progressI < itemCount; progressI++) { - linesToRender.Add(progressItems[progressI]); - linesToRender.AddRange(detailItems[progressI]); + _linesToRenderBuffer.Add(_progressItemsBuffer[progressI]); + if (_detailItemsBuffer[progressI] is { } details) + { + _linesToRenderBuffer.AddRange(details); + _detailItemsBuffer[progressI] = null; // release to avoid holding stale GC roots + } } - return linesToRender; + return _linesToRenderBuffer; } public void Clear() => RenderedLines?.Clear(); + /// + /// Reusable comparer for sorting progress-item indices by running-task count. + /// Cached as a field to avoid a new allocations on every render tick. + /// + private sealed class ProgressCountComparer : IComparer + { + internal TestProgressState[] Buffer { get; set; } = []; + + public int Compare(int a, int b) + => (Buffer[a].TestNodeResultsState?.Count ?? 0) + .CompareTo(Buffer[b].TestNodeResultsState?.Count ?? 0); + } + internal sealed class RenderedProgressItem { public RenderedProgressItem(long id, long version) From 0a8e528b9b21c110477619dbe84b1c6b6d96f474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:20:12 +0200 Subject: [PATCH 2/2] Address PR review feedback on AnsiTerminalTestProgressFrame caching - Guard against linesPerItem <= 0 to avoid GetRunningTasks(0) triggering RemoveRange(-1, ...). - Null out _progressItemsBuffer slots after use to release TestProgressState GC roots, consistent with the existing _detailItemsBuffer null-out. - Fix grammar in ProgressCountComparer doc comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Terminal/AnsiTerminalTestProgressFrame.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs index 6b8c7e73a7..6992cd2745 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs @@ -413,11 +413,17 @@ private List GenerateLinesToRender(TestProgressState?[] progress) _progressCountComparer.Buffer = _progressItemsBuffer; Array.Sort(_sortedIndicesBuffer, 0, itemCount, _progressCountComparer); + // Only populate detail buffers when there is a positive line budget per item. + // GetRunningTasks(0) would compute itemsToTake = -1 and throw inside RemoveRange, + // and there's no point asking for details we cannot display anyway. int linesPerItem = itemCount > 0 ? linesToDistribute / itemCount : 0; - for (int j = 0; j < itemCount; j++) + if (linesPerItem > 0) { - int sortedItemIndex = _sortedIndicesBuffer[j]; - _detailItemsBuffer[sortedItemIndex] = _progressItemsBuffer[sortedItemIndex].TestNodeResultsState?.GetRunningTasks(linesPerItem) ?? []; + for (int j = 0; j < itemCount; j++) + { + int sortedItemIndex = _sortedIndicesBuffer[j]; + _detailItemsBuffer[sortedItemIndex] = _progressItemsBuffer[sortedItemIndex].TestNodeResultsState?.GetRunningTasks(linesPerItem); + } } for (int progressI = 0; progressI < itemCount; progressI++) @@ -428,6 +434,10 @@ private List GenerateLinesToRender(TestProgressState?[] progress) _linesToRenderBuffer.AddRange(details); _detailItemsBuffer[progressI] = null; // release to avoid holding stale GC roots } + + // Release the progress item reference too so completed workers can be collected + // even when this frame instance is kept alive across ticks. + _progressItemsBuffer[progressI] = null!; } return _linesToRenderBuffer; @@ -437,7 +447,7 @@ private List GenerateLinesToRender(TestProgressState?[] progress) /// /// Reusable comparer for sorting progress-item indices by running-task count. - /// Cached as a field to avoid a new allocations on every render tick. + /// Cached as a field to avoid new allocations on every render tick. /// private sealed class ProgressCountComparer : IComparer {