Skip to content

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

@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, which is called twice per test execution β€” once for the test class (ClassType.IsIgnored) and once for the test method (MethodInfo.IsIgnored). 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);
foreach (IGrouping<string, ConditionBaseAttribute>? group in groups)
{ ... }

Every call allocates:

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

After:

Attribute[] attributes = ReflectHelper.Instance.GetCustomAttributesCached(type);

// Fast path: no ConditionBaseAttribute (common case) β†’ zero allocations
bool hasConditionAttr = false;
foreach (Attribute attr in attributes)
{
    if (attr is ConditionBaseAttribute) { hasConditionAttr = true; break; }
}

if (!hasConditionAttr) { ignoreMessage = null; return false; }

// Uncommon path: manual grouping with lazy Dictionary allocation
Dictionary<string, (bool Satisfied, string? FirstIgnoreMessage)> groups = [];
// ...

Direct iteration over the cached Attribute[] β€” no iterator, no LINQ operator. The Dictionary is allocated only when a ConditionBaseAttribute is present (the uncommon case).

Performance Evidence

Methodology: Object allocation count per IsIgnored call.

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

For a test suite with 1,000 tests: eliminates ~4,000 short-lived LINQ objects per run in the typical case.

Trade-offs

  • Manual grouping logic is slightly more verbose, but follows the established allocation-free pattern used by GetTestPropertiesAsTraits and GetTestCategories in the same codebase.
  • The Dictionary allocated in the uncommon case replaces a LINQ Lookup<> β€” comparable or better allocation profile.
  • Semantics are identical: OR within group, AND across groups, first non-null ignore message reported.

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
Closes #7993

Generated by Daily Perf Improver Β· ● 1.8M Β· β—·


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 25280157015 -n agent -D /tmp/agent-25280157015

# Create a new branch
git checkout -b perf-assist/eliminate-linq-allocations-isignored-aa25bab8269044b6

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

# Push the branch to origin
git push origin perf-assist/eliminate-linq-allocations-isignored-aa25bab8269044b6

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

Replace GetAttributes<ConditionBaseAttribute> (yield iterator) + GroupBy
(LINQ operator) with direct iteration of GetCustomAttributesCached().

IsIgnored() is called twice per test execution - once for the test class,
once for the test method. In the common case (no ConditionBaseAttribute),
the fast path exits with zero allocations. In the uncommon case (ConditionBaseAttribute
present), a Dictionary is allocated instead of a LINQ Lookup, which is
comparable or better.

Preserves semantics: OR within group, AND across groups, first ignore message
per unsatisfied group.

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

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
index a2bbcbe..59907e8 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
@@ -9,27 +9,57 @@ 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 GetCustomAttributesCached to avoid allocating a yield iterator and LINQ GroupBy operator.
+        // IsIgnored is called twic
... (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