Skip to content

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

@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 AttributeHelpers.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);
// ...

Every call allocates:

  1. A yield iterator state machine from GetAttributes (even when no ConditionBaseAttribute is present)
  2. A LINQ GroupBy operator object (closure + Lookup internals)

After:

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

// Fast path: scan for any ConditionBaseAttribute β†’ zero allocations if none found
// Slow path: manual Dictionary-based grouping (one Dictionary, no LINQ)

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

Performance Evidence

Methodology: Allocation count per IsIgnored call.

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

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

Trade-offs

  • Manual grouping 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 [Ignore] case replaces a LINQ Lookup<> β€” comparable allocation profile.
  • Semantics are identical: OR within group, AND across groups, first non-satisfied 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 (AwesomeAssertions 9.3.0 only ships netstandard2.1, not net8.0). CI will run the full test suite.


Closes #7992, #7993, #8000, #8016, #8028

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

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

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

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

# 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-may6-7ff99f193b9ef432 --repo microsoft/testfx
Show patch preview (100 of 100 lines)
From 3c99f5af823cb20d8451db252811371f7e25b1e3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 13:29:34 +0000
Subject: [PATCH] perf: eliminate LINQ allocations in IsIgnored hot path

Replace GetAttributes<ConditionBaseAttribute> + GroupBy LINQ pipeline
with direct iteration of the cached Attribute[] array.

Fast path (no ConditionBaseAttribute): zero allocations per call.
Slow path (ConditionBaseAttribute present): one Dictionary instead of
yield iterator + LINQ Lookup internals.

IsIgnored is called twice per test execution (class + method), so for
a suite of 1,000 tests this eliminates ~2,000 short-lived objects in
the common case.

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

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
index a2bbcbe..e522ece 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Helpers/AttributeHelpers.cs
@@ -9,27 +9,56 @@ 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)
+        Attribute[] attributes = ReflectHelper.Instance.GetCustomAttributesCached(type);
+
+        // Fast path: no ConditionBaseAttribute means nothing is ignored (zero allocations).
+        bool hasConditionAttribute = false;
+        foreach (Attribute
... (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