Skip to content

[perf-improver] perf: avoid List(TestResult) allocation in RunTestMethodAsync non-data-driven fast path#9507

Merged
Evangelink merged 2 commits into
mainfrom
perf-assist/skip-list-alloc-non-data-driven-8e762aaad2e45aec
Jun 29, 2026
Merged

[perf-improver] perf: avoid List(TestResult) allocation in RunTestMethodAsync non-data-driven fast path#9507
Evangelink merged 2 commits into
mainfrom
perf-assist/skip-list-alloc-non-data-driven-8e762aaad2e45aec

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Goal and Rationale

For the vast majority of test executions — non-data-driven tests — RunTestMethodAsync was paying three unnecessary costs on every call:

  1. Two attribute scans: TryExecuteDataSourceBasedTestsAsync scans all cached method attributes for DataSourceAttribute; if that returns false, TryExecuteFoldedDataDrivenTestsAsync scans them again for ITestDataSource implementors.
  2. List<TestResult> allocation: a List<TestResult> is created even though only ExecuteTestAsync will be called (which already returns TestResult[]).
  3. Spread allocation: return [.. results] converts the list back to an array, allocating a second short-lived TestResult[].

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 over GetCustomAttributesCached that checks attribute is DataSourceAttribute or UTF.ITestDataSource — combining both scans into one.

Fast path (new): if _test.DataType != DynamicDataType.ITestDataSource and IsDataDrivenTest() returns false, the method:

  • Calls ExecuteTestAsync directly
  • Returns its TestResult[] without allocating a List<TestResult>
  • Performs one attribute-cache scan instead of two

Slow path (unchanged): all data-driven tests continue through the existing List<TestResult> path.

GetAggregateOutcome signature: widened from List<TestResult> to IReadOnlyList<TestResult> so both the fast-path TestResult[] and slow-path List<TestResult> can call it (arrays and lists both implement IReadOnlyList<T>).

Performance Evidence

Per non-data-driven test, the change eliminates approximately:

  • 1 List<TestResult> object (~56 bytes: 24-byte header + internal capacity-4 backing array)
  • 1 TestResult[] spread allocation (~24 bytes, length 1)
  • 1 redundant attribute-cache enumeration (second 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

  • Very slight code duplication: the "set display name → execute → set outcome" block appears in both the fast path and the slow path's else branch. The else branch is an edge case (test has DataSourceAttribute but multiple such attributes cause TryExecuteDataSourceBasedTestsAsync to skip it), so it is never exercised on common paths.
  • No behavioral changes: the fast path is only reached when both DataType != ITestDataSource and IsDataDrivenTest() is false, ensuring data-driven tests are unaffected.

Test Status

Delegated to CI. Existing unit tests in TestMethodRunnerTests.cs exercise 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 -test

🤖 Automated content 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 Perf Improver workflow. · 1.7K AIC · ⌖ 26.2 AIC · ⊞ 57.7K · [◷]( · )

Add this agentic workflows to your repo

To install this agentic workflow, run

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

…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>
Copilot AI review requested due to automatic review settings June 29, 2026 15:07
@Evangelink Evangelink added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 29, 2026

Copilot AI 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 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 detect DataSourceAttribute / ITestDataSource in a single cached-attribute pass.
  • Introduced a non-data-driven fast path that calls ExecuteTestAsync directly and returns its TestResult[] without allocating/spreading a List<TestResult>.
  • Widened GetAggregateOutcome from List<TestResult> to IReadOnlyList<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

Comment thread src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs Dismissed
Comment thread src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs Dismissed
Comment thread src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs Dismissed
@Evangelink Evangelink marked this pull request as ready for review June 29, 2026 17:13
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@Evangelink Evangelink left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 ⚠️ SEE INLINE 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 ⚠️ Same as #1 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.

Comment thread src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs Outdated
@Evangelink Evangelink enabled auto-merge (squash) June 29, 2026 17:50
@Evangelink Evangelink added the state/needs-review Awaiting review from the team. label Jun 29, 2026
@Evangelink Evangelink merged commit 9cbe2da into main Jun 29, 2026
67 checks passed
@Evangelink Evangelink deleted the perf-assist/skip-list-alloc-non-data-driven-8e762aaad2e45aec branch June 29, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performance Runtime / build performance / efficiency. state/needs-review Awaiting review from the team. type/automation Created or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants