From 817bd3a14b82df557617c519c8874d1a369ac947 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:11:07 +0000 Subject: [PATCH 1/3] perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync 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. --- TUnit.Core/Tracking/ObjectTracker.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index 472b55377f..f759e76fb7 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -114,9 +114,24 @@ public ValueTask UntrackObjects(TestContext testContext, List cleanup private async ValueTask UntrackObjectsAsync(List cleanupExceptions, Dictionary> trackedObjects) { - foreach (var depth in trackedObjects.Keys.OrderByDescending(k => k)) + // Find the maximum depth key without LINQ allocation. + // Depth keys are small non-negative integers (typically 0-3), + // so iterating from max down to 0 is allocation-free. + var maxDepth = -1; + foreach (var key in trackedObjects.Keys) { - var bucket = trackedObjects[depth]; + if (key > maxDepth) + { + maxDepth = key; + } + } + + for (var depth = maxDepth; depth >= 0; depth--) + { + if (!trackedObjects.TryGetValue(depth, out var bucket)) + { + continue; + } List? disposalTasks = null; foreach (var obj in bucket) From afbbf717b044fac56d3520c727fcb5a12e870583 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:12:06 +0000 Subject: [PATCH 2/3] style: simplify comment in UntrackObjectsAsync --- TUnit.Core/Tracking/ObjectTracker.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index f759e76fb7..c772b5196a 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -114,9 +114,7 @@ public ValueTask UntrackObjects(TestContext testContext, List cleanup private async ValueTask UntrackObjectsAsync(List cleanupExceptions, Dictionary> trackedObjects) { - // Find the maximum depth key without LINQ allocation. - // Depth keys are small non-negative integers (typically 0-3), - // so iterating from max down to 0 is allocation-free. + // Find max depth key to iterate descending without LINQ allocation. var maxDepth = -1; foreach (var key in trackedObjects.Keys) { From 8358c68e0b900ff53d981492acf4141034eb5bab Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:22:59 +0000 Subject: [PATCH 3/3] refactor: use SortedList for TrackedObjects to enable allocation-free reverse iteration Replace Dictionary> with SortedList> 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. --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 6 ++-- .../Interfaces/IObjectGraphDiscoverer.cs | 2 +- TUnit.Core/TestContext.cs | 2 +- TUnit.Core/Tracking/ObjectTracker.cs | 25 +++++--------- .../Tracking/TrackableObjectGraphProvider.cs | 2 +- .../Services/ObjectLifecycleService.cs | 34 +++++++------------ 6 files changed, 26 insertions(+), 45 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index 96fb88747d..5d77386df0 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -148,7 +148,7 @@ public ObjectGraph DiscoverNestedObjectGraph(object rootObject, CancellationToke /// The test context to discover objects from. /// Cancellation token for the operation. /// The tracked objects dictionary (same as testContext.TrackedObjects). - public Dictionary> DiscoverAndTrackObjects(TestContext testContext, CancellationToken cancellationToken = default) + public SortedList> DiscoverAndTrackObjects(TestContext testContext, CancellationToken cancellationToken = default) { var visitedObjects = testContext.TrackedObjects; @@ -212,7 +212,7 @@ void Recurse(object value, int depth) /// private void DiscoverNestedObjectsForTracking( object obj, - Dictionary> visitedObjects, + IDictionary> visitedObjects, int currentDepth, CancellationToken cancellationToken) { @@ -264,7 +264,7 @@ private static bool ShouldSkipType(Type type) /// /// Add to HashSet at specified depth. Returns true if added (not duplicate). /// - private static bool TryAddToHashSet(Dictionary> dict, int depth, object obj) + private static bool TryAddToHashSet(IDictionary> dict, int depth, object obj) { var hashSet = dict.GetOrAdd(depth, _ => new HashSet(ReferenceComparer)); return hashSet.Add(obj); diff --git a/TUnit.Core/Interfaces/IObjectGraphDiscoverer.cs b/TUnit.Core/Interfaces/IObjectGraphDiscoverer.cs index b4b67880b5..2c45a6007c 100644 --- a/TUnit.Core/Interfaces/IObjectGraphDiscoverer.cs +++ b/TUnit.Core/Interfaces/IObjectGraphDiscoverer.cs @@ -64,7 +64,7 @@ internal interface IObjectGraphDiscoverer /// This method modifies testContext.TrackedObjects directly. For pure query operations, /// use instead. /// - Dictionary> DiscoverAndTrackObjects(TestContext testContext, CancellationToken cancellationToken = default); + SortedList> DiscoverAndTrackObjects(TestContext testContext, CancellationToken cancellationToken = default); } /// diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 770565e145..51b3f1fa00 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -332,7 +332,7 @@ internal void InvalidateEventReceiverCaches() internal AbstractExecutableTest InternalExecutableTest { get; set; } = null!; - internal Dictionary> TrackedObjects { get; } = new(); + internal SortedList> TrackedObjects { get; } = new(); /// /// Sets the output captured during test building phase. diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index c772b5196a..3d841e4b02 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -46,7 +46,7 @@ private static Counter GetOrCreateCounter(object obj) => /// /// Counts total tracked objects across all depth levels without allocating a new collection. /// - private static int CountTrackedObjects(Dictionary> trackedObjects) + private static int CountTrackedObjects(SortedList> trackedObjects) { var count = 0; foreach (var kvp in trackedObjects) @@ -61,7 +61,7 @@ private static int CountTrackedObjects(Dictionary> trackedO /// Takes a snapshot of currently tracked objects before new discovery mutates the dictionary. /// Uses ReferenceEqualityComparer to match object identity semantics. /// - private static HashSet SnapshotTrackedObjects(Dictionary> trackedObjects) + private static HashSet SnapshotTrackedObjects(SortedList> trackedObjects) { var snapshot = new HashSet(Helpers.ReferenceEqualityComparer.Instance); foreach (var kvp in trackedObjects) @@ -112,24 +112,15 @@ public ValueTask UntrackObjects(TestContext testContext, List cleanup return UntrackObjectsAsync(cleanupExceptions, trackedObjects); } - private async ValueTask UntrackObjectsAsync(List cleanupExceptions, Dictionary> trackedObjects) + private async ValueTask UntrackObjectsAsync(List cleanupExceptions, SortedList> trackedObjects) { - // Find max depth key to iterate descending without LINQ allocation. - var maxDepth = -1; - foreach (var key in trackedObjects.Keys) - { - if (key > maxDepth) - { - maxDepth = key; - } - } + // SortedList keeps keys in ascending order; iterate by index in reverse for descending depth. + var keys = trackedObjects.Keys; + var values = trackedObjects.Values; - for (var depth = maxDepth; depth >= 0; depth--) + for (var i = keys.Count - 1; i >= 0; i--) { - if (!trackedObjects.TryGetValue(depth, out var bucket)) - { - continue; - } + var bucket = values[i]; List? disposalTasks = null; foreach (var obj in bucket) diff --git a/TUnit.Core/Tracking/TrackableObjectGraphProvider.cs b/TUnit.Core/Tracking/TrackableObjectGraphProvider.cs index 2b51fc7dac..e11cbdc8d6 100644 --- a/TUnit.Core/Tracking/TrackableObjectGraphProvider.cs +++ b/TUnit.Core/Tracking/TrackableObjectGraphProvider.cs @@ -34,7 +34,7 @@ public TrackableObjectGraphProvider(IObjectGraphDiscoverer discoverer) /// /// The test context to get trackable objects from. /// Optional cancellation token for long-running discovery. - public Dictionary> GetTrackableObjects(TestContext testContext, CancellationToken cancellationToken = default) + public SortedList> GetTrackableObjects(TestContext testContext, CancellationToken cancellationToken = default) { // OCP-compliant: Use the interface method directly instead of type-checking return _discoverer.DiscoverAndTrackObjects(testContext, cancellationToken); diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 4e10d5f7f6..bbc0f0590b 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -220,34 +220,24 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont /// private async Task InitializeTrackedObjectsAsync(TestContext testContext, CancellationToken cancellationToken) { - // Get levels without LINQ - use Array.Sort with reverse comparison for descending order + // SortedList keeps keys in ascending order; iterate by index in reverse for descending depth. var trackedObjects = testContext.TrackedObjects; - var levelCount = trackedObjects.Count; + var values = trackedObjects.Values; - if (levelCount > 0) + for (var i = values.Count - 1; i >= 0; i--) { - var levels = new int[levelCount]; - trackedObjects.Keys.CopyTo(levels, 0); - Array.Sort(levels, (a, b) => b.CompareTo(a)); // Descending order + var objectsAtLevel = values[i]; - foreach (var level in levels) + // Initialize all objects at this level in parallel + var tasks = new List(objectsAtLevel.Count); + foreach (var obj in objectsAtLevel) { - if (!trackedObjects.TryGetValue(level, out var objectsAtLevel)) - { - continue; - } - - // Initialize all objects at this level in parallel - var tasks = new List(objectsAtLevel.Count); - foreach (var obj in objectsAtLevel) - { - tasks.Add(InitializeObjectWithNestedAsync(obj, cancellationToken)); - } + tasks.Add(InitializeObjectWithNestedAsync(obj, cancellationToken)); + } - if (tasks.Count > 0) - { - await Task.WhenAll(tasks); - } + if (tasks.Count > 0) + { + await Task.WhenAll(tasks); } }