[efficiency-improver] perf: avoid redundant dictionary lookups in FastFilter.Evaluate - #16139
Conversation
FastFilter.Evaluate is called once per test case when a test filter is active. Previously it iterated over FilterProperties.Keys and then called FilterProperties[name] 2-3 times per property per call, causing repeated hash-compute-and-compare dictionary lookups. Change the loop to iterate over the key-value pairs directly (foreach var kvp in FilterProperties) so that kvp.Value is obtained once per property with no additional lookups. Also simplify ValidForProperties to a single-pass scan: the previous implementation scanned FilterProperties.Keys twice in the failing case (once for All(...) and once for Where(...)). The new implementation builds the invalid array in one pass and checks its length. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes FastFilter evaluation in the filter engine by reducing redundant dictionary lookups in a per-test-case hot path (FastFilter.Evaluate), aligning with the goal of lowering overhead when --filter is used.
Changes:
- Updated
FastFilter.Evaluateto iterateFilterPropertiesas key/value pairs and reuse the value set instead of repeatedly indexing the dictionary by key. - Simplified
ValidForPropertiesto a single-pass invalid-property computation (but see performance note in comments re: allocation behavior).
| string[] invalid = FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray(); | ||
| return invalid.Length == 0 ? null : invalid; |
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
Review Summary
Scope: Single file, FastFilter.cs — two method changes totalling 17 lines.
Evaluate — ✅ Correct and beneficial
The core optimization holds: iterating foreach (var kvp in FilterProperties) and capturing kvp.Value once per property eliminates 2–3 ImmutableDictionary hash-compute-and-compare lookups per property per test-case evaluation. For an ImmutableDictionary backed by a sorted tree, each [name] access was O(log n) over the number of distinct filter properties — eliminating them in a per-test-case hot path is legitimate.
The lambda closure in the multiValues path previously re-entered FilterProperties[name] for every value tested; now it closes over the already-resolved filterValues reference, which is also correct.
No correctness, thread-safety, null-safety, or IPC concerns in Evaluate.
ValidForProperties — ⚠️ Minor allocation tradeoff (not blocking)
See inline comment on line 71. The refactoring eliminates the double-scan in the failure path but introduces an empty string[] allocation in the success path where the old .All() guard had none. Given this method is called once per test source, the real-world impact is immeasurable. The PR description's framing as a pure single-pass optimization is slightly imprecise.
Checklist
- Algorithmic correctness: ✅ Functionally equivalent in all branches
- Thread safety: ✅
FilterPropertiesisImmutableDictionary; values areImmutableHashSet— both inherently thread-safe - Null safety: ✅
kvp.Keyandkvp.Valuecannot be null for this dictionary's construction path - API surface: ✅ All changed methods are
internal— no public surface impact - IPC/protocol: ✅ No wire-format changes
- Cross-TFM: ✅
#if IS_VSTEST_REPOguards handle both vstest and Roslyn-analyzer contexts correctly - Binding redirects: ✅ No new dependencies
- Description alignment: Minor — title scopes to
Evaluatewhile the change also includesValidForProperties; the body covers both
🧠 Reviewed by expert-reviewing workflow · Dimensions: Algorithmic Correctness, Performance & Allocations, Null Safety & Boundary Validation, Backward Compatibility & Rollback Safety
🧠 Reviewed by Expert Code Reviewer 🧠
| return FilterProperties.Keys.All(name => properties.Contains(name)) | ||
| ? null | ||
| : FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray(); | ||
| string[] invalid = FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray(); |
There was a problem hiding this comment.
[Performance & Allocations] Minor allocation tradeoff worth noting.
The previous code:
return FilterProperties.Keys.All(name => properties.Contains(name))
? null
: FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray();had no allocation in the success path (all properties valid): .All() iterates and returns true without calling .ToArray().
The new code:
string[] invalid = FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray();
return invalid.Length == 0 ? null : invalid;always calls .ToArray(), allocating an empty string[0] in the success path before immediately discarding it.
Since ValidForProperties is called once per test source (not per test case), the practical impact is zero. But the PR description frames this as a pure "single-pass optimization" — it is single-pass, but it trades the double-scan in the rare failure path for an allocation in the common success path. The PR's energy-efficiency claim for this method should be noted with that nuance.
Not blocking — just flagging for accuracy against the PR description.
| { | ||
| var values = PropertyValueRegex == null ? multiValues : multiValues?.Select(value => ApplyRegex(value)); | ||
| matched = values?.Any(result => result != null && FilterProperties[name].Contains(result)) == true; | ||
| matched = values?.Any(result => result != null && filterValues.Contains(result)) == true; |
There was a problem hiding this comment.
[Performance] Any(lambda) still allocates a Closure object per outer-loop iteration that reaches this branch. The closure previously captured FilterProperties + name (two fields); it now captures only filterValues (one field)—smaller, but still a heap allocation on every multi-value test evaluation.
With filterValues already a local, a foreach eliminates the closure entirely:
| matched = values?.Any(result => result != null && filterValues.Contains(result)) == true; | |
| matched = false; | |
| if (values != null) | |
| { | |
| foreach (var result in values) | |
| { | |
| if (result != null && filterValues.Contains(result)) | |
| { | |
| matched = true; | |
| break; | |
| } | |
| } | |
| } |
Also removes the == true nullable-bool coercion, which is defensive but unreachable here — values is always non-null in this branch (multiValues was just proven { Length: > 0 }, and Select on a non-null source returns a non-null iterator).
| ? null | ||
| : FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray(); | ||
| string[] invalid = FilterProperties.Keys.Where(name => !properties.Contains(name)).ToArray(); | ||
| return invalid.Length == 0 ? null : invalid; |
There was a problem hiding this comment.
nit: [Performance] On the happy path (all properties valid) the old All() short-circuited and returned null without any allocation. The new Where().ToArray() always runs to completion — on .NET 6+ this returns Array.Empty<string>() (no heap cost), but on net462 Buffer<T>.ToArray() emits new string[0].
ValidForProperties is cold (once per test source), so the practical impact is zero. But since the stated goal of the PR is eliminating redundant work, worth being aware that this path trades a short-circuit win for avoiding the double-pass in the unhappy case.
|
looks not worth it with the other allocations it introduces.= |
Goal and Rationale
FastFilter.Evaluateis called once per test case whenever a test filter is active (e.g.--filter "TestCategory=UnitTests"). The previous implementation iterated overFilterProperties.Keysand then calledFilterProperties[name]2–3 times per property per call, resulting in repeated hash-compute-and-compare dictionary lookups.For a test suite with 10,000 tests and a single-property filter, this is ~20,000–30,000 extra dictionary lookups that can be eliminated with no behaviour change.
Focus Area
Code-Level Efficiency — eliminating redundant computation in a tight per-test-case loop.
Approach
Two changes in
FastFilter.cs:Evaluate— changedforeach (var name in FilterProperties.Keys)toforeach (var kvp in FilterProperties), obtaining the value set once per property (kvp.Value) instead of looking it up viaFilterProperties[name]on each access.ValidForProperties— replaced the double-scan patternwith a single-pass:
ValidForPropertiesis called once per test source (not per test case), so the impact here is smaller, but the simplification is clear.Energy Efficiency Evidence
Proxy metric: CPU instruction count / execution time (faster code = fewer CPU cycles = less energy drawn).
ImmutableDictionary<TKey, TValue>lookup involves:For the common single-string-value case the previous code did 2 lookups per property per
Evaluatecall; the new code does 0. For the multi-value case it did 3 lookups; now 0. GivenEvaluateis called O(tests) times per run, and typical filters have 1–3 properties, savings compound across large test suites.Reproducibility: Run
dotnet test --filter TestCategory=...against a large test suite and compare wall-clock time. With 10,000 tests, expect a small but measurable reduction in filter-evaluation overhead.Green Software Foundation Context
Hardware Efficiency: Eliminating unnecessary work makes better use of the available CPU cycles, reducing energy drawn per functional unit (test execution).
SCI: Reducing the instruction count per
dotnet testrun lowers the energy term of the SCI equation for CI/CD pipelines that run vstest millions of times per day across the .NET ecosystem.Trade-offs
None. The change is purely mechanical — no algorithmic change, no new dependencies, no readability degradation. Using
kvp.Key/kvp.Valuefrom aforeachover a dictionary is idiomatic C# and already used throughout the codebase (e.g.JsoniteConvert.cs).Test Status
Microsoft.TestPlatform.Common.UnitTests(Filtering namespace): ✅ 108/108 passedMicrosoft.TestPlatform.Filter.Source.UnitTests: ✅ 45/45 passednet462+netstandard2.0)