Skip to content

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

@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 Dictionary (lazy allocation)
Dictionary<string, (bool Satisfied, string? FirstMessage)> 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
Closes #8000

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

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

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

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

# 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-e810d3e2bb11db59 --repo microsoft/testfx
Show patch preview (111 of 111 lines)
From db05ff02f73ae619d5496c3b1eaac06208622d4f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Mon, 4 May 2026 13:24:52 +0000
Subject: [PATCH] perf: eliminate LINQ allocations in IsIgnored hot path

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

IsIgnored() is called twice per test execution (class + method), so
allocations here scale linearly with test count. The fast path (no
ConditionBaseAttribute present) exits with zero allocations. The uncommon
path (ConditionBaseAttribute present) uses a Dictionary instead of a
LINQ Lookup, which is comparable or better allocation-wise.

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

Follows the allocation-free pattern from GetTestPropertiesAsTraits and
GetTestCategories in the same codebase.

Closes #7992
Closes #7993
Closes #8000

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

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
index a2bbcbe..86ee10a 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
@@ -9,27 +9,59 @@ 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 g
... (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