From 519b2b17b299642c4e12cfc92fa11c4594e13591 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 21:12:44 +0100 Subject: [PATCH] perf: use FrozenSet/FrozenDictionary for read-only static lookups Convert three read-only static collections queried in hot per-test/per-assembly loops to FrozenSet/FrozenDictionary on net8+ for faster lookups: - ObjectGraphDiscoverer.SkipTypes (HashSet) - ReflectionTestDataCollector.ExcludedAssemblyNames (HashSet) - TupleFactory.TypedFactories (Dictionary>) Frozen collections are net8+ only, so usage is guarded with #if NET8_0_OR_GREATER; netstandard2.0 retains the existing HashSet/Dictionary. All four TFMs build clean. Closes #6047 --- TUnit.Core/Discovery/ObjectGraphDiscoverer.cs | 31 +++++++++++----- TUnit.Core/Helpers/TupleFactory.cs | 35 +++++++++++++------ .../Discovery/ReflectionTestDataCollector.cs | 20 +++++++++-- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index 24732c6b26..5f48b8ef0e 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -1,4 +1,7 @@ using System.Collections.Concurrent; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -48,16 +51,26 @@ internal sealed class ObjectGraphDiscoverer : IObjectGraphTracker // Reference equality comparer for object tracking (ignores Equals overrides) private static readonly Helpers.ReferenceEqualityComparer ReferenceComparer = Helpers.ReferenceEqualityComparer.Instance; - // Types to skip during discovery (primitives, strings, system types) + // Types to skip during discovery (primitives, strings, system types). + // Frozen on net8+ for faster lookups; this set is read-many during per-test traversal. +#if NET8_0_OR_GREATER + private static readonly System.Collections.Frozen.FrozenSet SkipTypes = +#else private static readonly HashSet SkipTypes = - [ - typeof(string), - typeof(decimal), - typeof(DateTime), - typeof(DateTimeOffset), - typeof(TimeSpan), - typeof(Guid) - ]; +#endif + new HashSet + { + typeof(string), + typeof(decimal), + typeof(DateTime), + typeof(DateTimeOffset), + typeof(TimeSpan), + typeof(Guid) + } +#if NET8_0_OR_GREATER + .ToFrozenSet() +#endif + ; // Thread-safe collection of discovery errors for diagnostics private static readonly ConcurrentBag DiscoveryErrors = []; diff --git a/TUnit.Core/Helpers/TupleFactory.cs b/TUnit.Core/Helpers/TupleFactory.cs index 88ed109309..6004bcf44a 100644 --- a/TUnit.Core/Helpers/TupleFactory.cs +++ b/TUnit.Core/Helpers/TupleFactory.cs @@ -7,28 +7,41 @@ namespace TUnit.Core.Helpers; /// public static class TupleFactory { - private static readonly Dictionary> TypedFactories = new(); - + // Read-only after the static initializer completes; TryGetValue is called per + // tuple-arg conversion. Frozen on net8+ for faster lookups. +#if NET8_0_OR_GREATER + private static readonly System.Collections.Frozen.FrozenDictionary> TypedFactories; +#else + private static readonly Dictionary> TypedFactories; +#endif + static TupleFactory() { // Register factories for common tuple types with object elements - RegisterFactory>((args) => + var factories = new Dictionary>(); + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1])); - RegisterFactory>((args) => + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1], args[2])); - RegisterFactory>((args) => + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1], args[2], args[3])); - RegisterFactory>((args) => + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1], args[2], args[3], args[4])); - RegisterFactory>((args) => + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1], args[2], args[3], args[4], args[5])); - RegisterFactory>((args) => + RegisterFactory>(factories, (args) => new ValueTuple(args[0], args[1], args[2], args[3], args[4], args[5], args[6])); + +#if NET8_0_OR_GREATER + TypedFactories = System.Collections.Frozen.FrozenDictionary.ToFrozenDictionary(factories); +#else + TypedFactories = factories; +#endif } - - private static void RegisterFactory(Func factory) where T : struct + + private static void RegisterFactory(Dictionary> factories, Func factory) where T : struct { - TypedFactories[typeof(T)] = args => factory(args); + factories[typeof(T)] = args => factory(args); } /// diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 5d599e3b6a..0af38cf6c7 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1,5 +1,8 @@ using System.Buffers; using System.Collections.Concurrent; +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -206,8 +209,17 @@ private static IEnumerable GetAllTestMethods([DynamicallyAccessedMem }); } + // Read-many during reflection discovery (one Contains per assembly). + // Frozen on net8+ for faster lookups. +#if NET8_0_OR_GREATER + private static readonly System.Collections.Frozen.FrozenSet ExcludedAssemblyNames = + new HashSet + { +#else private static readonly HashSet ExcludedAssemblyNames = - [ + new() + { +#endif "mscorlib", "System", "System.Core", @@ -269,7 +281,11 @@ private static IEnumerable GetAllTestMethods([DynamicallyAccessedMem "Shouldly", "NSubstitute", "Rhino.Mocks" - ]; + } +#if NET8_0_OR_GREATER + .ToFrozenSet() +#endif + ; private static bool ShouldScanAssembly(Assembly assembly) {