Skip to content

[Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter hot pathsΒ #7974

@Evangelink

Description

@Evangelink

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

Goal and Rationale

TreeNodeFilter.MatchFilterPattern and MatchProperties are called once per test node per filter segment during test execution. Both methods previously used switch expressions with LINQ callbacks (.Any(lambda), .All(lambda), .Single()) that allocate heap objects on every invocation.

Focus area: Code-Level Efficiency β€” unnecessary heap allocation in hot evaluation path.

Approach

Convert switch expressions to switch statements with explicit foreach loops, eliminating all lambda closures and the AsEnumerable() wrapper.

Allocations eliminated per MatchFilterPattern call (OperatorExpression arms)

Arm Before After
Or subexprs.Any(expr => ...) β†’ 1 closure foreach β†’ 0 closures
And subexprs.All(expr => ...) β†’ 1 closure foreach β†’ 0 closures
Not subexprs.Single() β†’ enumerator foreach β†’ 0 extra

Allocations eliminated per MatchProperties call

Case Before After
PropertyExpression AsEnumerable() box + lambda closure (2 allocs) foreach on PropertyBag directly (0 closures, no wrapper)
Or subExprs.Any(expr => ...) β†’ 1 closure foreach β†’ 0 closures
And subExprs.All(expr => ...) β†’ 1 closure foreach β†’ 0 closures
Not subExprs.Single() + lambda β†’ 1+ allocs foreach β†’ 0 closures

Additionally, _filters.Last() (LINQ enumeration) was replaced with _filters[_filters.Count - 1] (O(1) index access).

Energy Efficiency Evidence

Proxy metric used: Heap allocation count. Rationale: each allocation increments GC pressure; fewer allocations means shorter/less-frequent GC pauses, which directly reduces CPU cycles consumed during test execution.

Baseline: For each test node matched against a complex filter (Or/And/Not nesting + property predicates), 3–5 lambda closure/wrapper objects were allocated per MatchFilterPattern+MatchProperties call pair.

After: 0 closures; only the enumerator (boxed IEnumerator<T>) remains where iterating IReadOnlyCollection<FilterExpression> (same as before).

For a 10,000-test suite with a compound filter (e.g. A|B with a property predicate), this eliminates tens of thousands of short-lived closure objects per run, reducing GC collection frequency.

Green Software Foundation Context

Energy Proportionality: GC overhead scales linearly with allocation rate. Eliminating closures in the per-node hot path ensures energy consumed by the runtime is proportional to actual test execution work, not bookkeeping overhead.

Trade-offs

  • Readability: Switch statements are slightly more verbose than switch expressions. The explicit foreach loops make the short-circuit semantics (early return on Or match, early return on And non-match) more visible and arguably clearer.
  • Correctness: The Not arm uses foreach with an unconditional return on the first element, followed by throw ApplicationStateGuard.Unreachable(). This is equivalent to .Single() when the invariant (exactly 1 sub-expression) holds, which is enforced during parsing.

Reproducibility

# Build
export PATH="$PWD/.dotnet:$PATH"
dotnet restore src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj -p:SignAssembly=false
dotnet build src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj -p:TargetFramework=net8.0 --no-restore -p:SignAssembly=false

Test Status

βœ… Build: Microsoft.Testing.Platform β€” 0 warnings, 0 errors on net8.0

⚠️ Unit test project has a pre-existing NuGet version mismatch in this agent environment (locally-built 2.3.0.0 vs cached 2.2.1.0). CI runs are authoritative for test results.

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


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

# Create a new branch
git checkout -b efficiency/treenodefilter-no-linq-closures-abd830069bf424cc

# Apply the patch (--3way handles cross-repo patches where files may already exist)
git am --3way /tmp/agent-25202419588/aw-efficiency-treenodefilter-no-linq-closures.patch

# Push the branch to origin
git push origin efficiency/treenodefilter-no-linq-closures-abd830069bf424cc

# Create the pull request
gh pr create --title '[Efficiency Improver] perf: eliminate LINQ closure allocations in TreeNodeFilter hot paths' --base main --head efficiency/treenodefilter-no-linq-closures-abd830069bf424cc --repo microsoft/testfx
Show patch preview (188 of 188 lines)
From dfb6d3d49f4547c54eda88071c9ba070754a97be Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Fri, 1 May 2026 04:45:43 +0000
Subject: [PATCH] perf: eliminate LINQ closure allocations in TreeNodeFilter
 hot paths

MatchFilterPattern and MatchProperties used switch expressions with
LINQ lambda callbacks (.Any(), .All(), .Single()) that allocate heap
objects on every invocation during filter evaluation per test node.

Changes:
- Convert MatchFilterPattern switch expression to switch statement
  with explicit foreach loops, eliminating:
  * Lambda closures capturing testNodeFragment and properties args
  * .Single() enumerator allocation in the Not arm
- Convert MatchProperties switch expression to switch statement
  with explicit foreach loops, eliminating:
  * PropertyBagEnumerable wrapper allocation from AsEnumerable()
  * Lambda closure capturing propExpr and valueExpr
  * Lambda closures capturing properties in Or/And/Not arms
- Replace _filters.Last() with _filters[_filters.Count - 1]
  to avoid LINQ enumeration in MatchesFilter

Energy impact: each filter match against a test node with complex
expressions (Or/And/Not operators, property predicates) allocated
1-3 extra heap objects per call. For test suites with thousands of
nodes, this reduces GC pressure proportional to filter complexity.

Proxy metric: heap allocation count (fewer allocations -> less GC
CPU burn -> lower energy per test run).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Requests/TreeNodeFilter/TreeNodeFilter.cs | 124 ++++++++++++++----
 1 file changed, 98 insertions(+), 26 deletions(-)

diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs
index da7178a..80b081e 100644
--- a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs
+++ b/src/Platform/Microso
... (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