Skip to content

[Perf Improver] perf: eliminate LINQ allocations in IsIgnored hot path #8075

@Evangelink

Description

@Evangelink

🤖 This PR was created by Perf Improver, an automated AI assistant focused on performance improvements.


Goal and Rationale

Reduce heap allocations in AttributeExtensions.IsIgnored, called twice per test execution — once for the test class and once for the test method. Allocations here scale linearly with test count.

Approach

Before:

IEnumerable<ConditionBaseAttribute> attributes = ReflectHelper.Instance.GetAttributes<ConditionBaseAttribute>(type);
IEnumerable<IGrouping<string, ConditionBaseAttribute>> groups = attributes.GroupBy(attr => attr.GroupName);

Every call allocates:

  1. A yield iterator state machine from GetAttributes<T>() (even when no ConditionBaseAttribute is present)
  2. A LINQ GroupBy operator object

After:

Direct iteration of GetCustomAttributesCached()'s Attribute[] result. Grouping state is tracked via two lazily-allocated dictionaries (unsatisfiedGroups, satisfiedGroups) that are only created when a ConditionBaseAttribute is found — the uncommon case for most tests.

This follows the established allocation-free pattern used by GetTestPropertiesAsTraits and GetTestCategories in the same file.

Performance Evidence

Scenario Before (objects) After
No ConditionBaseAttribute (common) 1 yield iterator + 1 GroupBy 0
[Ignore] present on class/method 1 yield iterator + LINQ Lookup internals 1 Dictionary + 1 HashSet
Per test execution (class + method) ~4 iterator/LINQ objects 0 (common case)

For 1,000 tests: eliminates ~4,000 short-lived LINQ objects per run.

Trade-offs

  • Manual grouping is slightly more verbose, but semantics are identical: OR within group, AND across groups, first non-null ignore message reported.
  • The dictionaries allocated in the uncommon case ([Ignore] present) replace LINQ's Lookup<> internals — comparable allocation profile.

Reproducibility

export PATH="$PWD/.dotnet:$PATH"
dotnet restore src/Adapter/MSTestAdapter.PlatformServices/MSTestAdapter.PlatformServices.csproj -p:TargetFramework=net8.0
dotnet build src/Adapter/MSTestAdapter.PlatformServices/MSTestAdapter.PlatformServices.csproj -f net8.0 -warnaserror

Test Status

MSTestAdapter.PlatformServices builds with 0 warnings, 0 errors on net8.0.

Note: MSTestAdapter.PlatformServices.UnitTests has a pre-existing build failure in this environment due to AwesomeAssertions 9.3.0 only shipping netstandard2.1 (no net8.0 folder). This is unrelated to these changes. CI will run the full test suite.

Closes #7992

Generated by Daily Perf Improver · ● 2.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 25602028457 -n agent -D /tmp/agent-25602028457

# Create a new branch
git checkout -b perf-assist/isignored-linq-elimination-v9-3a48b53cb26fe8b1

# Apply the patch (--3way handles cross-repo patches where files may already exist)
git am --3way /tmp/agent-25602028457/aw-perf-assist-isignored-linq-elimination-v9.patch

# Push the branch to origin
git push origin perf-assist/isignored-linq-elimination-v9-3a48b53cb26fe8b1

# Create the pull request
gh pr create --title '[Perf Improver] perf: eliminate LINQ allocations in IsIgnored hot path' --base main --head perf-assist/isignored-linq-elimination-v9-3a48b53cb26fe8b1 --repo microsoft/testfx
Show patch preview (97 of 97 lines)
From 45e06d1ffaa68c204fb115a71fab908967bdeab5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Sat, 9 May 2026 13:25:00 +0000
Subject: [PATCH] perf: eliminate LINQ allocations in IsIgnored hot path

Replace GetAttributes<T>() yield iterator + LINQ GroupBy with direct
iteration of GetCustomAttributesCached()'s Attribute[] result. Grouping
is tracked via lazily-allocated dictionaries (only when a
ConditionBaseAttribute is present, which is the uncommon case for most
tests).

Common case (no [Ignore] / no OSCondition): 0 allocations vs 2 before.
Per-test savings (class + method call): ~4 short-lived objects eliminated.
For 1,000 tests: ~4,000 LINQ objects eliminated per run.

Closes #7992

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Helpers/AttributeHelpers.cs               | 55 +++++++++++++------
 1 file changed, 39 insertions(+), 16 deletions(-)

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
index a2bbcbe..56ba75b 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
@@ -9,27 +9,50 @@ internal static class AttributeExtensions
 {
     public static bool IsIgnored(this ICustomAttributeProvider type, out string? ignoreMessage)
     {
-        IEnumerable<ConditionBaseAttribute> attributes = ReflectHelper.Instance.GetAttributes<ConditionBaseAttribute>(type);
-        IEnumerable<IGrouping<string, ConditionBaseAttribute>> groups = attributes.GroupBy(attr => attr.GroupName);
-        foreach (IGrouping<string, ConditionBaseAttribute>? group in groups)
+        // Use the cached Attribute[] directly to avoid the yield iterator allocation from GetAttributes<T>()
+        // and the LINQ GroupBy operator allocation. Grouping is tracked lazily via dictionaries that are
+  
... (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