From d6283bfd572640e5088271377ed325271c651422 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:31:29 +0100 Subject: [PATCH 1/2] perf: reduce allocations in source-gen test building hot paths - TestBuilder streaming path: replace LINQ Where/Cast/FirstOrDefault attribute lookup with a foreach, matching the non-streaming path - TestBuilderPipeline: hoist ToAttributeDictionary() and the TestClassInstanceFactory delegate out of the repeat loops in both dynamic-test paths so iterations share one dictionary and closure - DataSourceHelpers.UnwrapTupleWithTypes: write into a pre-sized array instead of List + ToArray (both former branches were identical) - DataSourceHelpers.InvokeIfFunc: fetch GetType() once --- TUnit.Core/Helpers/DataSourceHelpers.cs | 29 ++++++-------------- TUnit.Engine/Building/TestBuilder.cs | 13 ++++++--- TUnit.Engine/Building/TestBuilderPipeline.cs | 20 +++++++++----- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index 2c11963bb9..cc79436c4f 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -23,7 +23,8 @@ public static class DataSourceHelpers return null; } - if(value.GetType().IsGenericType && value.GetType().GetGenericTypeDefinition() == typeof(Func<>)) + var type = value.GetType(); + if(type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Func<>)) { return ((Delegate)value).DynamicInvoke(); } @@ -426,30 +427,16 @@ public static T InvokeIfFunc(object? value) // Try to use ITuple interface first for any ValueTuple type if (value is ITuple tuple) { - var result = new List(); - var typeIndex = 0; + // Elements map 1:1 to expected parameters; nested tuples are kept as-is + var count = Math.Min(tuple.Length, expectedTypes.Length); + var result = new object?[count]; - for (var i = 0; i < tuple.Length && typeIndex < expectedTypes.Length; i++) + for (var i = 0; i < count; i++) { - var element = tuple[i]; - var expectedType = expectedTypes[typeIndex]; - - // Check if the expected type is a tuple type - if (TupleHelper.IsTupleType(expectedType) && IsTuple(element)) - { - // Keep nested tuple as-is - result.Add(element); - typeIndex++; - } - else - { - // Add element normally - result.Add(element); - typeIndex++; - } + result[i] = tuple[i]; } - return result.ToArray(); + return result; } #endif diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index b41cff79e4..ae8a22bb70 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1605,10 +1605,15 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( // Check for ClassConstructor attribute and set it early if present // Look for any attribute that inherits from ClassConstructorAttribute // This handles both ClassConstructorAttribute and ClassConstructorAttribute - var classConstructorAttribute = attributes - .Where(a => a is ClassConstructorAttribute) - .Cast() - .FirstOrDefault(); + ClassConstructorAttribute? classConstructorAttribute = null; + foreach (var attribute in attributes) + { + if (attribute is ClassConstructorAttribute cca) + { + classConstructorAttribute = cca; + break; + } + } if (classConstructorAttribute != null) { diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 1879ed045e..929fb30f5c 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -210,6 +210,11 @@ private async Task GenerateDynamicTests(TestMetadata m // Get dynamic test metadata for DisplayName support var dynamicTestMetadata = metadata as IDynamicTestMetadata; + // Hoist loop-invariant state so repeat iterations share one attribute dictionary and factory delegate + var attributes = metadata.GetOrCreateAttributes(); + var attributesByType = attributes.ToAttributeDictionary(); + Func> instanceFactory = () => Task.FromResult(metadata.InstanceFactory(Type.EmptyTypes, [])); + return await Enumerable.Range(0, repeatCount + 1) .SelectAsync(async repeatIndex => { @@ -218,7 +223,7 @@ private async Task GenerateDynamicTests(TestMetadata m var dynamicTestIndex = dynamicTestMetadata?.DynamicTestIndex ?? 0; var testData = new TestBuilder.TestData { - TestClassInstanceFactory = () => Task.FromResult(metadata.InstanceFactory(Type.EmptyTypes, [])), + TestClassInstanceFactory = instanceFactory, ClassDataSourceAttributeIndex = 0, ClassDataLoopIndex = 0, ClassData = [], @@ -239,9 +244,6 @@ private async Task GenerateDynamicTests(TestMetadata m ? $"{baseDisplayName} (Repeat {repeatIndex + 1}/{repeatCount + 1})" : baseDisplayName; - // Get attributes first - var attributes = metadata.GetOrCreateAttributes(); - // Create TestDetails for dynamic tests var testDetails = new TestDetails(attributes) { @@ -259,7 +261,7 @@ private async Task GenerateDynamicTests(TestMetadata m TestEndColumnNumber = metadata.EndColumnNumber, ReturnType = typeof(Task), MethodMetadata = metadata.MethodMetadata, - AttributesByType = attributes.ToAttributeDictionary() + AttributesByType = attributesByType }; var testBuilderContext = CreateTestBuilderContext(metadata); @@ -339,6 +341,10 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad // Get attributes for test details var attributes = resolvedMetadata.GetOrCreateAttributes(); + // Hoist loop-invariant state so repeat iterations share one attribute dictionary and factory delegate + var attributesByType = attributes.ToAttributeDictionary(); + Func> instanceFactory = () => Task.FromResult(resolvedMetadata.InstanceFactory(Type.EmptyTypes, [])); + // Dynamic tests need to honor attributes like RepeatCount, RetryCount, etc. // We'll create multiple test instances based on RepeatCount // Use DynamicTestIndex from the metadata to ensure unique test IDs for multiple dynamic tests @@ -348,7 +354,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad // Create a simple TestData for ID generation var testData = new TestBuilder.TestData { - TestClassInstanceFactory = () => Task.FromResult(resolvedMetadata.InstanceFactory(Type.EmptyTypes, [])), + TestClassInstanceFactory = instanceFactory, ClassDataSourceAttributeIndex = 0, ClassDataLoopIndex = 0, ClassData = [], @@ -385,7 +391,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad TestEndColumnNumber = resolvedMetadata.EndColumnNumber, ReturnType = typeof(Task), MethodMetadata = resolvedMetadata.MethodMetadata, - AttributesByType = attributes.ToAttributeDictionary() + AttributesByType = attributesByType }; var context = _contextProvider.CreateTestContext( From dd027b48fdeb522843c69e62876d5cbce9fb73b2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:52:29 +0100 Subject: [PATCH 2/2] refactor: deduplicate attribute lookup and tuple copy loops - Add internal FirstOfType extension and use it at the three identical first-ClassConstructorAttribute foreach sites - Share the ITuple element copy loop between UnwrapTupleAot and UnwrapTupleWithTypes via a private CopyTupleElements helper --- TUnit.Core/Helpers/DataSourceHelpers.cs | 32 +++++++++---------- TUnit.Engine/Building/TestBuilder.cs | 20 ++---------- TUnit.Engine/Building/TestBuilderPipeline.cs | 11 ++----- .../Extensions/AttributeArrayExtensions.cs | 21 ++++++++++++ 4 files changed, 40 insertions(+), 44 deletions(-) create mode 100644 TUnit.Engine/Extensions/AttributeArrayExtensions.cs diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index cc79436c4f..11ee4cf52d 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -75,6 +75,18 @@ public static T InvokeIfFunc(object? value) return [() => Task.FromResult(value)]; } +#if NET5_0_OR_GREATER || NETCOREAPP3_0_OR_GREATER + private static object?[] CopyTupleElements(ITuple tuple, int count) + { + var result = new object?[count]; + for (var i = 0; i < count; i++) + { + result[i] = tuple[i]; + } + return result; + } +#endif + /// /// AOT-compatible tuple unwrapping that handles common tuple types without reflection /// @@ -89,13 +101,7 @@ public static T InvokeIfFunc(object? value) // Try to use ITuple interface first for any ValueTuple type (available in .NET Core 3.0+) if (value is ITuple tuple) { - var length = tuple.Length; - var result = new object?[length]; - for (var i = 0; i < length; i++) - { - result[i] = tuple[i]; - } - return result; + return CopyTupleElements(tuple, tuple.Length); } #endif @@ -427,16 +433,8 @@ public static T InvokeIfFunc(object? value) // Try to use ITuple interface first for any ValueTuple type if (value is ITuple tuple) { - // Elements map 1:1 to expected parameters; nested tuples are kept as-is - var count = Math.Min(tuple.Length, expectedTypes.Length); - var result = new object?[count]; - - for (var i = 0; i < count; i++) - { - result[i] = tuple[i]; - } - - return result; + // Elements are copied 1:1, capped at the expected parameter count + return CopyTupleElements(tuple, Math.Min(tuple.Length, expectedTypes.Length)); } #endif diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index ae8a22bb70..aa6f1e9496 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -173,15 +173,7 @@ public async Task> BuildTestsFromMetadataAsy TestBuilderContext.Current = testBuilderContext; // Check for ClassConstructor attribute and set it early if present (reuse already created attributes) - ClassConstructorAttribute? classConstructorAttribute = null; - foreach (var attr in attributes) - { - if (attr is ClassConstructorAttribute cca) - { - classConstructorAttribute = cca; - break; - } - } + var classConstructorAttribute = attributes.FirstOfType(); if (classConstructorAttribute != null) { testBuilderContext.ClassConstructor = (IClassConstructor)Activator.CreateInstance(classConstructorAttribute.ClassConstructorType)!; @@ -1605,15 +1597,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( // Check for ClassConstructor attribute and set it early if present // Look for any attribute that inherits from ClassConstructorAttribute // This handles both ClassConstructorAttribute and ClassConstructorAttribute - ClassConstructorAttribute? classConstructorAttribute = null; - foreach (var attribute in attributes) - { - if (attribute is ClassConstructorAttribute cca) - { - classConstructorAttribute = cca; - break; - } - } + var classConstructorAttribute = attributes.FirstOfType(); if (classConstructorAttribute != null) { diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 929fb30f5c..12d27b4ae6 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -7,6 +7,7 @@ using TUnit.Core.Interfaces; using TUnit.Core.Services; using TUnit.Engine.Building.Interfaces; +using TUnit.Engine.Extensions; using TUnit.Engine.Services; using TUnit.Engine.Utilities; @@ -56,15 +57,7 @@ private TestBuilderContext CreateTestBuilderContext(TestMetadata metadata) // Look for any attribute that inherits from ClassConstructorAttribute // This handles both ClassConstructorAttribute and ClassConstructorAttribute - ClassConstructorAttribute? classConstructorAttribute = null; - foreach (var attr in attributes) - { - if (attr is ClassConstructorAttribute cca) - { - classConstructorAttribute = cca; - break; - } - } + var classConstructorAttribute = attributes.FirstOfType(); if (classConstructorAttribute != null) { diff --git a/TUnit.Engine/Extensions/AttributeArrayExtensions.cs b/TUnit.Engine/Extensions/AttributeArrayExtensions.cs new file mode 100644 index 0000000000..ee4c73a0c9 --- /dev/null +++ b/TUnit.Engine/Extensions/AttributeArrayExtensions.cs @@ -0,0 +1,21 @@ +namespace TUnit.Engine.Extensions; + +internal static class AttributeArrayExtensions +{ + /// + /// Returns the first attribute assignable to , or null. + /// Allocation-free alternative to OfType<T>().FirstOrDefault() for hot paths. + /// + public static T? FirstOfType(this Attribute[] attributes) where T : Attribute + { + foreach (var attribute in attributes) + { + if (attribute is T typed) + { + return typed; + } + } + + return null; + } +}