From 8f18e682209cbd3f6aa41393224eaace103fe228 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:14:35 +0000 Subject: [PATCH 1/3] perf: reduce async state machine overhead in test execution pipeline - Elide async/await in forwarding methods (TestExecutor discovery hooks, TestMethodInvoker, RetryHelper.ShouldRetry/ApplyBackoffDelay) to avoid unnecessary state machine allocations - Replace async lambda wrappers with direct ValueTask construction in TestExecutor.ExecuteTestAsync and TestCoordinator retry path - Change RetryHelper.ExecuteWithRetry to accept Func instead of Func to avoid ValueTask-to-Task conversion on the retry path - Cache DateTimeOffset.UtcNow in TestStateManager.MarkFailed, TestBuilder, and TestBuilderPipeline to avoid redundant clock reads - Replace Stopwatch instance with Stopwatch.GetTimestamp() / Stopwatch.GetElapsedTime() in DiscoveryCircuitBreaker on .NET 8+ (falls back to Stopwatch on netstandard2.0) --- TUnit.Engine/Building/TestBuilder.cs | 5 +-- TUnit.Engine/Building/TestBuilderPipeline.cs | 11 +++--- .../Services/DiscoveryCircuitBreaker.cs | 36 ++++++++++++++----- .../Services/TestExecution/RetryHelper.cs | 18 +++++----- .../Services/TestExecution/TestCoordinator.cs | 6 ++-- .../TestExecution/TestMethodInvoker.cs | 15 ++++---- .../TestExecution/TestStateManager.cs | 3 +- TUnit.Engine/TestExecutor.cs | 10 +++--- 8 files changed, 63 insertions(+), 41 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 4a470817ee0..7e81d243aa3 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1109,6 +1109,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada var testDetails = CreateFailedTestDetails(metadata, testId); var context = CreateFailedTestContext(metadata, testDetails); + var now = DateTimeOffset.UtcNow; return new FailedExecutableTest(exception) { @@ -1121,8 +1122,8 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 9cd01fd205b..14a2c6dcb26 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -473,6 +473,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada context.Metadata.TestDetails = testDetails; + var now = DateTimeOffset.UtcNow; return new FailedExecutableTest(exception) { @@ -485,8 +486,8 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, @@ -525,6 +526,8 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet context.Metadata.TestDetails = testDetails; + var now = DateTimeOffset.UtcNow; + return new FailedExecutableTest(exception) { TestId = testId, @@ -536,8 +539,8 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet Result = new TestResult { State = TestState.Failed, - Start = DateTimeOffset.UtcNow, - End = DateTimeOffset.UtcNow, + Start = now, + End = now, Duration = TimeSpan.Zero, Exception = exception, ComputerName = EnvironmentHelper.MachineName, diff --git a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs index 133e9c4a84f..d7976fff036 100644 --- a/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs +++ b/TUnit.Engine/Services/DiscoveryCircuitBreaker.cs @@ -11,7 +11,11 @@ public sealed class DiscoveryCircuitBreaker { private readonly long _maxMemoryBytes; private readonly TimeSpan _maxGenerationTime; +#if NET + private readonly long _startTimestamp; +#else private readonly Stopwatch _stopwatch; +#endif private readonly long _initialMemoryUsage; /// @@ -23,8 +27,12 @@ public DiscoveryCircuitBreaker( { _maxMemoryBytes = (long)(GetAvailableMemoryBytes() * maxMemoryPercentage); _maxGenerationTime = maxGenerationTime ?? EngineDefaults.MaxGenerationTime; +#if NET + _startTimestamp = Stopwatch.GetTimestamp(); +#else _stopwatch = Stopwatch.StartNew(); - +#endif + // Track initial memory to calculate growth GC.Collect(); GC.WaitForPendingFinalizers(); @@ -32,6 +40,15 @@ public DiscoveryCircuitBreaker( _initialMemoryUsage = GC.GetTotalMemory(false); } + private TimeSpan GetElapsed() + { +#if NET + return Stopwatch.GetElapsedTime(_startTimestamp); +#else + return _stopwatch.Elapsed; +#endif + } + /// /// Checks if the circuit breaker should trip based on current resource usage /// @@ -39,14 +56,14 @@ public DiscoveryCircuitBreaker( /// True if generation should continue, false if circuit breaker trips public bool ShouldContinue(int currentTestCount = 0) { - if (_stopwatch.Elapsed > _maxGenerationTime) + if (GetElapsed() > _maxGenerationTime) { return false; } var currentMemoryUsage = GC.GetTotalMemory(false); var memoryGrowth = currentMemoryUsage - _initialMemoryUsage; - + if (memoryGrowth > _maxMemoryBytes) { return false; @@ -62,14 +79,15 @@ internal DiscoveryResourceUsage GetResourceUsage() { var currentMemoryUsage = GC.GetTotalMemory(false); var memoryGrowth = currentMemoryUsage - _initialMemoryUsage; - + var elapsed = GetElapsed(); + return new DiscoveryResourceUsage { - ElapsedTime = _stopwatch.Elapsed, + ElapsedTime = elapsed, MaxTime = _maxGenerationTime, MemoryGrowthBytes = memoryGrowth, MaxMemoryBytes = _maxMemoryBytes, - TimeUsagePercentage = _stopwatch.Elapsed.TotalMilliseconds / _maxGenerationTime.TotalMilliseconds, + TimeUsagePercentage = elapsed.TotalMilliseconds / _maxGenerationTime.TotalMilliseconds, MemoryUsagePercentage = (double)memoryGrowth / _maxMemoryBytes }; } @@ -110,7 +128,9 @@ private static long GetAvailableMemoryBytes() public void Dispose() { - _stopwatch?.Stop(); +#if !NET + _stopwatch.Stop(); +#endif } } @@ -125,4 +145,4 @@ internal record DiscoveryResourceUsage public long MaxMemoryBytes { get; init; } public double TimeUsagePercentage { get; init; } public double MemoryUsagePercentage { get; init; } -} \ No newline at end of file +} diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 7db0a93d735..9a12f775c24 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -4,7 +4,7 @@ namespace TUnit.Engine.Services.TestExecution; internal static class RetryHelper { - public static async Task ExecuteWithRetry(TestContext testContext, Func action) + public static async Task ExecuteWithRetry(TestContext testContext, Func action) { var maxRetries = testContext.Metadata.TestDetails.RetryLimit; @@ -57,29 +57,29 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func ac } } - private static async Task ShouldRetry(TestContext testContext, Exception ex, int attempt) + private static Task ShouldRetry(TestContext testContext, Exception ex, int attempt) { if (attempt >= testContext.Metadata.TestDetails.RetryLimit) { - return false; + return Task.FromResult(false); } if (testContext.RetryFunc == null) { // Default behavior: retry on any exception if within retry limit - return true; + return Task.FromResult(true); } - return await testContext.RetryFunc(testContext, ex, attempt + 1).ConfigureAwait(false); + return testContext.RetryFunc(testContext, ex, attempt + 1); } - private static async Task ApplyBackoffDelay(TestContext testContext, int attempt) + private static Task ApplyBackoffDelay(TestContext testContext, int attempt) { var backoffMs = testContext.Metadata.TestDetails.RetryBackoffMs; if (backoffMs <= 0) { - return; + return Task.CompletedTask; } var multiplier = testContext.Metadata.TestDetails.RetryBackoffMultiplier; @@ -87,7 +87,9 @@ private static async Task ApplyBackoffDelay(TestContext testContext, int attempt if (delayMs > 0) { - await Task.Delay(delayMs, testContext.CancellationToken).ConfigureAwait(false); + return Task.Delay(delayMs, testContext.CancellationToken); } + + return Task.CompletedTask; } } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 70d0f0a5532..c7ea325f1bf 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -117,10 +117,8 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca // Slow path: use retry wrapper // Timeout is handled inside TestExecutor.ExecuteAsync, wrapping only the test body // (not hooks or data source initialization) — fixes #4772 - await RetryHelper.ExecuteWithRetry(test.Context, async () => - { - await ExecuteTestLifecycleAsync(test, cancellationToken).ConfigureAwait(false); - }).ConfigureAwait(false); + await RetryHelper.ExecuteWithRetry(test.Context, + () => ExecuteTestLifecycleAsync(test, cancellationToken)).ConfigureAwait(false); } _stateManager.MarkCompleted(test); diff --git a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs index 451dddb20b5..c54e8938680 100644 --- a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs +++ b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs @@ -8,18 +8,15 @@ namespace TUnit.Engine.Services.TestExecution; /// internal sealed class TestMethodInvoker { - public async Task InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + public Task InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (test.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor) { - await testExecutor.ExecuteTest(test.Context, - async () => await test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken)) - .ConfigureAwait(false); - } - else - { - await test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken) - .ConfigureAwait(false); + return testExecutor.ExecuteTest(test.Context, + () => new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken))) + .AsTask(); } + + return test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken); } } \ No newline at end of file diff --git a/TUnit.Engine/Services/TestExecution/TestStateManager.cs b/TUnit.Engine/Services/TestExecution/TestStateManager.cs index 232842109a4..e5e5f8d903d 100644 --- a/TUnit.Engine/Services/TestExecution/TestStateManager.cs +++ b/TUnit.Engine/Services/TestExecution/TestStateManager.cs @@ -44,8 +44,9 @@ public void MarkFailed(AbstractExecutableTest test, Exception exception) } else { + var now = DateTimeOffset.UtcNow; test.State = TestState.Failed; - test.EndTime ??= DateTimeOffset.UtcNow; + test.EndTime ??= now; test.Result = new TestResult { State = TestState.Failed, diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 125ad8e6769..42ef211f67d 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -323,7 +323,7 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl if (executableTest.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor) { await testExecutor.ExecuteTest(executableTest.Context, - async () => await executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken)).ConfigureAwait(false); + () => new ValueTask(executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken))).ConfigureAwait(false); } else { @@ -375,17 +375,17 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio /// /// Execute discovery-level before hooks. /// - public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - await _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).AsTask(); } /// /// Execute discovery-level after hooks. /// - public async Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - await _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).AsTask(); } /// From 437d1f2e131c643d786192de411d93566f0c450f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:22:27 +0000 Subject: [PATCH 2/3] fix: revert no-op caching and cache Task.FromResult in ShouldRetry --- TUnit.Engine/Services/TestExecution/RetryHelper.cs | 7 +++++-- TUnit.Engine/Services/TestExecution/TestStateManager.cs | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 9a12f775c24..7352cba6cb1 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -4,6 +4,9 @@ namespace TUnit.Engine.Services.TestExecution; internal static class RetryHelper { + private static readonly Task s_shouldRetryTrue = Task.FromResult(true); + private static readonly Task s_shouldRetryFalse = Task.FromResult(false); + public static async Task ExecuteWithRetry(TestContext testContext, Func action) { var maxRetries = testContext.Metadata.TestDetails.RetryLimit; @@ -61,13 +64,13 @@ private static Task ShouldRetry(TestContext testContext, Exception ex, int { if (attempt >= testContext.Metadata.TestDetails.RetryLimit) { - return Task.FromResult(false); + return s_shouldRetryFalse; } if (testContext.RetryFunc == null) { // Default behavior: retry on any exception if within retry limit - return Task.FromResult(true); + return s_shouldRetryTrue; } return testContext.RetryFunc(testContext, ex, attempt + 1); diff --git a/TUnit.Engine/Services/TestExecution/TestStateManager.cs b/TUnit.Engine/Services/TestExecution/TestStateManager.cs index e5e5f8d903d..232842109a4 100644 --- a/TUnit.Engine/Services/TestExecution/TestStateManager.cs +++ b/TUnit.Engine/Services/TestExecution/TestStateManager.cs @@ -44,9 +44,8 @@ public void MarkFailed(AbstractExecutableTest test, Exception exception) } else { - var now = DateTimeOffset.UtcNow; test.State = TestState.Failed; - test.EndTime ??= now; + test.EndTime ??= DateTimeOffset.UtcNow; test.Result = new TestResult { State = TestState.Failed, From 74e63ecc7b337afe209fee1653386e08cbb7752c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:43:14 +0000 Subject: [PATCH 3/3] perf: eliminate unnecessary Task/ValueTask round-trips --- .../Services/TestExecution/TestMethodInvoker.cs | 7 +++---- TUnit.Engine/TestExecutor.cs | 16 ++++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs index c54e8938680..9e7a77e197c 100644 --- a/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs +++ b/TUnit.Engine/Services/TestExecution/TestMethodInvoker.cs @@ -8,15 +8,14 @@ namespace TUnit.Engine.Services.TestExecution; /// internal sealed class TestMethodInvoker { - public Task InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + public ValueTask InvokeTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { if (test.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor) { return testExecutor.ExecuteTest(test.Context, - () => new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken))) - .AsTask(); + () => new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken))); } - return test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken); + return new ValueTask(test.InvokeTestAsync(test.Context.Metadata.TestDetails.ClassInstance, cancellationToken)); } } \ No newline at end of file diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 42ef211f67d..4f513d36aa9 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -59,7 +59,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask( // Register After Session hook to run on cancellation (guarantees cleanup) _afterHookPairTracker.RegisterAfterTestSessionHook( cancellationToken, - () => new ValueTask>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None).AsTask())); + () => _hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None)); } /// @@ -95,7 +95,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask( _afterHookPairTracker.RegisterAfterAssemblyHook( testAssembly, cancellationToken, - (assembly) => new ValueTask>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None).AsTask())); + (assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None)); await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync( executableTest.Context, @@ -351,7 +351,7 @@ internal async Task> ExecuteAfterClassAssemblyHooks(AbstractExec // Use AfterHookPairTracker to prevent double execution if already triggered by cancellation var assemblyExceptions = await _afterHookPairTracker.GetOrCreateAfterAssemblyTask( testAssembly, - (assembly) => new ValueTask>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken).AsTask())).ConfigureAwait(false); + (assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken)).ConfigureAwait(false); exceptions.AddRange(assemblyExceptions); } @@ -367,7 +367,7 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio { // Use AfterHookPairTracker to prevent double execution if already triggered by cancellation var exceptions = await _afterHookPairTracker.GetOrCreateAfterTestSessionTask( - () => new ValueTask>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken).AsTask())).ConfigureAwait(false); + () => _hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken)).ConfigureAwait(false); return exceptions; } @@ -375,17 +375,17 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio /// /// Execute discovery-level before hooks. /// - public Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public ValueTask ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).AsTask(); + return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken); } /// /// Execute discovery-level after hooks. /// - public Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public ValueTask ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) { - return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).AsTask(); + return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken); } ///