Skip to content

perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync#5215

Merged
thomhurst merged 3 commits intomainfrom
perf/eliminate-linq-in-untrack-objects
Mar 22, 2026
Merged

perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync#5215
thomhurst merged 3 commits intomainfrom
perf/eliminate-linq-in-untrack-objects

Conversation

@thomhurst
Copy link
Owner

Summary

  • Replace trackedObjects.Keys.OrderByDescending(k => k) with a manual max-find loop + countdown iteration
  • Eliminates LINQ enumerator and sorted buffer allocation on every test cleanup call (~1,000 allocations per 1,000 tests)
  • Depth keys are small non-negative integers (typically 0-3), so counting down from max with TryGetValue is allocation-free

Test plan

  • Builds on all target frameworks (net8.0, net9.0, net10.0)
  • DisposableFieldTests and AsyncDisposableFieldTests pass
  • CI passes

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.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — SortedList only stores populated keys, so reverse index iteration touches only real data.
  • SortedList suggestion: Implemented throughout — TestContext.TrackedObjects, ObjectGraphDiscoverer, ObjectTracker, ObjectLifecycleService, TrackableObjectGraphProvider, and the interface IObjectGraphDiscoverer.

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.

@thomhurst thomhurst enabled auto-merge (squash) March 22, 2026 14:29
@thomhurst thomhurst merged commit 6ef48b5 into main Mar 22, 2026
14 of 15 checks passed
@thomhurst thomhurst deleted the perf/eliminate-linq-in-untrack-objects branch March 22, 2026 14:50
This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant