From c713efed03a88cbbcf01157f43ae272b0cb20315 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:10:01 +0000 Subject: [PATCH] fix(engine): lower default maximum parallel tests to 2x processor count 4x only wins on pure async I/O with no downstream limit. With a shared resource it doubles test body time (toward [Timeout]) without adding throughput, and with blocking tests it is slower than 2x. Also fix docs that described the limit wrongly: the thread pool does not decide concurrency, TUNIT_MAX_PARALLEL_TESTS=0 is not unlimited, and the TUnitMaxConcurrency MSBuild property does not exist. Co-Authored-By: Claude --- docs/docs/execution/parallelism.md | 2 +- docs/docs/reference/environment-variables.md | 4 ++-- docs/docs/reference/programmatic-configuration.md | 2 +- src/TUnit.Core/README.md | 10 +++++----- src/TUnit.Core/Settings/ParallelismSettings.cs | 2 +- src/TUnit.Engine/Scheduling/MaxParallelismLimiter.cs | 8 +++++--- src/TUnit.Engine/Scheduling/TestScheduler.cs | 6 +++--- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/docs/execution/parallelism.md b/docs/docs/execution/parallelism.md index df5f03b97f8..ca93508bba7 100644 --- a/docs/docs/execution/parallelism.md +++ b/docs/docs/execution/parallelism.md @@ -13,7 +13,7 @@ Parallel execution is a major contributor to TUnit's speed advantage. See the [p ## Default Behavior -With no attributes, every test is eligible to run concurrently. The .NET thread pool determines how many execute at once. For most test suites this is the fastest option and requires no configuration. +With no attributes, every test is eligible to run concurrently. At most 2 x the processor count run at once. Change this limit with `--maximum-parallel-tests`, the `TUNIT_MAX_PARALLEL_TESTS` environment variable or `context.Settings.Parallelism.MaximumParallelTests`. For most test suites the default is the fastest option and requires no configuration. If your tests share a resource with limited capacity, such as a database connection pool, lower the limit for those tests with `[ParallelLimiter]`: extra concurrent tests add no throughput and only wait inside their test bodies, which counts toward `[Timeout]`. :::note Async tests and the thread pool TUnit uses the standard .NET thread pool and, like most .NET applications, is susceptible to thread pool starvation. Blocking calls or heavy CPU-bound `Task.Run` work can delay async continuations in other tests and cause timed waits to expire. diff --git a/docs/docs/reference/environment-variables.md b/docs/docs/reference/environment-variables.md index d4851e7c272..3d084879fc7 100644 --- a/docs/docs/reference/environment-variables.md +++ b/docs/docs/reference/environment-variables.md @@ -119,11 +119,11 @@ export JUNIT_XML_OUTPUT_PATH=/path/to/output.xml ### TUNIT_MAX_PARALLEL_TESTS -Sets the maximum number of tests that can run in parallel. +Sets the maximum number of tests that can run in parallel. The default is 2 x the processor count. ```bash export TUNIT_MAX_PARALLEL_TESTS=4 # Limit to 4 concurrent tests -export TUNIT_MAX_PARALLEL_TESTS=0 # Unlimited parallelism +export TUNIT_MAX_PARALLEL_TESTS=0 # Use the default limit (2 x CPU cores) ``` **Equivalent to:** `--maximum-parallel-tests` diff --git a/docs/docs/reference/programmatic-configuration.md b/docs/docs/reference/programmatic-configuration.md index 59d7059ee0a..3b04cdacd3a 100644 --- a/docs/docs/reference/programmatic-configuration.md +++ b/docs/docs/reference/programmatic-configuration.md @@ -63,7 +63,7 @@ On Linux and macOS with .NET 8 or later, SIGTERM (sent by `docker stop`, Kuberne | Property | Type | Default | Description | |---|---|---|---| -| `MaximumParallelTests` | `int?` | `null` (4 x CPU cores) | Maximum number of tests that can execute concurrently. Set to `null` to use the default heuristic. | +| `MaximumParallelTests` | `int?` | `null` (2 x CPU cores) | Maximum number of tests that can execute concurrently. Set to `null` to use the default heuristic. | ### `context.Settings.Display` diff --git a/src/TUnit.Core/README.md b/src/TUnit.Core/README.md index c010f17eb86..57dc25b36e9 100644 --- a/src/TUnit.Core/README.md +++ b/src/TUnit.Core/README.md @@ -131,11 +131,11 @@ public class DatabaseTests ## Configuration -### MSBuild Properties +### Maximum Parallel Tests -| Property | Default | Description | -|----------|---------|-------------| -| `TUnitMaxConcurrency` | `ProcessorCount` | Maximum parallel test execution | +| Setting | Default | Description | +|---------|---------|-------------| +| `--maximum-parallel-tests` / `TUNIT_MAX_PARALLEL_TESTS` | `ProcessorCount * 2` | Maximum number of tests that run at once | ## Advanced Features @@ -230,7 +230,7 @@ public void Test(int a, int b, int c) { } 1. **Expression Compilation**: TestBuilder uses compiled expressions for fast execution 2. **Share Data Sources**: Use `Shared = true` for expensive data -3. **Optimize Parallel Execution**: Adjust `TUnitMaxConcurrency` +3. **Optimize Parallel Execution**: Adjust `--maximum-parallel-tests`, or use `[ParallelLimiter]` for tests that share a limited resource 4. **Cache Test Data**: Use lazy initialization for expensive setup ## Contributing diff --git a/src/TUnit.Core/Settings/ParallelismSettings.cs b/src/TUnit.Core/Settings/ParallelismSettings.cs index c3e4834cd53..e8868a98a61 100644 --- a/src/TUnit.Core/Settings/ParallelismSettings.cs +++ b/src/TUnit.Core/Settings/ParallelismSettings.cs @@ -8,7 +8,7 @@ public sealed class ParallelismSettings internal ParallelismSettings() { } /// - /// Maximum number of tests to run in parallel. Default: null (= 4× CPU cores). + /// Maximum number of tests to run in parallel. Default: null (= 2× CPU cores). /// Precedence: --maximum-parallel-tests → TUNIT_MAX_PARALLEL_TESTS → TUnitSettings → built-in default. /// public int? MaximumParallelTests diff --git a/src/TUnit.Engine/Scheduling/MaxParallelismLimiter.cs b/src/TUnit.Engine/Scheduling/MaxParallelismLimiter.cs index 8524d429061..5cb5822fe94 100644 --- a/src/TUnit.Engine/Scheduling/MaxParallelismLimiter.cs +++ b/src/TUnit.Engine/Scheduling/MaxParallelismLimiter.cs @@ -17,10 +17,12 @@ internal sealed class MaxParallelismLimiter private readonly Lazy _semaphore; /// - /// The cap used when nothing configures one, and for "unlimited" (0). ProcessorCount * 4 - /// is empirically sized for async/IO-bound workloads. + /// The cap used when nothing configures one, and for "unlimited" (0). A higher cap gains + /// throughput only when nothing downstream limits concurrency. When a database pool, a + /// container or a blocking call is the bottleneck, extra concurrent tests add no throughput: + /// they wait inside their bodies, which inflates body time toward [Timeout]. /// - public static int DefaultMaxParallelism => Environment.ProcessorCount * 4; + public static int DefaultMaxParallelism => Environment.ProcessorCount * 2; public MaxParallelismLimiter(Func resolveMaxParallelism) { diff --git a/src/TUnit.Engine/Scheduling/TestScheduler.cs b/src/TUnit.Engine/Scheduling/TestScheduler.cs index f1d18191f35..25ba96acd4f 100644 --- a/src/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/src/TUnit.Engine/Scheduling/TestScheduler.cs @@ -596,7 +596,7 @@ 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. + // (ProcessorCount * 2) there rather than being a separate code path. return ExecuteWithGlobalLimitAsync(tests, _maxParallelismLimiter.Semaphore, cancellationToken); } @@ -757,9 +757,9 @@ internal static int GetMaxParallelism(ILogger logger, ICommandLineOptions comman return codeLimit; } - // Default: 4x CPU cores (empirically optimized for async/IO-bound workloads) + // Default: 2x CPU cores (see MaxParallelismLimiter.DefaultMaxParallelism) // Users can override via --maximum-parallel-tests or TUNIT_MAX_PARALLEL_TESTS - logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 4)"); + logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 2)"); return defaultLimit; } }