From d134e2e755918b95f82d39c465a341a7d3ffc910 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:47:40 +0100 Subject: [PATCH 1/4] perf(engine): batch per-test overhead cleanups (#5719) Four of the five batched cleanups from #5719. Individually modest (~0.2-1% each), collectively ~1% CPU across typical suites. Shipped: - Hoist EnsureEventReceiversCached out of the five per-test receiver getters; gate with an EventReceiversBuilt bool instead of probing each cached-array field. TestCoordinator.ExecuteTestLifecycleAsync rebuilds once after ClassInstance is assigned, so the hot-path getters are now plain field reads. - Cache ObjectGraphDiscoverer.TraverseInitializerProperties output per-type. The inheritance walk + HashSet dedupe now runs once and is memoized; per-test traversal iterates the flattened InitializerPropertyInfo[] directly. ShouldSkipType is memoized too, so Namespace.StartsWith("System") no longer fires per call. - Drop the Lazy from TestScheduler on the .NET 8+ path. Parallel.ForEachAsync caps concurrency via ParallelOptions; the semaphore is now only built on the netstandard2.0 fallback. Skipped: - Per-TestContext slot for ObjectTracker counters: requires a robust "shared vs per-test" signal at Track/Untrack time. The existing _initializedObjects set in EventReceiverOrchestrator only flags seen-before after the second test, so the bulk of per-test objects would still hit the shared dict on first track. Held back pending a dedicated sharing marker. - RestoreExecutionContext version-counter skip: a working variant regressed the Bugs/1914 AsyncLocal-flow tests. The captured ExecutionContext on a context can carry AsyncLocal values set by hooks on the active async flow between Restore sites; a thread- static "same context + same version" skip misses those flows when the thread lands a continuation that diverges from its last Restore. Held back for a safer design. --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 112 +++++++++++------- TUnit.Core/TestContext.cs | 8 ++ .../Extensions/TestContextExtensions.cs | 30 ++--- TUnit.Engine/Scheduling/TestScheduler.cs | 38 ++++-- .../Services/TestExecution/TestCoordinator.cs | 9 +- 5 files changed, 121 insertions(+), 76 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index 129a0679b5..c888477285 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -62,6 +62,17 @@ internal sealed class ObjectGraphDiscoverer : IObjectGraphTracker // Thread-safe collection of discovery errors for diagnostics private static readonly ConcurrentBag DiscoveryErrors = []; + // Memoize ShouldSkipType — Namespace.StartsWith("System") is expensive when repeated + // across every per-test initializer traversal. + private static readonly ConcurrentDictionary ShouldSkipTypeCache = new(); + + // Cached flattened InitializerPropertyInfo[] for each type, including base-class + // properties with derived-first precedence. Traversal would otherwise walk the + // inheritance chain and allocate a HashSet on every call. + // - non-null array: type has registered source-gen metadata (at this or an ancestor level). + // - null : no source-gen registration anywhere in hierarchy — use reflection fallback. + private static readonly ConcurrentDictionary FlattenedInitializerPropertiesCache = new(); + /// /// Gets all discovery errors that occurred during object graph traversal. /// Useful for debugging and diagnostics when property access fails. @@ -249,18 +260,20 @@ public static void ClearCache() { PropertyCacheManager.ClearCache(); TypeHierarchyCache.Clear(); + ShouldSkipTypeCache.Clear(); + FlattenedInitializerPropertiesCache.Clear(); ClearDiscoveryErrors(); } /// - /// Checks if a type should be skipped during discovery. + /// Checks if a type should be skipped during discovery. Result is memoized per type — + /// the underlying check (Namespace.StartsWith) is stable for any given type. /// private static bool ShouldSkipType(Type type) - { - return type.IsPrimitive || - SkipTypes.Contains(type) || - type.Namespace?.StartsWith("System") == true; - } + => ShouldSkipTypeCache.GetOrAdd(type, static t => + t.IsPrimitive || + SkipTypes.Contains(t) || + t.Namespace?.StartsWith("System", StringComparison.Ordinal) == true); /// /// Add to HashSet at specified depth. Returns true if added (not duplicate). @@ -408,47 +421,70 @@ private static void TraverseInitializerProperties( return; } - // Track processed property names to handle overrides correctly - // (derived class properties take precedence over base class properties) - var processedPropertyNames = new HashSet(StringComparer.Ordinal); - var hasAnySourceGenRegistration = false; + // Fetch the pre-flattened source-gen property list for this type. The flattening + // walks the inheritance chain once and caches the deduplicated result, so per-test + // traversal does not re-walk BaseType nor allocate a HashSet. + var flattened = GetFlattenedInitializerProperties(type); - // Walk up the inheritance chain to find all IAsyncInitializer properties - // This ensures base class properties are discovered even when derived class has source-gen registration - var currentType = type; - while (currentType != null && currentType != typeof(object)) + if (flattened != null) { - cancellationToken.ThrowIfCancellationRequested(); + TraverseFlattenedInitializerProperties(obj, type, flattened, tryAdd, recurse, currentDepth, cancellationToken); + return; + } - var registeredProperties = InitializerPropertyRegistry.GetProperties(currentType); - if (registeredProperties != null) - { - hasAnySourceGenRegistration = true; - TraverseRegisteredInitializerPropertiesWithTracking( - obj, currentType, registeredProperties, processedPropertyNames, - tryAdd, recurse, currentDepth, cancellationToken); - } + // No source-gen registration anywhere in the hierarchy → fall back to reflection. + TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); + } - currentType = currentType.BaseType; + /// + /// Returns the cached flattened InitializerPropertyInfo[] for a type (inheritance-walked, + /// derived-first precedence, deduplicated by property name). Returns null when no + /// source-gen registration exists in the type's inheritance chain. + /// + private static InitializerPropertyInfo[]? GetFlattenedInitializerProperties(Type type) + { + if (FlattenedInitializerPropertiesCache.TryGetValue(type, out var cached)) + { + return cached; } - // If no source-gen registration was found in the entire hierarchy, fall back to reflection - // Reflection path already handles inheritance correctly via GetProperties without DeclaredOnly - if (!hasAnySourceGenRegistration) + List? merged = null; + HashSet? seen = null; + + for (var currentType = type; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) { - TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); + var registered = InitializerPropertyRegistry.GetProperties(currentType); + if (registered == null) + { + continue; + } + + merged ??= new List(registered.Length); + seen ??= new HashSet(StringComparer.Ordinal); + + foreach (var p in registered) + { + if (seen.Add(p.PropertyName)) + { + merged.Add(p); + } + } } + + var result = merged?.ToArray(); + FlattenedInitializerPropertiesCache[type] = result; + return result; } /// - /// Traverses source-generated IAsyncInitializer properties with property name tracking. - /// Skips properties that have already been processed (handles overrides in derived classes). + /// Traverses the pre-flattened source-generated IAsyncInitializer properties. + /// No per-call inheritance walk or HashSet allocation — that work was done once + /// during flattening and cached in . /// - private static void TraverseRegisteredInitializerPropertiesWithTracking( + private static void TraverseFlattenedInitializerProperties( object obj, Type type, InitializerPropertyInfo[] properties, - HashSet processedPropertyNames, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, @@ -458,12 +494,6 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking( { cancellationToken.ThrowIfCancellationRequested(); - // Skip if already processed (overridden in derived class) - if (!processedPropertyNames.Add(propInfo.PropertyName)) - { - continue; - } - try { var value = propInfo.GetValue(obj); @@ -472,7 +502,6 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking( continue; } - // Only discover IAsyncInitializer objects if (value is IAsyncInitializer && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); @@ -480,15 +509,12 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking( } catch (OperationCanceledException) { - throw; // Propagate cancellation + throw; } catch (Exception ex) { - // Record error for diagnostics (still available via GetDiscoveryErrors()) DiscoveryErrors.Add(new DiscoveryError(type.Name, propInfo.PropertyName, ex.Message, ex)); - // Propagate the exception with context about which property failed - // This ensures data source failures are reported as test failures throw DataSourceException.FromNestedFailure( $"Failed to access property '{propInfo.PropertyName}' on type '{type.Name}' during object graph discovery. " + $"This may indicate that a data source or its nested dependencies failed to initialize. " + diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index df1f07dbfd..1c86956c39 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -402,6 +402,13 @@ public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId) // Track the class instance used when building caches for invalidation on retry internal object? CachedClassInstance { get; set; } + /// + /// Fast-path gate for EnsureEventReceiversCached. A single bool check replaces the + /// previous "cache-array is non-null" inspection that ran in every per-test receiver + /// getter (see ). + /// + internal bool EventReceiversBuilt { get; set; } + /// /// Invalidates all cached event receiver data. Called when class instance changes (e.g., on retry). /// @@ -421,6 +428,7 @@ internal void InvalidateEventReceiverCaches() CachedTestDiscoveryReceivers = null; CachedTestRegisteredReceivers = null; CachedClassInstance = null; + EventReceiversBuilt = false; } internal ConcurrentDictionary ObjectBag => _testBuilderContext.StateBag; diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index 381cfd2cf2..b3953ff715 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -18,24 +18,16 @@ internal static class TestContextExtensions /// When this happens, eligible event objects may include the new instance (if it implements /// event receiver interfaces), so all caches must be invalidated and rebuilt. /// - private static void EnsureEventReceiversCached(TestContext testContext) + public static void EnsureEventReceiversCached(this TestContext testContext) { var currentClassInstance = testContext.Metadata.TestDetails.ClassInstance; - // Check if caches are valid (populated and class instance hasn't changed) -#if NET - if (testContext.CachedTestStartReceiversEarly != null && + // Fast path: caches populated and class instance unchanged since last build. + if (testContext.EventReceiversBuilt && ReferenceEquals(testContext.CachedClassInstance, currentClassInstance)) { return; } -#else - if (testContext.CachedTestStartReceivers != null && - ReferenceEquals(testContext.CachedClassInstance, currentClassInstance)) - { - return; - } -#endif // Invalidate stale caches if class instance changed if (testContext.CachedClassInstance != null && @@ -138,6 +130,7 @@ private static void EnsureEventReceiversCached(TestContext testContext) // Update cached class instance last testContext.CachedClassInstance = currentClassInstance; + testContext.EventReceiversBuilt = true; } private static T[] SortAndFilter(List? receivers) where T : class, IEventReceiver @@ -157,7 +150,7 @@ private static T[] SortAndFilter(List? receivers) where T : class, IEventR public static IEnumerable GetEligibleEventObjects(this TestContext testContext) { // Use EnsureEventReceiversCached which builds eligible objects as part of cache initialization - EnsureEventReceiversCached(testContext); + testContext.EnsureEventReceiversCached(); return testContext.CachedEligibleEventObjects!; } @@ -265,11 +258,12 @@ private static int CountNonNullValues(IDictionary props) /// /// Gets pre-computed test start receivers (filtered, sorted, scoped-attribute filtered). + /// Assumes has been called by the lifecycle + /// coordinator — hot-path callers (per-test start/end/skipped) skip the guard. /// #if NET public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext, EventReceiverStage stage) { - EnsureEventReceiversCached(testContext); return stage == EventReceiverStage.Early ? testContext.CachedTestStartReceiversEarly! : testContext.CachedTestStartReceiversLate!; @@ -277,7 +271,6 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #else public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext) { - EnsureEventReceiversCached(testContext); return testContext.CachedTestStartReceivers!; } #endif @@ -288,7 +281,6 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #if NET public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext, EventReceiverStage stage) { - EnsureEventReceiversCached(testContext); return stage == EventReceiverStage.Early ? testContext.CachedTestEndReceiversEarly! : testContext.CachedTestEndReceiversLate!; @@ -296,7 +288,6 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC #else public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext) { - EnsureEventReceiversCached(testContext); return testContext.CachedTestEndReceivers!; } #endif @@ -306,25 +297,26 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC /// public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext) { - EnsureEventReceiversCached(testContext); return testContext.CachedTestSkippedReceivers!; } /// /// Gets pre-computed test discovery receivers (filtered, sorted, scoped-attribute filtered). + /// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand. /// public static ITestDiscoveryEventReceiver[] GetTestDiscoveryReceivers(this TestContext testContext) { - EnsureEventReceiversCached(testContext); + testContext.EnsureEventReceiversCached(); return testContext.CachedTestDiscoveryReceivers!; } /// /// Gets pre-computed test registered receivers (filtered, sorted, scoped-attribute filtered). + /// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand. /// public static ITestRegisteredEventReceiver[] GetTestRegisteredReceivers(this TestContext testContext) { - EnsureEventReceiversCached(testContext); + testContext.EnsureEventReceiversCached(); return testContext.CachedTestRegisteredReceivers!; } } diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 1ab31aa951..748e31c9e3 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -27,7 +27,9 @@ internal sealed class TestScheduler : ITestScheduler private readonly StaticPropertyHandler _staticPropertyHandler; private readonly IDynamicTestQueue _dynamicTestQueue; private readonly Lazy _maxParallelism; +#if !NET8_0_OR_GREATER private readonly Lazy _maxParallelismSemaphore; +#endif public TestScheduler( TUnitFrameworkLogger logger, @@ -57,8 +59,13 @@ public TestScheduler( _maxParallelism = new Lazy(() => GetMaxParallelism(logger, commandLineOptions)); +#if !NET8_0_OR_GREATER + // The .NET 8+ path uses Parallel.ForEachAsync which caps concurrency via + // ParallelOptions.MaxDegreeOfParallelism — the semaphore is only needed + // for the netstandard2.0 fallback path. _maxParallelismSemaphore = new Lazy(() => new SemaphoreSlim(_maxParallelism.Value, _maxParallelism.Value)); +#endif } #if NET8_0_OR_GREATER @@ -310,10 +317,14 @@ private Task ExecuteTestsAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken) { - // All paths run through the semaphore-backed limiter so the DOP cap is - // unified in GetMaxParallelism. "Unlimited" is resolved to the default - // cap (ProcessorCount * 4) there rather than being a separate code path. + // All paths run through the shared limiter so the DOP cap is unified in + // GetMaxParallelism. "Unlimited" is resolved to the default cap + // (ProcessorCount * 4) there rather than being a separate code path. +#if NET8_0_OR_GREATER + return ExecuteWithGlobalLimitAsync(tests, cancellationToken); +#else return ExecuteWithGlobalLimitAsync(tests, _maxParallelismSemaphore.Value, cancellationToken); +#endif } #if NET8_0_OR_GREATER @@ -330,19 +341,16 @@ private async Task ExecuteSequentiallyAsync( } } - private async Task ExecuteWithGlobalLimitAsync( +#if NET8_0_OR_GREATER + private Task ExecuteWithGlobalLimitAsync( AbstractExecutableTest[] tests, - SemaphoreSlim globalSemaphore, CancellationToken cancellationToken) { - var maxParallelism = _maxParallelism.Value; - -#if NET8_0_OR_GREATER - await Parallel.ForEachAsync( + return Parallel.ForEachAsync( tests, new ParallelOptions { - MaxDegreeOfParallelism = maxParallelism, + MaxDegreeOfParallelism = _maxParallelism.Value, CancellationToken = cancellationToken }, async (test, ct) => @@ -350,8 +358,14 @@ await Parallel.ForEachAsync( test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask(); await test.ExecutionTask.ConfigureAwait(false); } - ).ConfigureAwait(false); + ); + } #else + private async Task ExecuteWithGlobalLimitAsync( + AbstractExecutableTest[] tests, + SemaphoreSlim globalSemaphore, + CancellationToken cancellationToken) + { // Fallback for netstandard2.0: Manual bounded concurrency using existing semaphore var tasks = new Task[tests.Length]; for (var i = 0; i < tests.Length; i++) @@ -372,8 +386,8 @@ await Parallel.ForEachAsync( }, CancellationToken.None); } await Task.WhenAll(tasks).ConfigureAwait(false); -#endif } +#endif private async Task WaitForTasksWithFailFastHandling(IEnumerable tasks, CancellationToken cancellationToken) { diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 0df9702a58..26e28c6b5c 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -3,6 +3,7 @@ using TUnit.Core.Exceptions; using TUnit.Core.Logging; using TUnit.Core.Tracking; +using TUnit.Engine.Extensions; using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; @@ -295,8 +296,12 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false); - // Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed. - test.Context.CachedEligibleEventObjects = null; + // Rebuild the receiver cache now that ClassInstance is assigned — the initial build + // at RegisterReceivers time ran with the placeholder instance. EnsureEventReceiversCached + // detects the changed instance via its internal invalidation check. Hoisting the call + // here lets the five per-test receiver getters (GetTestStartReceivers/GetTestEndReceivers/...) + // skip their own guards on the hot path. + test.Context.EnsureEventReceiversCached(); // Check if this test should be skipped (after creating instance). // This handles basic [Skip] attributes that use SkippedTestInstance as a sentinel, From 05f1046c73032ec568c629c0b41f8773ce725ee4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:06:03 +0100 Subject: [PATCH 2/4] perf(engine): address review feedback on per-test cleanups - Add Debug.Assert guards on the three hot-path receiver getters (GetTestStartReceivers, GetTestEndReceivers, GetTestSkippedReceivers) to catch invariant violations in Debug/test builds without cost in Release. - Unify dictionary access in ObjectGraphDiscoverer: both ShouldSkipType and GetFlattenedInitializerProperties now use GetOrAdd with a static factory. - Document the RegisterReceivers -> EnsureEventReceiversCached ordering dependency that makes the early skip-path safe on ExecuteTestLifecycleAsync. --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 42 ++++++++----------- .../Extensions/TestContextExtensions.cs | 8 +++- .../Services/TestExecution/TestCoordinator.cs | 10 +++++ 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index c888477285..24732c6b26 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -442,39 +442,33 @@ private static void TraverseInitializerProperties( /// source-gen registration exists in the type's inheritance chain. /// private static InitializerPropertyInfo[]? GetFlattenedInitializerProperties(Type type) - { - if (FlattenedInitializerPropertiesCache.TryGetValue(type, out var cached)) + => FlattenedInitializerPropertiesCache.GetOrAdd(type, static t => { - return cached; - } + List? merged = null; + HashSet? seen = null; - List? merged = null; - HashSet? seen = null; - - for (var currentType = type; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) - { - var registered = InitializerPropertyRegistry.GetProperties(currentType); - if (registered == null) + for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) { - continue; - } + var registered = InitializerPropertyRegistry.GetProperties(currentType); + if (registered == null) + { + continue; + } - merged ??= new List(registered.Length); - seen ??= new HashSet(StringComparer.Ordinal); + merged ??= new List(registered.Length); + seen ??= new HashSet(StringComparer.Ordinal); - foreach (var p in registered) - { - if (seen.Add(p.PropertyName)) + foreach (var p in registered) { - merged.Add(p); + if (seen.Add(p.PropertyName)) + { + merged.Add(p); + } } } - } - var result = merged?.ToArray(); - FlattenedInitializerPropertiesCache[type] = result; - return result; - } + return merged?.ToArray(); + }); /// /// Traverses the pre-flattened source-generated IAsyncInitializer properties. diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index b3953ff715..3bb9a9c35c 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -1,4 +1,5 @@ -using TUnit.Core; +using System.Diagnostics; +using TUnit.Core; using TUnit.Core.Enums; using TUnit.Core.Interfaces; using TUnit.Engine.Utilities; @@ -264,6 +265,7 @@ private static int CountNonNullValues(IDictionary props) #if NET public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext, EventReceiverStage stage) { + Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard."); return stage == EventReceiverStage.Early ? testContext.CachedTestStartReceiversEarly! : testContext.CachedTestStartReceiversLate!; @@ -271,6 +273,7 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #else public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext) { + Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard."); return testContext.CachedTestStartReceivers!; } #endif @@ -281,6 +284,7 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #if NET public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext, EventReceiverStage stage) { + Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard."); return stage == EventReceiverStage.Early ? testContext.CachedTestEndReceiversEarly! : testContext.CachedTestEndReceiversLate!; @@ -288,6 +292,7 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC #else public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext) { + Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard."); return testContext.CachedTestEndReceivers!; } #endif @@ -297,6 +302,7 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC /// public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext) { + Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestSkippedReceivers — caller is on the hot path and skips the guard."); return testContext.CachedTestSkippedReceivers!; } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 26e28c6b5c..16e5066bcb 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -277,6 +277,15 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( /// Timeout is passed through to TestExecutor.ExecuteAsync, which applies it only to the test /// body — hooks and data source initialization run outside the timeout scope (fixes #4772). /// + /// + /// Ordering invariant: calls + /// _eventReceiverOrchestrator.RegisterReceivers (which invokes + /// EnsureEventReceiversCached) before entering this method. The hot-path + /// receiver getters used by InvokeTest{Skipped,End}EventReceiversAsync therefore + /// skip their own guards and rely on that upstream call to have populated the caches — + /// the skip-path branch below is safe because it cannot be reached without that + /// prerequisite having run. + /// private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { // Check if this test should be skipped before creating the class instance. @@ -287,6 +296,7 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C { _stateManager.MarkSkipped(test, test.Context.SkipReason!); + // Receiver caches are populated upstream (see on this method). await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false); await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false); From 515072508a52a335c106277ae9b15bc485d12ace Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:17:04 +0100 Subject: [PATCH 3/4] perf(engine): simplify event-receiver cache handling (#5719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over-engineered Item 1 from #5730 is simplified: - Kept the EventReceiversBuilt bool on TestContext so the fast-path gate is a single TFM-agnostic field check (unifies the NET / non-NET #if branches that previously poked different cache-array fields). - Dropped the hoisted EnsureEventReceiversCached call in TestCoordinator.ExecuteTestLifecycleAsync and the Debug.Assert ordering-guards on GetTestStartReceivers / GetTestEndReceivers / GetTestSkippedReceivers — the 4x fast-path savings (<1ms on a 10K suite) did not justify the invariant-maintenance burden. - Added [MethodImpl(AggressiveInlining)] to EnsureEventReceiversCached so the cache-hit check collapses to a field load + branch at every call site, recovering equivalent perf with none of the baggage. - Reverted EnsureEventReceiversCached back to private static; removed the load-bearing , skip-path comment, and stale using. Items #2 (ObjectGraphDiscoverer GetOrAdd caching) and #3 (scheduler semaphore removal) from #5730 are unchanged. --- .../Extensions/TestContextExtensions.cs | 27 +++++++++---------- .../Services/TestExecution/TestCoordinator.cs | 19 ++----------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index 3bb9a9c35c..4b6110f01d 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Runtime.CompilerServices; using TUnit.Core; using TUnit.Core.Enums; using TUnit.Core.Interfaces; @@ -19,7 +19,10 @@ internal static class TestContextExtensions /// When this happens, eligible event objects may include the new instance (if it implements /// event receiver interfaces), so all caches must be invalidated and rebuilt. /// - public static void EnsureEventReceiversCached(this TestContext testContext) + // Inlined so the fast-path (cache-hit) check collapses to a field load + branch at every + // call site, eliminating call overhead for the 99%+ already-built case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void EnsureEventReceiversCached(TestContext testContext) { var currentClassInstance = testContext.Metadata.TestDetails.ClassInstance; @@ -151,7 +154,7 @@ private static T[] SortAndFilter(List? receivers) where T : class, IEventR public static IEnumerable GetEligibleEventObjects(this TestContext testContext) { // Use EnsureEventReceiversCached which builds eligible objects as part of cache initialization - testContext.EnsureEventReceiversCached(); + EnsureEventReceiversCached(testContext); return testContext.CachedEligibleEventObjects!; } @@ -259,13 +262,11 @@ private static int CountNonNullValues(IDictionary props) /// /// Gets pre-computed test start receivers (filtered, sorted, scoped-attribute filtered). - /// Assumes has been called by the lifecycle - /// coordinator — hot-path callers (per-test start/end/skipped) skip the guard. /// #if NET public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext, EventReceiverStage stage) { - Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard."); + EnsureEventReceiversCached(testContext); return stage == EventReceiverStage.Early ? testContext.CachedTestStartReceiversEarly! : testContext.CachedTestStartReceiversLate!; @@ -273,7 +274,7 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #else public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext) { - Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard."); + EnsureEventReceiversCached(testContext); return testContext.CachedTestStartReceivers!; } #endif @@ -284,7 +285,7 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t #if NET public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext, EventReceiverStage stage) { - Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard."); + EnsureEventReceiversCached(testContext); return stage == EventReceiverStage.Early ? testContext.CachedTestEndReceiversEarly! : testContext.CachedTestEndReceiversLate!; @@ -292,7 +293,7 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC #else public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext) { - Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard."); + EnsureEventReceiversCached(testContext); return testContext.CachedTestEndReceivers!; } #endif @@ -302,27 +303,25 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC /// public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext) { - Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestSkippedReceivers — caller is on the hot path and skips the guard."); + EnsureEventReceiversCached(testContext); return testContext.CachedTestSkippedReceivers!; } /// /// Gets pre-computed test discovery receivers (filtered, sorted, scoped-attribute filtered). - /// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand. /// public static ITestDiscoveryEventReceiver[] GetTestDiscoveryReceivers(this TestContext testContext) { - testContext.EnsureEventReceiversCached(); + EnsureEventReceiversCached(testContext); return testContext.CachedTestDiscoveryReceivers!; } /// /// Gets pre-computed test registered receivers (filtered, sorted, scoped-attribute filtered). - /// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand. /// public static ITestRegisteredEventReceiver[] GetTestRegisteredReceivers(this TestContext testContext) { - testContext.EnsureEventReceiversCached(); + EnsureEventReceiversCached(testContext); return testContext.CachedTestRegisteredReceivers!; } } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 16e5066bcb..0df9702a58 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -3,7 +3,6 @@ using TUnit.Core.Exceptions; using TUnit.Core.Logging; using TUnit.Core.Tracking; -using TUnit.Engine.Extensions; using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; @@ -277,15 +276,6 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( /// Timeout is passed through to TestExecutor.ExecuteAsync, which applies it only to the test /// body — hooks and data source initialization run outside the timeout scope (fixes #4772). /// - /// - /// Ordering invariant: calls - /// _eventReceiverOrchestrator.RegisterReceivers (which invokes - /// EnsureEventReceiversCached) before entering this method. The hot-path - /// receiver getters used by InvokeTest{Skipped,End}EventReceiversAsync therefore - /// skip their own guards and rely on that upstream call to have populated the caches — - /// the skip-path branch below is safe because it cannot be reached without that - /// prerequisite having run. - /// private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { // Check if this test should be skipped before creating the class instance. @@ -296,7 +286,6 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C { _stateManager.MarkSkipped(test, test.Context.SkipReason!); - // Receiver caches are populated upstream (see on this method). await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false); await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false); @@ -306,12 +295,8 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false); - // Rebuild the receiver cache now that ClassInstance is assigned — the initial build - // at RegisterReceivers time ran with the placeholder instance. EnsureEventReceiversCached - // detects the changed instance via its internal invalidation check. Hoisting the call - // here lets the five per-test receiver getters (GetTestStartReceivers/GetTestEndReceivers/...) - // skip their own guards on the hot path. - test.Context.EnsureEventReceiversCached(); + // Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed. + test.Context.CachedEligibleEventObjects = null; // Check if this test should be skipped (after creating instance). // This handles basic [Skip] attributes that use SkippedTestInstance as a sentinel, From 78bf4157bedcd5c9d2a4c13cafb54e000e41fb95 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:50:13 +0100 Subject: [PATCH 4/4] perf(engine): split EnsureEventReceiversCached into inlined gate + outlined builder (#5719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RyuJIT's inliner has a per-method IL size budget (typically ~100 bytes after accounting for CORINFO flags and other penalties). The previous EnsureEventReceiversCached was ~100+ bytes of IL once the single-pass categorization, invalidation check, and 5x SortAndFilter calls were included, so [MethodImpl(AggressiveInlining)] was silently ignored — every call site kept paying full call overhead even on the 99%+ cache-hit fast path. Refactor into the canonical fast/slow split: - EnsureEventReceiversCached: tiny wrapper (~10 bytes IL) that reads the current class instance, checks the cache-validity flag + a ReferenceEquals, returns on hit, otherwise tail-calls the builder. AggressiveInlining now reliably fires, so every call site collapses to a field-load + branch + return in the hot case. - BuildEventReceiverCaches: explicitly NoInlining so the JIT keeps one outlined copy of the ~100 bytes of build logic rather than duplicating it into every caller. currentClassInstance is threaded through from the gate to avoid a redundant field re-read. Also unify #if NET8_0_OR_GREATER -> #if NET in TestScheduler.cs and TestContextExtensions.cs (both already touched on this branch) to match the majority convention across TUnit.Engine (87 uses of #if NET vs 42 of #if NET8_0_OR_GREATER). Both are equivalent for the netstandard2.0;net8.0;net9.0;net10.0 target set. No behavior change: invalidation logic, eligible-objects walk, all 5 list builds, all 5 SortAndFilter calls, and the CachedClassInstance/EventReceiversBuilt commit order are preserved. --- .../Extensions/TestContextExtensions.cs | 15 +++++++++++++-- TUnit.Engine/Scheduling/TestScheduler.cs | 18 +++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index 4b6110f01d..51099495f0 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -19,8 +19,10 @@ internal static class TestContextExtensions /// When this happens, eligible event objects may include the new instance (if it implements /// event receiver interfaces), so all caches must be invalidated and rebuilt. /// - // Inlined so the fast-path (cache-hit) check collapses to a field load + branch at every - // call site, eliminating call overhead for the 99%+ already-built case. + // Fast-path gate: small enough (~10 bytes IL) for RyuJIT to reliably inline at every call + // site, collapsing the 99%+ cache-hit case into a field-load + branch + return. The heavy + // build work is outlined into BuildEventReceiverCaches so the JIT's inliner size budget + // never rejects this wrapper. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void EnsureEventReceiversCached(TestContext testContext) { @@ -33,6 +35,15 @@ private static void EnsureEventReceiversCached(TestContext testContext) return; } + BuildEventReceiverCaches(testContext, currentClassInstance); + } + + // Explicitly NoInlining so the JIT keeps a single outlined copy instead of duplicating + // ~100 bytes of IL into every caller. currentClassInstance is threaded through from the + // gate to avoid a redundant field re-read. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void BuildEventReceiverCaches(TestContext testContext, object? currentClassInstance) + { // Invalidate stale caches if class instance changed if (testContext.CachedClassInstance != null && !ReferenceEquals(testContext.CachedClassInstance, currentClassInstance)) diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 748e31c9e3..ea64e58369 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -27,7 +27,7 @@ internal sealed class TestScheduler : ITestScheduler private readonly StaticPropertyHandler _staticPropertyHandler; private readonly IDynamicTestQueue _dynamicTestQueue; private readonly Lazy _maxParallelism; -#if !NET8_0_OR_GREATER +#if !NET private readonly Lazy _maxParallelismSemaphore; #endif @@ -59,7 +59,7 @@ public TestScheduler( _maxParallelism = new Lazy(() => GetMaxParallelism(logger, commandLineOptions)); -#if !NET8_0_OR_GREATER +#if !NET // The .NET 8+ path uses Parallel.ForEachAsync which caps concurrency via // ParallelOptions.MaxDegreeOfParallelism — the semaphore is only needed // for the netstandard2.0 fallback path. @@ -68,7 +68,7 @@ public TestScheduler( #endif } - #if NET8_0_OR_GREATER + #if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif public async Task ScheduleAndExecuteAsync( @@ -161,7 +161,7 @@ public async Task ScheduleAndExecuteAsync( return true; } - #if NET8_0_OR_GREATER + #if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif private async Task ExecuteGroupedTestsAsync( @@ -237,7 +237,7 @@ private async Task ExecuteGroupedTestsAsync( await dynamicTestProcessingTask.ConfigureAwait(false); } - #if NET8_0_OR_GREATER + #if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationToken) @@ -310,7 +310,7 @@ private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationTo } } -#if NET8_0_OR_GREATER +#if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif private Task ExecuteTestsAsync( @@ -320,14 +320,14 @@ private Task ExecuteTestsAsync( // All paths run through the shared limiter so the DOP cap is unified in // GetMaxParallelism. "Unlimited" is resolved to the default cap // (ProcessorCount * 4) there rather than being a separate code path. -#if NET8_0_OR_GREATER +#if NET return ExecuteWithGlobalLimitAsync(tests, cancellationToken); #else return ExecuteWithGlobalLimitAsync(tests, _maxParallelismSemaphore.Value, cancellationToken); #endif } -#if NET8_0_OR_GREATER +#if NET [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] #endif private async Task ExecuteSequentiallyAsync( @@ -341,7 +341,7 @@ private async Task ExecuteSequentiallyAsync( } } -#if NET8_0_OR_GREATER +#if NET private Task ExecuteWithGlobalLimitAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken)