Skip to content

Commit b9007c6

Browse files
authored
perf: optimize test execution by partitioning tests (#3676)t
* perf: optimize test execution by partitioning tests with and without parallel limiters * fix: add ConfigureAwait(false) to asynchronous calls for improved performance
1 parent a114b1f commit b9007c6

File tree

5 files changed

+256
-74
lines changed

5 files changed

+256
-74
lines changed

TUnit.Engine/Scheduling/TestRunner.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,13 @@ internal TestRunner(
3939
private readonly ThreadSafeDictionary<string, Task> _executingTests = new();
4040
private Exception? _firstFailFastException;
4141

42-
#if NET6_0_OR_GREATER
43-
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
44-
#endif
4542
public async Task ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
4643
{
4744
// Prevent double execution with a simple lock
4845
var executionTask = _executingTests.GetOrAdd(test.TestId, _ => ExecuteTestInternalAsync(test, cancellationToken));
4946
await executionTask.ConfigureAwait(false);
5047
}
5148

52-
#if NET6_0_OR_GREATER
53-
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
54-
#endif
5549
private async Task ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
5650
{
5751
try

TUnit.Engine/Scheduling/TestScheduler.cs

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -367,45 +367,38 @@ private async Task ExecuteSequentiallyAsync(
367367
}
368368
}
369369

370-
#if NET6_0_OR_GREATER
371-
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
372-
#endif
373370
private async Task ExecuteWithGlobalLimitAsync(
374371
AbstractExecutableTest[] tests,
375372
CancellationToken cancellationToken)
376373
{
377374
#if NET6_0_OR_GREATER
378-
// Use Parallel.ForEachAsync with explicit MaxDegreeOfParallelism
379-
// This eliminates unbounded Task.Run calls and leverages work-stealing for efficiency
380-
await Parallel.ForEachAsync(
381-
tests,
382-
new ParallelOptions
375+
// PERFORMANCE OPTIMIZATION: Partition tests by whether they have parallel limiters
376+
// Tests without limiters can run with unlimited parallelism (avoiding global semaphore overhead)
377+
var testsWithLimiters = new List<AbstractExecutableTest>();
378+
var testsWithoutLimiters = new List<AbstractExecutableTest>();
379+
380+
foreach (var test in tests)
381+
{
382+
if (test.Context.ParallelLimiter != null)
383383
{
384-
MaxDegreeOfParallelism = _maxParallelism,
385-
CancellationToken = cancellationToken
386-
},
387-
async (test, ct) =>
384+
testsWithLimiters.Add(test);
385+
}
386+
else
388387
{
389-
SemaphoreSlim? parallelLimiterSemaphore = null;
388+
testsWithoutLimiters.Add(test);
389+
}
390+
}
390391

391-
// Acquire parallel limiter semaphore if needed
392-
if (test.Context.ParallelLimiter != null)
393-
{
394-
parallelLimiterSemaphore = _parallelLimitLockProvider.GetLock(test.Context.ParallelLimiter);
395-
await parallelLimiterSemaphore.WaitAsync(ct).ConfigureAwait(false);
396-
}
392+
// Execute both groups concurrently
393+
var limitedTask = testsWithLimiters.Count > 0
394+
? ExecuteWithLimitAsync(testsWithLimiters, cancellationToken)
395+
: Task.CompletedTask;
397396

398-
try
399-
{
400-
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct);
401-
await test.ExecutionTask.ConfigureAwait(false);
402-
}
403-
finally
404-
{
405-
parallelLimiterSemaphore?.Release();
406-
}
407-
}
408-
).ConfigureAwait(false);
397+
var unlimitedTask = testsWithoutLimiters.Count > 0
398+
? ExecuteUnlimitedAsync(testsWithoutLimiters, cancellationToken)
399+
: Task.CompletedTask;
400+
401+
await Task.WhenAll(limitedTask, unlimitedTask).ConfigureAwait(false);
409402
#else
410403
// Fallback for netstandard2.0: Manual bounded concurrency using existing semaphore
411404
var tasks = new Task[tests.Length];
@@ -445,6 +438,62 @@ await Parallel.ForEachAsync(
445438
#endif
446439
}
447440

441+
#if NET6_0_OR_GREATER
442+
private async Task ExecuteWithLimitAsync(
443+
List<AbstractExecutableTest> tests,
444+
CancellationToken cancellationToken)
445+
{
446+
// Execute tests with parallel limiters using the global limit
447+
await Parallel.ForEachAsync(
448+
tests,
449+
new ParallelOptions
450+
{
451+
MaxDegreeOfParallelism = _maxParallelism,
452+
CancellationToken = cancellationToken
453+
},
454+
async (test, ct) =>
455+
{
456+
var parallelLimiterSemaphore = _parallelLimitLockProvider.GetLock(test.Context.ParallelLimiter!);
457+
await parallelLimiterSemaphore.WaitAsync(ct).ConfigureAwait(false);
458+
459+
try
460+
{
461+
#pragma warning disable IL2026 // ExecuteTestAsync uses reflection, but caller (ExecuteWithGlobalLimitAsync) is already marked with RequiresUnreferencedCode
462+
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct);
463+
#pragma warning restore IL2026
464+
await test.ExecutionTask.ConfigureAwait(false);
465+
}
466+
finally
467+
{
468+
parallelLimiterSemaphore.Release();
469+
}
470+
}
471+
).ConfigureAwait(false);
472+
}
473+
474+
private async Task ExecuteUnlimitedAsync(
475+
List<AbstractExecutableTest> tests,
476+
CancellationToken cancellationToken)
477+
{
478+
// Execute tests without limiters with unlimited parallelism (no global semaphore overhead)
479+
await Parallel.ForEachAsync(
480+
tests,
481+
new ParallelOptions
482+
{
483+
CancellationToken = cancellationToken
484+
// No MaxDegreeOfParallelism = unlimited parallelism
485+
},
486+
async (test, ct) =>
487+
{
488+
#pragma warning disable IL2026 // ExecuteTestAsync uses reflection, but caller (ExecuteWithGlobalLimitAsync) is already marked with RequiresUnreferencedCode
489+
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct);
490+
#pragma warning restore IL2026
491+
await test.ExecutionTask.ConfigureAwait(false);
492+
}
493+
).ConfigureAwait(false);
494+
}
495+
#endif
496+
448497
private async Task WaitForTasksWithFailFastHandling(IEnumerable<Task> tasks, CancellationToken cancellationToken)
449498
{
450499
try

TUnit.Engine/Services/TestExecution/TestCoordinator.cs

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ private async Task ExecuteTestInternalAsync(AbstractExecutableTest test, Cancell
6060
{
6161
try
6262
{
63-
await _stateManager.MarkRunningAsync(test);
64-
await _messageBus.InProgress(test.Context);
63+
await _stateManager.MarkRunningAsync(test).ConfigureAwait(false);
64+
await _messageBus.InProgress(test.Context).ConfigureAwait(false);
6565

6666
_contextRestorer.RestoreContext(test);
6767

@@ -90,7 +90,7 @@ private async Task ExecuteTestInternalAsync(AbstractExecutableTest test, Cancell
9090
}
9191

9292
// Ensure TestSession hooks run before creating test instances
93-
await _testExecutor.EnsureTestSessionHooksExecutedAsync();
93+
await _testExecutor.EnsureTestSessionHooksExecutedAsync().ConfigureAwait(false);
9494

9595
// Execute test with retry logic - each retry gets a fresh instance
9696
// Timeout is applied per retry attempt, not across all retries
@@ -106,7 +106,7 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () =>
106106
await TimeoutHelper.ExecuteWithTimeoutAsync(
107107
async ct =>
108108
{
109-
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync();
109+
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
110110

111111
// Invalidate cached eligible event objects since ClassInstance changed
112112
test.Context.CachedEligibleEventObjects = null;
@@ -115,20 +115,20 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
115115
if (test.Context.Metadata.TestDetails.ClassInstance is SkippedTestInstance ||
116116
!string.IsNullOrEmpty(test.Context.SkipReason))
117117
{
118-
await _stateManager.MarkSkippedAsync(test, test.Context.SkipReason ?? "Test was skipped");
118+
await _stateManager.MarkSkippedAsync(test, test.Context.SkipReason ?? "Test was skipped").ConfigureAwait(false);
119119

120-
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, ct);
120+
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, ct).ConfigureAwait(false);
121121

122-
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, ct);
122+
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, ct).ConfigureAwait(false);
123123

124124
return;
125125
}
126126

127127
try
128128
{
129-
await _testInitializer.InitializeTest(test, ct);
129+
await _testInitializer.InitializeTest(test, ct).ConfigureAwait(false);
130130
test.Context.RestoreExecutionContext();
131-
await _testExecutor.ExecuteAsync(test, ct);
131+
await _testExecutor.ExecuteAsync(test, ct).ConfigureAwait(false);
132132
}
133133
finally
134134
{
@@ -140,59 +140,59 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
140140
{
141141
try
142142
{
143-
await invocation.InvokeAsync(test.Context, test.Context);
143+
await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false);
144144
}
145145
catch (Exception disposeEx)
146146
{
147-
await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}");
147+
await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false);
148148
}
149149
}
150150
}
151151

152152
try
153153
{
154-
await TestExecutor.DisposeTestInstance(test);
154+
await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false);
155155
}
156156
catch (Exception disposeEx)
157157
{
158-
await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}");
158+
await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false);
159159
}
160160
}
161161
},
162162
testTimeout,
163163
cancellationToken,
164-
timeoutMessage);
165-
});
164+
timeoutMessage).ConfigureAwait(false);
165+
}).ConfigureAwait(false);
166166

167-
await _stateManager.MarkCompletedAsync(test);
167+
await _stateManager.MarkCompletedAsync(test).ConfigureAwait(false);
168168

169169
}
170170
catch (SkipTestException ex)
171171
{
172172
test.Context.SkipReason = ex.Message;
173-
await _stateManager.MarkSkippedAsync(test, ex.Message);
173+
await _stateManager.MarkSkippedAsync(test, ex.Message).ConfigureAwait(false);
174174

175-
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken);
175+
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
176176
}
177177
catch (Exception ex)
178178
{
179-
await _stateManager.MarkFailedAsync(test, ex);
179+
await _stateManager.MarkFailedAsync(test, ex).ConfigureAwait(false);
180180
}
181181
finally
182182
{
183183
var cleanupExceptions = new List<Exception>();
184184

185-
await _objectTracker.UntrackObjects(test.Context, cleanupExceptions);
185+
await _objectTracker.UntrackObjects(test.Context, cleanupExceptions).ConfigureAwait(false);
186186

187187
var testClass = test.Metadata.TestClassType;
188188
var testAssembly = testClass.Assembly;
189-
var hookExceptions = await _testExecutor.ExecuteAfterClassAssemblyHooks(test, testClass, testAssembly, CancellationToken.None);
189+
var hookExceptions = await _testExecutor.ExecuteAfterClassAssemblyHooks(test, testClass, testAssembly, CancellationToken.None).ConfigureAwait(false);
190190

191191
if (hookExceptions.Count > 0)
192192
{
193193
foreach (var ex in hookExceptions)
194194
{
195-
await _logger.LogErrorAsync($"Error executing After hooks for {test.TestId}: {ex}");
195+
await _logger.LogErrorAsync($"Error executing After hooks for {test.TestId}: {ex}").ConfigureAwait(false);
196196
}
197197
cleanupExceptions.AddRange(hookExceptions);
198198
}
@@ -203,11 +203,11 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
203203
await _eventReceiverOrchestrator.InvokeLastTestInClassEventReceiversAsync(
204204
test.Context,
205205
test.Context.ClassContext,
206-
CancellationToken.None);
206+
CancellationToken.None).ConfigureAwait(false);
207207
}
208208
catch (Exception ex)
209209
{
210-
await _logger.LogErrorAsync($"Error in last test in class event receiver for {test.TestId}: {ex}");
210+
await _logger.LogErrorAsync($"Error in last test in class event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
211211
cleanupExceptions.Add(ex);
212212
}
213213

@@ -216,11 +216,11 @@ await _eventReceiverOrchestrator.InvokeLastTestInClassEventReceiversAsync(
216216
await _eventReceiverOrchestrator.InvokeLastTestInAssemblyEventReceiversAsync(
217217
test.Context,
218218
test.Context.ClassContext.AssemblyContext,
219-
CancellationToken.None);
219+
CancellationToken.None).ConfigureAwait(false);
220220
}
221221
catch (Exception ex)
222222
{
223-
await _logger.LogErrorAsync($"Error in last test in assembly event receiver for {test.TestId}: {ex}");
223+
await _logger.LogErrorAsync($"Error in last test in assembly event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
224224
cleanupExceptions.Add(ex);
225225
}
226226

@@ -229,11 +229,11 @@ await _eventReceiverOrchestrator.InvokeLastTestInAssemblyEventReceiversAsync(
229229
await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
230230
test.Context,
231231
test.Context.ClassContext.AssemblyContext.TestSessionContext,
232-
CancellationToken.None);
232+
CancellationToken.None).ConfigureAwait(false);
233233
}
234234
catch (Exception ex)
235235
{
236-
await _logger.LogErrorAsync($"Error in last test in session event receiver for {test.TestId}: {ex}");
236+
await _logger.LogErrorAsync($"Error in last test in session event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
237237
cleanupExceptions.Add(ex);
238238
}
239239

@@ -244,7 +244,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
244244
? cleanupExceptions[0]
245245
: new AggregateException("One or more errors occurred during test cleanup", cleanupExceptions);
246246

247-
await _stateManager.MarkFailedAsync(test, aggregatedException);
247+
await _stateManager.MarkFailedAsync(test, aggregatedException).ConfigureAwait(false);
248248
}
249249

250250
switch (test.State)
@@ -254,20 +254,20 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
254254
case TestState.Queued:
255255
case TestState.Running:
256256
// This shouldn't happen
257-
await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault());
257+
await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
258258
break;
259259
case TestState.Passed:
260-
await _messageBus.Passed(test.Context, test.StartTime.GetValueOrDefault());
260+
await _messageBus.Passed(test.Context, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
261261
break;
262262
case TestState.Timeout:
263263
case TestState.Failed:
264-
await _messageBus.Failed(test.Context, test.Context.Execution.Result?.Exception!, test.StartTime.GetValueOrDefault());
264+
await _messageBus.Failed(test.Context, test.Context.Execution.Result?.Exception!, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
265265
break;
266266
case TestState.Skipped:
267-
await _messageBus.Skipped(test.Context, test.Context.SkipReason ?? "Skipped");
267+
await _messageBus.Skipped(test.Context, test.Context.SkipReason ?? "Skipped").ConfigureAwait(false);
268268
break;
269269
case TestState.Cancelled:
270-
await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault());
270+
await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault()).ConfigureAwait(false);
271271
break;
272272
default:
273273
throw new ArgumentOutOfRangeException();

0 commit comments

Comments
 (0)