From 54e08edb7d9fe67206a978598d2b9ee78719a6f9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:10:39 +0000 Subject: [PATCH] perf: reduce ConcurrentDictionary closure allocations in hot paths Add TryGetValue fast paths before GetOrAdd calls to avoid closure allocations and unnecessary ConcurrentDictionary contention on the hot path. Key changes: - TestExecutionGuard: avoid allocating TaskCompletionSource when a test is already executing by checking TryGetValue first - EventReceiverOrchestrator: add TryGetValue fast paths before GetOrAdd for first-test-in-session/assembly/class event tasks and for assembly/class test count counters, avoiding closure allocations from captured state - HookDelegateBuilder: mark GetCachedGenericTypeDefinition lambda as static to prevent implicit closure allocation --- .../Services/EventReceiverOrchestrator.cs | 43 +++++++++++++++++-- TUnit.Engine/Services/HookDelegateBuilder.cs | 2 +- .../TestExecution/TestExecutionGuard.cs | 8 +++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 8e03ef52ad..050d4e4ec1 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -317,6 +317,11 @@ public ValueTask InvokeFirstTestInSessionEventReceiversAsync( return default; } + if (_firstTestInSessionTasks.TryGetValue("session", out var existingTask)) + { + return new ValueTask(existingTask); + } + var task = _firstTestInSessionTasks.GetOrAdd("session", _ => InvokeFirstTestInSessionEventReceiversCoreAsync(context, sessionContext, cancellationToken)); return new ValueTask(task); @@ -347,6 +352,12 @@ public ValueTask InvokeFirstTestInAssemblyEventReceiversAsync( } var assemblyName = assemblyContext.Assembly.GetName().FullName ?? ""; + + if (_firstTestInAssemblyTasks.TryGetValue(assemblyName, out var existingTask)) + { + return new ValueTask(existingTask); + } + var task = _firstTestInAssemblyTasks.GetOrAdd(assemblyName, _ => InvokeFirstTestInAssemblyEventReceiversCoreAsync(context, assemblyContext, cancellationToken)); return new ValueTask(task); @@ -377,6 +388,12 @@ public ValueTask InvokeFirstTestInClassEventReceiversAsync( } var classType = classContext.ClassType; + + if (_firstTestInClassTasks.TryGetValue(classType, out var existingTask)) + { + return new ValueTask(existingTask); + } + var task = _firstTestInClassTasks.GetOrAdd(classType, _ => InvokeFirstTestInClassEventReceiversCoreAsync(context, classContext, cancellationToken)); return new ValueTask(task); @@ -448,7 +465,12 @@ public ValueTask InvokeLastTestInAssemblyEventReceiversAsync( var assemblyName = assemblyContext.Assembly.GetName().FullName ?? ""; - var assemblyCount = _assemblyTestCounts.GetOrAdd(assemblyName, static _ => new Counter()).Decrement(); + if (!_assemblyTestCounts.TryGetValue(assemblyName, out var assemblyCounter)) + { + assemblyCounter = _assemblyTestCounts.GetOrAdd(assemblyName, static _ => new Counter()); + } + + var assemblyCount = assemblyCounter.Decrement(); if (assemblyCount == 0) { @@ -491,7 +513,12 @@ public ValueTask InvokeLastTestInClassEventReceiversAsync( var classType = classContext.ClassType; - var classCount = _classTestCounts.GetOrAdd(classType, static _ => new Counter()).Decrement(); + if (!_classTestCounts.TryGetValue(classType, out var classCounter)) + { + classCounter = _classTestCounts.GetOrAdd(classType, static _ => new Counter()); + } + + var classCount = classCounter.Decrement(); if (classCount == 0) { @@ -536,13 +563,21 @@ public void InitializeTestCounts(IEnumerable allTestContexts) foreach (var group in contexts.GroupBy(c => c.ClassContext.AssemblyContext.Assembly.GetName().FullName)) { - var counter = _assemblyTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + if (!_assemblyTestCounts.TryGetValue(group.Key, out var counter)) + { + counter = _assemblyTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + } + counter.Add(group.Count()); } foreach (var group in contexts.GroupBy(c => c.ClassContext.ClassType)) { - var counter = _classTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + if (!_classTestCounts.TryGetValue(group.Key, out var counter)) + { + counter = _classTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + } + counter.Add(group.Count()); } } diff --git a/TUnit.Engine/Services/HookDelegateBuilder.cs b/TUnit.Engine/Services/HookDelegateBuilder.cs index e367e7a347..6f78f1a99a 100644 --- a/TUnit.Engine/Services/HookDelegateBuilder.cs +++ b/TUnit.Engine/Services/HookDelegateBuilder.cs @@ -50,7 +50,7 @@ public HookDelegateBuilder(EventReceiverOrchestrator eventReceiverOrchestrator, private static Type GetCachedGenericTypeDefinition(Type type) { - return _genericTypeDefinitionCache.GetOrAdd(type, t => t.GetGenericTypeDefinition()); + return _genericTypeDefinitionCache.GetOrAdd(type, static t => t.GetGenericTypeDefinition()); } public async ValueTask InitializeAsync() diff --git a/TUnit.Engine/Services/TestExecution/TestExecutionGuard.cs b/TUnit.Engine/Services/TestExecution/TestExecutionGuard.cs index 78126112b6..06ac0c688c 100644 --- a/TUnit.Engine/Services/TestExecution/TestExecutionGuard.cs +++ b/TUnit.Engine/Services/TestExecution/TestExecutionGuard.cs @@ -12,8 +12,14 @@ internal sealed class TestExecutionGuard public ValueTask TryStartExecutionAsync(string testId, Func executionFunc) { + // Fast path: check if test is already executing without allocating a TCS + if (_executingTests.TryGetValue(testId, out var existingTcs)) + { + return new ValueTask(WaitForExistingExecutionAsync(existingTcs)); + } + var tcs = new TaskCompletionSource(); - var existingTcs = _executingTests.GetOrAdd(testId, tcs); + existingTcs = _executingTests.GetOrAdd(testId, tcs); if (existingTcs != tcs) {