Repository navigation
perf(engine): reduce lock contention in scheduling and hook caches #5686
Description
Summary
CPU profile of ~1000-test run shows Monitor.Enter_Slowpath at 4.33% exclusive and Parallel.ForEachAsync/workqueue frames implying oversubscription. Two structural issues in the scheduling layer concentrate contention unnecessarily.
Baseline trace: .profile/TUnit.Profile-20260424-011500/.
Observations
1. Global _classLock in hook caches serialises unrelated classes
TUnit.Engine/Services/BeforeHookTaskCache.cs:54–66—lock (_classLock)guards all class types behind a single shared lockTUnit.Engine/Services/AfterHookPairTracker.cs:179–191— identical pattern
Every test entering TestExecutor.ExecuteAsync (line 107 for Before, line 413 for After) contends on the same monitor regardless of which class it belongs to. 1000 tests × N classes = heavy contention on a lock that shouldn't be shared across classes at all.
Fix: Replace with per-key Lazy<T> pattern using ConcurrentDictionary.GetOrAdd(type, static t => new Lazy<T>(...)). This pattern is already used correctly in BeforeHookTaskCache.GetOrCreateBeforeAssemblyTask — apply it to GetOrCreateBeforeClassTask and the AfterHookPairTracker equivalent.
2. Unbounded Parallel.ForEachAsync branch in TestScheduler
TUnit.Engine/Scheduling/TestScheduler.cs:322–344 — the else branch (when semaphore == null, i.e. no --maximum-parallel-tests set) calls Parallel.ForEachAsync without a MaxDegreeOfParallelism. The runtime default is Environment.ProcessorCount, but with 1000 tests we still spawn all continuations near-simultaneously, saturating the IOCP thread and producing the 48.5% GetQueuedCompletionStatus idle cliff.
Fix: Pass an explicit ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2 } (or a tunable constant) even on the unlimited path. The limited path (line 369+) already does this correctly — converge the two paths.
3. (Related, secondary) EventReceiverRegistry single global _lock
TUnit.Engine/Events/EventReceiverRegistry.cs:38–44 holds one monitor for all registrations. Write-contention at start of run. Consider splitting write/freeze phases or using ReaderWriterLockSlim. Lower priority than items 1 & 2.
Acceptance criteria
- Per-key locking in
BeforeHookTaskCache - Per-key locking in
AfterHookPairTracker - DOP cap on unbounded
Parallel.ForEachAsyncbranch inTestScheduler - Verify with
scripts/profile-tunit.ps1 -Top 50:Monitor.Enter_Slowpathexclusive < 1% (down from 4.33%)- Total wall-clock for
TUnit.Profilerun drops measurably (target 10–20%)
Files
TUnit.Engine/Services/BeforeHookTaskCache.csTUnit.Engine/Services/AfterHookPairTracker.csTUnit.Engine/Scheduling/TestScheduler.cs
Activity
perf(engine): reduce lock contention in scheduling and hook caches (#…