perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync#5215
perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync#5215
Conversation
Replace OrderByDescending(k => k) with a manual max-key scan and downward for-loop. Depth keys are small non-negative integers (typically 0-3), so iterating from max down to 0 is allocation-free and avoids creating a LINQ enumerator + sorted sequence on every test cleanup. With ~1,000 tests this removes ~1,000 LINQ allocations per run.
There was a problem hiding this comment.
Code Review
The optimization is correct and the allocation elimination reasoning is sound. A few observations:
Correctness ✅
The logic is correct. Finding the max key then counting down with properly handles sparse depth sets and maintains the same descending-depth disposal order as the original LINQ approach.
Assumption vs. Reality: Depth Range
The PR description states depths are "typically 0-3", but . In the worst case, the new loop runs 51 iterations (0–50) even if only a handful of depths are populated. The original LINQ approach allocated but iterated only the actually populated keys.
For typical tests this is a clear win. For deeply nested object graphs with sparse depth distribution, the loop does more iterations than LINQ would (though all iterations are O(1) TryGetValue, so it's still fast).
Better Structural Approach
Rather than working around the unordered Dictionary<int, HashSet<object>>, consider changing TrackedObjects in TestContext.cs from:
internal Dictionary<int, HashSet<object>> TrackedObjects { get; } = new();to:
internal SortedList<int, HashSet<object>> TrackedObjects { get; } = new();This would let UntrackObjectsAsync use index-based reverse iteration — zero allocations, zero extra loop overhead, and only iterates actually-populated keys:
var keys = trackedObjects.Keys;
var values = trackedObjects.Values;
for (var i = keys.Count - 1; i >= 0; i--)
{
var bucket = values[i];
// ...
}Why this is better: The sorted order becomes an invariant of the data structure rather than something every caller must re-establish. It also eliminates the max-find loop entirely and avoids iterating missing depth integers. Insertions into SortedList are O(n) but n (number of distinct depths) is always tiny (≤ MaxRecursionDepth = 50), so this cost is negligible.
That said, this is a broader change touching TestContext.cs and potentially ObjectGraphDiscoverer.cs. The current PR's narrower approach is pragmatic and is an improvement over the status quo — I just wanted to flag the cleaner long-term path.
Minor: Comment Accuracy
// Find max depth key to iterate descending without LINQ allocation.This comment is accurate. No issues.
Overall, this is a valid, targeted perf improvement that achieves its stated goal. The approach would be architecturally cleaner but is out of scope for this PR. Approving as-is with the suggestion noted for a future follow-up.
There was a problem hiding this comment.
Code Review
The optimization is correct and the allocation elimination reasoning is sound. A few observations:
Correctness ✅
The logic is correct. Finding the max key then counting down with TryGetValue properly handles sparse depth sets and maintains the same descending-depth disposal order as the original LINQ approach.
Assumption vs. Reality: Depth Range
The PR description states depths are "typically 0-3", but ObjectGraphDiscoverer.MaxRecursionDepth = 50. In the worst case, the new loop runs 51 iterations (0–50) even if only a handful of depths are populated. The original LINQ approach allocated but iterated only the actually populated keys.
For typical tests this is a clear win. For deeply nested object graphs with sparse depth distribution, the loop does more iterations than LINQ would (though all iterations are O(1) TryGetValue, so it's still fast).
Better Structural Approach
Rather than working around the unordered Dictionary<int, HashSet<object>>, consider changing TrackedObjects in TestContext.cs (line 335) from:
internal Dictionary<int, HashSet<object>> TrackedObjects { get; } = new();to:
internal SortedList<int, HashSet<object>> TrackedObjects { get; } = new();This would let UntrackObjectsAsync use index-based reverse iteration — zero allocations, zero extra loop overhead, and only iterates actually-populated keys:
var keys = trackedObjects.Keys;
var values = trackedObjects.Values;
for (var i = keys.Count - 1; i >= 0; i--)
{
var bucket = values[i];
// ...
}Why this is better: The sorted order becomes an invariant of the data structure rather than something every caller must re-establish. It also eliminates the max-find loop entirely and avoids iterating missing depth integers. Insertions into SortedList are O(n) but n (number of distinct depths) is always tiny (≤ MaxRecursionDepth = 50), so this cost is negligible.
That said, this is a broader change touching TestContext.cs and potentially ObjectGraphDiscoverer.cs. The current PR's narrower approach is pragmatic and is an improvement over the status quo — flagging the cleaner long-term path for consideration.
Minor: Comment Accuracy
// Find max depth key to iterate descending without LINQ allocation.Accurate and helpful. No issues.
Overall, this is a valid, targeted perf improvement that achieves its stated goal. The SortedList approach would be architecturally cleaner but is out of scope for this PR.
… reverse iteration Replace Dictionary<int, HashSet<object>> with SortedList<int, HashSet<object>> for TrackedObjects, making sorted order a structural invariant. This eliminates the max-find loop in UntrackObjectsAsync and the Array.Sort + CopyTo in InitializeTrackedObjectsAsync, replacing both with zero-allocation index-based reverse iteration over SortedList.Keys/Values.
There was a problem hiding this comment.
Code Review — Updated Implementation
The previous review suggested adopting SortedList<int, HashSet<object>> as the cleaner structural approach. This PR has fully implemented that suggestion. All previous observations are now resolved.
Previous Comments: Addressed ✅
- Max-find loop concern (sparse depth iteration): Gone —
SortedListonly stores populated keys, so reverse index iteration touches only real data. SortedListsuggestion: Implemented throughout —TestContext.TrackedObjects,ObjectGraphDiscoverer,ObjectTracker,ObjectLifecycleService,TrackableObjectGraphProvider, and the interfaceIObjectGraphDiscoverer.
Review of Current Implementation
Correctness ✅
SortedList<int, HashSet<object>> maintains ascending key order by definition. The reverse index-based for loop correctly yields descending depth order for disposal — same semantics as the original OrderByDescending, zero allocations.
ObjectLifecycleService simplification ✅
The removal of the int[] levels + Array.Sort workaround is a clean improvement. The if (levelCount > 0) guard was also correctly dropped — the for loop naturally handles empty collections.
IDictionary in private helpers — Minor note
DiscoverNestedObjectsForTracking and TryAddToHashSet take IDictionary<int, HashSet<object>>. Since these are private methods always called with the concrete SortedList, using the interface here is a reasonable choice (avoids leaking the concrete type deeper). No issue.
SortedList insertion cost
O(n) insertions vs. O(1) amortized for Dictionary. With n ≤ MaxRecursionDepth (50, typically 1–3), this is completely negligible and not worth worrying about.
TrackableObjectGraphProvider.cs
Return type updated here too — correctly keeping all call sites consistent. Good.
Summary
This is a well-executed performance improvement. Sorted order is now a structural invariant of TrackedObjects rather than a runtime concern every caller must re-establish. The hot-path iteration in both ObjectTracker and ObjectLifecycleService is now allocation-free and iterates only populated depth keys. The implementation is clean, correct, and simpler than what it replaces.
No blocking issues.
[//]: # (dependabot-start)⚠️ **Dependabot is rebasing this PR**⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to 1.21.6. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.21.6 <!-- Release notes generated using configuration in .github/release.yml at v1.21.6 --> ## What's Changed ### Other Changes * perf: replace object locks with Lock type for efficient synchronization by @thomhurst in thomhurst/TUnit#5219 * perf: parallelize test metadata collection for source-generated tests by @thomhurst in thomhurst/TUnit#5221 * perf: use GetOrAdd args overload to eliminate closure allocations in event receivers by @thomhurst in thomhurst/TUnit#5222 * perf: self-contained TestEntry<T> with consolidated switch invokers eliminates per-test JIT by @thomhurst in thomhurst/TUnit#5223 ### Dependencies * chore(deps): update tunit to 1.21.0 by @thomhurst in thomhurst/TUnit#5220 **Full Changelog**: thomhurst/TUnit@v1.21.0...v1.21.6 ## 1.21.0 <!-- Release notes generated using configuration in .github/release.yml at v1.21.0 --> ## What's Changed ### Other Changes * perf: reduce ConcurrentDictionary closure allocations in hot paths by @thomhurst in thomhurst/TUnit#5210 * perf: reduce async state machine overhead in test execution pipeline by @thomhurst in thomhurst/TUnit#5214 * perf: reduce allocations in EventReceiverOrchestrator and TestContextExtensions by @thomhurst in thomhurst/TUnit#5212 * perf: skip timeout machinery when no timeout configured by @thomhurst in thomhurst/TUnit#5211 * perf: reduce allocations and lock contention in ObjectTracker by @thomhurst in thomhurst/TUnit#5213 * Feat/numeric tolerance by @agray in thomhurst/TUnit#5110 * perf: remove unnecessary lock in ObjectTracker.TrackObjects by @thomhurst in thomhurst/TUnit#5217 * perf: eliminate async state machine in TestCoordinator.ExecuteTestAsync by @thomhurst in thomhurst/TUnit#5216 * perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync by @thomhurst in thomhurst/TUnit#5215 * perf: consolidate module initializers into single .cctor via partial class by @thomhurst in thomhurst/TUnit#5218 ### Dependencies * chore(deps): update tunit to 1.20.0 by @thomhurst in thomhurst/TUnit#5205 * chore(deps): update dependency nunit3testadapter to 6.2.0 by @thomhurst in thomhurst/TUnit#5206 * chore(deps): update dependency cliwrap to 3.10.1 by @thomhurst in thomhurst/TUnit#5207 **Full Changelog**: thomhurst/TUnit@v1.20.0...v1.21.0 ## 1.20.0 <!-- Release notes generated using configuration in .github/release.yml at v1.20.0 --> ## What's Changed ### Other Changes * Fix inverted colors in HTML report ring chart due to locale-dependent decimal formatting by @Copilot in thomhurst/TUnit#5185 * Fix nullable warnings when using Member() on nullable properties by @Copilot in thomhurst/TUnit#5191 * Add CS8629 suppression and member access expression matching to IsNotNullAssertionSuppressor by @Copilot in thomhurst/TUnit#5201 * feat: add ConfigureAppHost hook to AspireFixture by @thomhurst in thomhurst/TUnit#5202 * Fix ConfigureTestConfiguration being invoked twice by @thomhurst in thomhurst/TUnit#5203 * Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by @thomhurst in thomhurst/TUnit#5204 ### Dependencies * chore(deps): update dependency gitversion.tool to v6.6.2 by @thomhurst in thomhurst/TUnit#5181 * chore(deps): update dependency gitversion.msbuild to 6.6.2 by @thomhurst in thomhurst/TUnit#5180 * chore(deps): update tunit to 1.19.74 by @thomhurst in thomhurst/TUnit#5179 * chore(deps): update verify to 31.13.3 by @thomhurst in thomhurst/TUnit#5182 * chore(deps): update verify to 31.13.5 by @thomhurst in thomhurst/TUnit#5183 * chore(deps): update aspire to 13.1.3 by @thomhurst in thomhurst/TUnit#5189 * chore(deps): update dependency stackexchange.redis to 2.12.4 by @thomhurst in thomhurst/TUnit#5193 * chore(deps): update microsoft/setup-msbuild action to v3 by @thomhurst in thomhurst/TUnit#5197 **Full Changelog**: thomhurst/TUnit@v1.19.74...v1.20.0 ## 1.19.74 <!-- Release notes generated using configuration in .github/release.yml at v1.19.74 --> ## What's Changed ### Other Changes * feat: per-hook activity spans with method names by @thomhurst in thomhurst/TUnit#5159 * fix: add tooltip to truncated span names in HTML report by @thomhurst in thomhurst/TUnit#5164 * Use enum names instead of numeric values in test display names by @Copilot in thomhurst/TUnit#5178 * fix: resolve CS8920 when mocking interfaces whose members return static-abstract interfaces by @lucaxchaves in thomhurst/TUnit#5154 ### Dependencies * chore(deps): update tunit to 1.19.57 by @thomhurst in thomhurst/TUnit#5157 * chore(deps): update dependency gitversion.msbuild to 6.6.1 by @thomhurst in thomhurst/TUnit#5160 * chore(deps): update dependency gitversion.tool to v6.6.1 by @thomhurst in thomhurst/TUnit#5161 * chore(deps): update dependency polyfill to 9.20.0 by @thomhurst in thomhurst/TUnit#5163 * chore(deps): update dependency polyfill to 9.20.0 by @thomhurst in thomhurst/TUnit#5162 * chore(deps): update dependency polyfill to 9.21.0 by @thomhurst in thomhurst/TUnit#5166 * chore(deps): update dependency polyfill to 9.21.0 by @thomhurst in thomhurst/TUnit#5167 * chore(deps): update dependency polyfill to 9.22.0 by @thomhurst in thomhurst/TUnit#5168 * chore(deps): update dependency polyfill to 9.22.0 by @thomhurst in thomhurst/TUnit#5169 * chore(deps): update dependency coverlet.collector to 8.0.1 by @thomhurst in thomhurst/TUnit#5177 ## New Contributors * @lucaxchaves made their first contribution in thomhurst/TUnit#5154 **Full Changelog**: thomhurst/TUnit@v1.19.57...v1.19.74 Commits viewable in [compare view](thomhurst/TUnit@v1.19.57...v1.21.6). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
trackedObjects.Keys.OrderByDescending(k => k)with a manual max-find loop + countdown iterationTryGetValueis allocation-freeTest plan