[perf-improver] perf: eliminate LINQ allocations in terminal progress render hot path#8799
Conversation
- GetRunningTasks: replace Select+OrderByDescending+ToList LINQ chain with direct List<T> build + List.Sort using a static comparer, and change return type from IEnumerable<TestDetailState> to List<TestDetailState> to avoid iterator state-machine allocation from 'yield return'. Also replace 'sortedDetails = [.. sortedDetails.Take(n)]' with in-place List.RemoveRange to skip the second list/array allocation. - GenerateLinesToRender: replace 'progress.OfType<TestProgressState>()' spread with a null-filtering manual loop (avoids LINQ OfTypeIterator + collection-spread array copy). Replace 'Enumerable.Range(0,n).OrderBy(...)' with a pre-filled int[] sorted via Array.Sort (avoids RangeIterator and OrderedEnumerable allocations). Both sites are in the 500 ms live-progress render loop, so the allocations are pressure that appears on every frame during a test run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Build Failure AnalysisSummary — The build fails with 3 identical errors (one per target framework) because Root cause: Localization XLF out-of-sync with RESXThe The same error fires for all three TFMs ( Affected project / errors
Proposed fix Regenerate the XLF files locally and commit the result. From the repository root: dotnet msbuild src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj /t:UpdateXlfThen commit the updated
Build overview
All MSBuild errors (3)
🤖 Generated by the Build Failure Analysis workflow · commit a435e21
|
🤖 This is an automated contribution from Perf Improver.
Goal and Rationale
The live-progress render loop runs every 500 ms while tests are executing. Two methods in this path were creating unnecessary LINQ iterator chains and iterator state machines per frame:
TestNodeResultsState.GetRunningTasks— built aSelect + OrderByDescending + ToListLINQ pipeline to sort running test tasks, then usedyield returnto expose them. Each call produced: aDictionaryEnumerator, aSelectIterator<>, anOrderedEnumerable<,>, aList<TestDetailState>, a second array from[.. Take(n)](when too many items), and anIEnumeratorstate machine. Called once per assembly per frame.AnsiTerminalTestProgressFrame.GenerateLinesToRender— usedprogress.OfType<TestProgressState>()spread (OfTypeIterator+ array copy) to filter nulls, andEnumerable.Range(0, n).OrderBy(...)(RangeIterator+OrderedEnumerable<,>) to sort indices by detail count. Called once per frame.Approach
GetRunningTasksIEnumerable<TestDetailState>toList<TestDetailState>— caller gets the list directly, no iterator state machine needed.foreachover theConcurrentDictionaryinstead ofSelect(d => d.Value).List.Sortand astaticlambda (no closure allocation) instead of LINQOrderByDescending.List.RemoveRangeinstead of[.. Take(n)](avoids second array allocation).yield return— items returned from the list directly.GenerateLinesToRender[.. progress.OfType<TestProgressState>()]with two null-counting passes over the array — avoidsOfTypeIteratorand collection-spread copy.Enumerable.Range(0, n).OrderBy(i => ...)with a pre-filledint[]sorted viaArray.Sort— avoidsRangeIteratorandOrderedEnumerableallocations.detailItemsarray type fromIEnumerable<TestDetailState>[]toList<TestDetailState>[]to match the new return type.Performance Evidence
GetRunningTasks(per assembly)DictionaryEnumerator+SelectIterator+OrderedEnumerable+List+ array (Take) + state machine = ~5–6 objectsListonly = 1 objectGenerateLinesToRender— null filterOfTypeIterator+ array spread = 2 objectsGenerateLinesToRender— index sortRangeIterator+OrderedEnumerable= 2 objectsint[]+ closure = 2 objects (same count but avoids LINQ machinery overhead)For a test run with 10 assemblies, this eliminates roughly 4–5 allocations per assembly per frame = ~40–50 fewer heap allocations per 500 ms tick in the terminal render loop.
Methodology: code inspection + diff review. The render loop is easily verified by running tests and observing a steady terminal.
Trade-offs
GenerateLinesToRender(two loops to count + fill the array vs. a one-liner spread).GetRunningTasksreturn type change is internal — no public API impact.Array.Sortcomparison lambda still capturesprogressItems(a local), but this is one allocation per frame total rather than per-item LINQ machinery.Test Status
Microsoft.Testing.Platform.UnitTests(net8.0): 1003 passed, 0 failed, 3 skipped ✅Reproducibility
Add this agentic workflows to your repo
To install this agentic workflow, run