[perf-improver] perf: avoid List(TestResult) allocation in RunTestMethodAsync non-data-driven fast path#9507
Conversation
…a-driven fast path For non-data-driven tests (the common case), RunTestMethodAsync previously: - Allocated a List<TestResult> upfront - Scanned all cached method attributes twice (TryExecuteDataSourceBasedTestsAsync then TryExecuteFoldedDataDrivenTestsAsync) before reaching the execute call - Spread the list back into a TestResult[] via [.. results] The new fast path: - Performs a single combined attribute scan (IsDataDrivenTest) - Skips List allocation entirely, returning the TestResult[] from ExecuteTestAsync directly - Saves ~3 heap allocations per non-data-driven test execution The existing slow path (with List<TestResult>) is preserved for data-driven tests. GetAggregateOutcome parameter is widened to IReadOnlyList<TestResult> so both paths can call it (arrays and lists both implement IReadOnlyList<T>). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes MSTest adapter execution for the common non-data-driven case by avoiding redundant attribute scans and short-lived allocations in TestMethodRunner.RunTestMethodAsync.
Changes:
- Added a private
IsDataDrivenTest()helper to detectDataSourceAttribute/ITestDataSourcein a single cached-attribute pass. - Introduced a non-data-driven fast path that calls
ExecuteTestAsyncdirectly and returns itsTestResult[]without allocating/spreading aList<TestResult>. - Widened
GetAggregateOutcomefromList<TestResult>toIReadOnlyList<TestResult>to support both arrays and lists.
Show a summary per file
| File | Description |
|---|---|
| src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs | Adds a non-data-driven fast path and consolidates attribute scanning to reduce allocations and iterations. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Low
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink
left a comment
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
Review Summary
Reviewed src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs against all 22 applicable dimensions. The optimization is well-reasoned and algorithmically sound for the common case, but there is one correctness concern worth addressing before merge.
Verdict Table
| # | Dimension | Status | Notes |
|---|---|---|---|
| 1 | Algorithmic Correctness | Fast path bypasses results.Count == 0 guard (detail below) |
|
| 2 | Threading & Concurrency | ✅ LGTM | No new shared-state access; all reads are already synchronized by callers |
| 3 | Security & IPC Contract Safety | ✅ LGTM | No security-relevant code touched |
| 4 | Public API & Binary Compatibility | ✅ LGTM | All changes are private; IReadOnlyList<T> widening is private-only |
| 5 | Performance & Allocations | ✅ LGTM | Correctly eliminates List<TestResult> + spread allocation on the hot path |
| 6 | Cross-TFM Compatibility | ✅ LGTM | No TFM-conditional APIs introduced |
| 7 | Resource & IDisposable Management | ✅ LGTM | No disposables affected |
| 8 | Defensive Coding at Boundaries | See inline comment on line 134 | |
| 9 | Localization & Resources | ✅ LGTM | No new user-facing strings added |
| 10 | Test Isolation | ✅ LGTM | No static mutation introduced |
| 11 | Assertion Quality | ✅ LGTM | Tests use AwesomeAssertions (project convention) |
| 12 | Flakiness Patterns | ✅ LGTM | No timing/ordering sensitivity introduced |
| 13 | Test Completeness & Coverage | ✅ LGTM | Existing non-data-driven tests exercise the fast path; data-driven tests cover the slow path |
| 14 | Data-Driven Test Coverage | N/A | No new data-driven tests |
| 15 | Code Structure & Simplification | ✅ LGTM | Duplication acknowledged in PR; else branch is an edge case only |
| 16 | Naming & Conventions | ✅ LGTM | IsDataDrivenTest() name is clear and consistent with codebase style |
| 17 | Documentation Accuracy | ✅ LGTM | XML doc and inline comments are accurate |
| 18 | Analyzer & Code Fix Quality | N/A | No src/Analyzers/ changes |
| 19 | IPC Wire Compatibility | N/A | No serialization code changed |
| 20 | Build Infrastructure & Dependencies | ✅ LGTM | No build changes |
| 21 | Scope & PR Discipline | ✅ LGTM | Focused, single-file, single-concern change |
| 22 | PowerShell Scripting Hygiene | N/A | No .ps1 files changed |
The One Correctness Issue (inline comment, line 134)
DynamicDataType has only two values (None, ITestDataSource), and IsDataDrivenTest() correctly mirrors the exact attribute checks performed by TryExecuteDataSourceBasedTestsAsync (looks for DataSourceAttribute) and TryExecuteFoldedDataDrivenTestsAsync (looks for UTF.ITestDataSource). The fast-path gate is logically equivalent to the old else branch and the algorithm is correct.
The single gap: the slow path has a defensive guard at lines 190-199 that ensures RunTestMethodAsync never returns [] — because ExecuteAsync's finally block unconditionally dereferences result![0], an empty return would throw IndexOutOfRangeException rather than surfacing a clean error. The fast path omits this guard.
For all practical purposes (standard TestMethodAttribute always returns ≥ 1 result) this is benign. But a custom executor returning TestResult[0]{} would now crash on the fast path where it previously received a graceful error result. The inline comment explains the fix options.
No other issues found across the remaining 20 applicable dimensions.
Goal and Rationale
For the vast majority of test executions — non-data-driven tests —
RunTestMethodAsyncwas paying three unnecessary costs on every call:TryExecuteDataSourceBasedTestsAsyncscans all cached method attributes forDataSourceAttribute; if that returnsfalse,TryExecuteFoldedDataDrivenTestsAsyncscans them again forITestDataSourceimplementors.List<TestResult>allocation: aList<TestResult>is created even though onlyExecuteTestAsyncwill be called (which already returnsTestResult[]).return [.. results]converts the list back to an array, allocating a second short-livedTestResult[].In a typical suite with 1,000 non-data-driven tests, this adds ~80 KB of short-lived allocations and ~2,000 extra attribute-scan iterations per run.
Approach
New
IsDataDrivenTest()private method: one pass overGetCustomAttributesCachedthat checksattribute is DataSourceAttribute or UTF.ITestDataSource— combining both scans into one.Fast path (new): if
_test.DataType != DynamicDataType.ITestDataSourceandIsDataDrivenTest()returnsfalse, the method:ExecuteTestAsyncdirectlyTestResult[]without allocating aList<TestResult>Slow path (unchanged): all data-driven tests continue through the existing
List<TestResult>path.GetAggregateOutcomesignature: widened fromList<TestResult>toIReadOnlyList<TestResult>so both the fast-pathTestResult[]and slow-pathList<TestResult>can call it (arrays and lists both implementIReadOnlyList<T>).Performance Evidence
Per non-data-driven test, the change eliminates approximately:
List<TestResult>object (~56 bytes: 24-byte header + internal capacity-4 backing array)TestResult[]spread allocation (~24 bytes, length 1)TryExecute...scan avoided)For a 1,000-test non-data-driven suite: ~80 KB fewer short-lived allocations and ~2,000 fewer attribute-scan iterations per run.
No local SDK is available in this CI agent; build/test verification is delegated to CI.
Trade-offs
elsebranch. Theelsebranch is an edge case (test hasDataSourceAttributebut multiple such attributes causeTryExecuteDataSourceBasedTestsAsyncto skip it), so it is never exercised on common paths.DataType != ITestDataSourceandIsDataDrivenTest()isfalse, ensuring data-driven tests are unaffected.Test Status
Delegated to CI. Existing unit tests in
TestMethodRunnerTests.csexercise both the new fast path (non-data-driven) and the unchanged slow path (data-driven) code paths.Reproducibility
# Build and run adapter unit tests ./build.sh -testAdd this agentic workflows to your repo
To install this agentic workflow, run