Skip to content

[Efficiency Improver] perf: avoid yield iterator allocations in test execution hot pathΒ #8080

@Evangelink

Description

@Evangelink

πŸ€– Daily Efficiency Improver β€” automated AI assistant reducing the energy footprint of this repository.

Goal and Rationale

Eliminate two unnecessary heap allocations that occur on every test execution in the test runner hot path:

  1. TestMethodInfo.GetRetryAttribute(): Called from the TestMethodInfo constructor (once per test). Was using GetAttributes<RetryBaseAttribute>(), a yield return iterator that allocates a compiler-generated state machine object even when no retry attribute is present (the common case).

  2. TestMethodRunner.TryExecuteDataSourceBasedTestsAsync(): Was allocating a DataSourceAttribute[] array (via GetAttributes<DataSourceAttribute>()) on every test execution before checking whether data-source tests exist β€” for the ~99% of tests that don't use [DataSource].

Focus Area

Code-Level Efficiency β€” eliminating per-test-execution heap allocations on the hot path.

Approach

Fix 1 β€” TestMethodInfo.GetRetryAttribute()

Before: GetAttributes<RetryBaseAttribute>() (allocates yield iterator state machine + enumerator)

IEnumerable<RetryBaseAttribute> attributes = ReflectHelper.Instance.GetAttributes<RetryBaseAttribute>(MethodInfo);
using IEnumerator<RetryBaseAttribute> enumerator = attributes.GetEnumerator();
if (!enumerator.MoveNext()) return null;
RetryBaseAttribute attribute = enumerator.Current;
if (enumerator.MoveNext()) ThrowMultipleAttributesException(...);
return attribute;

After: Direct iteration over the cached attribute array (zero allocations)

// PERF: Iterate the cached attribute array directly instead of allocating
// a yield iterator state machine via GetAttributes<RetryBaseAttribute>().
Attribute[] cachedAttributes = ReflectHelper.Instance.GetCustomAttributesCached(MethodInfo);
RetryBaseAttribute? found = null;
foreach (Attribute attribute in cachedAttributes)
{
    if (attribute is RetryBaseAttribute retry)
    {
        if (found is not null) ThrowMultipleAttributesException(nameof(RetryBaseAttribute));
        found = retry;
    }
}
return found;

Fix 2 β€” TestMethodRunner.TryExecuteDataSourceBasedTestsAsync()

Before: Allocates array + state machine, then checks length:

DataSourceAttribute[] dataSourceAttribute = _testMethodInfo.GetAttributes<DataSourceAttribute>();
if (dataSourceAttribute is { Length: 1 }) { ... }

After: IsAttributeDefined<DataSourceAttribute>() walks cached array with zero allocation:

// PERF: Use IsAttributeDefined instead of GetAttributes<T>() to avoid allocating
// a yield iterator state machine + array on every test execution for the common case.
if (!ReflectHelper.Instance.IsAttributeDefined<DataSourceAttribute>(_testMethodInfo.MethodInfo)) { return false; }

Energy Efficiency Evidence

Proxy metric: heap allocation count per test execution
(fewer allocations β†’ less GC pressure β†’ less CPU in collection β†’ less energy)

Site Before After
GetRetryAttribute per test 1 state machine (~48 B) 0
TryExecuteDataSourceBasedTestsAsync per non-DataSource test array + state machine (~96 B) 0
Combined savings at 10,000 tests ~1.4 MB GC roots eliminated β€”

The improvements apply to every test in the common case (no [Retry] / [DataSource] attribute).

Measurement approach: Verifiable with BenchmarkDotNet [MemoryDiagnoser] targeting TestMethodInfo construction and TryExecuteDataSourceBasedTestsAsync invocations.

Green Software Foundation context: Hardware Efficiency β€” less memory churn means DRAM and CPU do more useful work per joule. Energy Proportionality β€” GC cost scales linearly with allocation rate.

Trade-offs

  • GetRetryAttribute is slightly more verbose, but follows the same pattern as GetFirstAttributeOrDefault() / GetSingleAttributeOrDefault() already in ReflectHelper.
  • Semantics are fully preserved: multi-attribute detection still throws; IsAttributeDefined uses the same cached array as GetAttributes.

Reproducibility

export PATH="$PWD/.dotnet:$PATH"
dotnet test test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/ \
  -p:TargetFramework=net8.0 --no-restore \
  --filter "RetryAttribute|DataSource" \
  -p:SignAssembly=false

Test Status

⚠️ Local environment has only .NET 11 preview SDK (net8.0/net9.0 targeting packs not installed). CI is authoritative.

Generated by Daily Efficiency Improver Β· ● 5.1M Β· β—·


Note

This was originally intended as a pull request, but the git push operation failed.

Workflow Run: View run details and download patch artifact

The patch file is available in the agent artifact in the workflow run linked above.

To create a pull request with the changes:

# Download the artifact from the workflow run
gh run download 25619883483 -n agent -D /tmp/agent-25619883483

# Create a new branch
git checkout -b efficiency/avoid-yield-iterator-allocs-v4-9a3ee2364a017ed4

# Apply the patch (--3way handles cross-repo patches where files may already exist)
git am --3way /tmp/agent-25619883483/aw-efficiency-avoid-yield-iterator-allocs-v4.patch

# Push the branch to origin
git push origin efficiency/avoid-yield-iterator-allocs-v4-9a3ee2364a017ed4

# Create the pull request
gh pr create --title '[Efficiency Improver] perf: avoid yield iterator allocations in test execution hot path' --base main --head efficiency/avoid-yield-iterator-allocs-v4-9a3ee2364a017ed4 --repo microsoft/testfx
Show patch preview (100 of 100 lines)
From 6eb3aa80a8d1a4bdfcf3da193c38ffcd946d9bb2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 28 Apr 2026 13:44:11 +0000
Subject: [PATCH] perf: avoid yield iterator allocations in test execution hot
 path

In the test execution hot path, eliminate LINQ/iterator allocations
that happen for every test method invocation:

1. TestMethodRunner.TryExecuteDataSourceBasedTestsAsync: replace
   _testMethodInfo.GetAttributes<DataSourceAttribute>() (which allocates
   a yield iterator state machine + DataSourceAttribute[] array) with
   ReflectHelper.Instance.IsAttributeDefined<DataSourceAttribute>() which
   directly iterates the cached Attribute[] without any extra allocation.
   Since DataSourceAttribute has AllowMultiple=false, a Length:1 check is
   equivalent to an existence check. Saves 2 allocations per test execution
   for the common case (non-DataSource tests).

2. TestMethodInfo.GetRetryAttribute: replace GetAttributes<RetryBaseAttribute>
   (yield iterator) + IEnumerator<T> (enumerator) with direct iteration of
   GetCustomAttributesCached(). Eliminates the iterator state machine
   allocation per test execution. Preserves the duplicate-attribute check
   and custom exception.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Execution/TestMethodInfo.cs               | 25 +++++++++++--------
 .../Execution/TestMethodRunner.cs             | 11 ++++----
 2 files changed, 20 insertions(+), 16 deletions(-)

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.cs
index e45ed74e5..c3853520c 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.cs
@@ -289,21 +289,24 @@ private TestMethodAttribute GetTestMethodAttribute()
     /// </returns>
     private RetryBaseAttribu
... (truncated)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions