From a7515c2547c496b30c26615d73c5e88f71c809e8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 23:28:14 +0100 Subject: [PATCH 1/2] perf(core): de-LINQ data-source expansion Replace allocation-heavy LINQ in the hot data-source expansion paths with explicit loops. Row content and order are identical in both source-gen and reflection modes. - Matrix/Combined Cartesian product: replace the `Aggregate(seed, (acc,e)=>acc.SelectMany(x=>e.Select(x.Append)))` chains with an odometer-style product loop that materializes `object?[]` rows directly (last dimension varies fastest, matching the previous ordering exactly). Also removes the downstream `row.ToArray()` per factory. - Matrix GetExclusions: LINQ Where/Cast/Select/ToArray -> single foreach with a null-initialized list (no list alloc when there are no exclusions). The per-row `exclusions.Any(...)` is also unrolled to a foreach to drop the per-row closure. - DataGeneratorMetadataCreator: O(N*M) FirstOrDefault property scan -> a name->metadata Dictionary (StringComparer.Ordinal) built once for O(1) lookups. - MethodDataSource.IsAsyncEnumerable: cache the GetInterfaces() probe per Type in a ConcurrentDictionary instead of re-walking interfaces on every call. Validated (net10.0, source-gen + reflection): MatrixTests 275/275, CombinedDataSource 204/204, MixedMatrixTests 864/864, MatrixExclusion 24/24. TUnit.Core builds clean across netstandard2.0;net8.0;net9.0;net10.0 (0 warnings). Closes #6104 --- .../TestData/CombinedDataSourcesAttribute.cs | 54 +++++++++-- .../TestData/MatrixDataSourceAttribute.cs | 89 ++++++++++++++++--- .../TestData/MethodDataSourceAttribute.cs | 27 +++++- TUnit.Core/DataGeneratorMetadataCreator.cs | 11 ++- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs index 1f13ed5ddf..28171aa5e4 100644 --- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs +++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs @@ -113,7 +113,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat // Compute Cartesian product of all parameter value sets foreach (var combination in GetCartesianProduct(parameterValueSets)) { - yield return () => Task.FromResult(combination.ToArray())!; + yield return () => Task.FromResult(combination)!; } } @@ -221,12 +221,52 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat return values; } - private readonly IEnumerable> _seed = [[]]; - - private IEnumerable> GetCartesianProduct(IEnumerable> parameterValueSets) + private static IEnumerable GetCartesianProduct(IReadOnlyList> parameterValueSets) { - // Same algorithm as Matrix - compute Cartesian product - return parameterValueSets.Aggregate(_seed, (accumulator, values) - => accumulator.SelectMany(x => values.Select(x.Append))); + var dimensionCount = parameterValueSets.Count; + + // Any empty dimension makes the product empty (matches the previous + // Aggregate/SelectMany behaviour where SelectMany over [] yields nothing). + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + if (parameterValueSets[dimension].Count == 0) + { + yield break; + } + } + + // Odometer-style Cartesian product: the last dimension varies fastest, + // matching the previous Aggregate/SelectMany ordering exactly. + var indices = new int[dimensionCount]; + + while (true) + { + var row = new object?[dimensionCount]; + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + row[dimension] = parameterValueSets[dimension][indices[dimension]]; + } + + yield return row; + + // Advance the odometer from the rightmost dimension. + var position = dimensionCount - 1; + while (position >= 0) + { + if (++indices[position] < parameterValueSets[position].Count) + { + break; + } + + indices[position] = 0; + position--; + } + + // All dimensions wrapped back to zero: enumeration is complete. + if (position < 0) + { + yield break; + } + } } } diff --git a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs index 187cc03604..57e6c2df4a 100644 --- a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs @@ -64,11 +64,25 @@ public sealed class MatrixDataSourceAttribute : UntypedDataSourceGeneratorAttrib ? dataGeneratorMetadata.TestInformation.GetCustomAttributes() : classType.GetCustomAttributesSafe()); - foreach (var row in GetMatrixValues(parameterInformation.Select(p => GetAllArguments(dataGeneratorMetadata, p)))) + var valueSets = new IReadOnlyList[parameterInformation.Length]; + for (var i = 0; i < parameterInformation.Length; i++) { - var rowArray = row.ToArray(); + valueSets[i] = GetAllArguments(dataGeneratorMetadata, parameterInformation[i]); + } - if (exclusions.Any(e => IsExcluded(e, rowArray))) + foreach (var rowArray in GetMatrixValues(valueSets)) + { + var excluded = false; + foreach (var exclusion in exclusions) + { + if (IsExcluded(exclusion, rowArray)) + { + excluded = true; + break; + } + } + + if (excluded) { continue; } @@ -109,13 +123,19 @@ private bool IsExcluded(object?[] exclusion, object?[] rowArray) return true; } - private object?[][] GetExclusions(IEnumerable attributes) + private static object?[][] GetExclusions(IEnumerable attributes) { - return attributes - .Where(x => x is MatrixExclusionAttribute) - .Cast() - .Select(x => x.Objects) - .ToArray(); + List? exclusions = null; + + foreach (var attribute in attributes) + { + if (attribute is MatrixExclusionAttribute exclusionAttribute) + { + (exclusions ??= []).Add(exclusionAttribute.Objects); + } + } + + return exclusions is null ? [] : [.. exclusions]; } private IReadOnlyList GetAllArguments(DataGeneratorMetadata dataGeneratorMetadata, @@ -226,12 +246,53 @@ private bool IsExcluded(object?[] exclusion, object?[] rowArray) throw new ArgumentNullException($"No MatrixAttribute found for parameter '{sourceGeneratedParameterInformation.Name}' and the parameter type '{resolvedType.Name}' cannot be auto-generated. Only bool and enum types support auto-generation."); } - private readonly IEnumerable> _seed = [[]]; - - private IEnumerable> GetMatrixValues(IEnumerable> elements) + private static IEnumerable GetMatrixValues(IReadOnlyList> elements) { - return elements.Aggregate(_seed, (accumulator, enumerable) - => accumulator.SelectMany(x => enumerable.Select(x.Append))); + var dimensionCount = elements.Count; + + // Any empty dimension makes the product empty (matches the previous + // Aggregate/SelectMany behaviour where SelectMany over [] yields nothing). + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + if (elements[dimension].Count == 0) + { + yield break; + } + } + + // Odometer-style Cartesian product: the last dimension varies fastest, + // matching the previous Aggregate/SelectMany ordering exactly. + var indices = new int[dimensionCount]; + + while (true) + { + var row = new object?[dimensionCount]; + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + row[dimension] = elements[dimension][indices[dimension]]; + } + + yield return row; + + // Advance the odometer from the rightmost dimension. + var position = dimensionCount - 1; + while (position >= 0) + { + if (++indices[position] < elements[position].Count) + { + break; + } + + indices[position] = 0; + position--; + } + + // All dimensions wrapped back to zero: enumeration is complete. + if (position < 0) + { + yield break; + } + } } } diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 5254d656cf..42bd5ac3a8 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using TUnit.Core.Helpers; @@ -377,11 +378,29 @@ public MethodDataSourceAttribute( return types; } + private static readonly ConcurrentDictionary IsAsyncEnumerableCache = new(); + private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type) { - return type.GetInterfaces() - .Any(i => i.IsGenericType && - i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)); + // Cache per-type: GetInterfaces() allocates an array on every call and the + // result is invariant for a given Type. + if (IsAsyncEnumerableCache.TryGetValue(type, out var cached)) + { + return cached; + } + + var result = false; + foreach (var i in type.GetInterfaces()) + { + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + { + result = true; + break; + } + } + + IsAsyncEnumerableCache[type] = result; + return result; } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection usage is documented. AOT-safe path available via Factory property")] diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index 70d8767efc..d56b429668 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -41,10 +41,17 @@ public static DataGeneratorMetadata CreateDataGeneratorMetadata( var propertyMetadataList = new List(); var allProperties = testMetadata.MethodMetadata.Class.Properties; + // Build a name -> metadata lookup once (O(N)) so the per-data-source + // resolution below is O(1) instead of an O(N*M) FirstOrDefault scan. + var propertiesByName = new Dictionary(allProperties.Length, StringComparer.Ordinal); + foreach (var property in allProperties) + { + propertiesByName[property.Name] = property; + } + foreach (var propertyDataSource in testMetadata.PropertyDataSources) { - var matchingProperty = allProperties.FirstOrDefault(p => p.Name == propertyDataSource.PropertyName); - if (matchingProperty != null) + if (propertiesByName.TryGetValue(propertyDataSource.PropertyName, out var matchingProperty)) { propertyMetadataList.Add(matchingProperty); } From f5cef7cb61357e01cd31e67567360df1a82734d1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 23:43:59 +0100 Subject: [PATCH 2/2] perf: use GetOrAdd for IsAsyncEnumerableCache to avoid duplicate scans Replace the check-then-write pattern (TryGetValue + indexer assignment) with a single GetOrAdd call using a closure-free static factory, matching the idiom used elsewhere in the codebase. Under parallel test runs the old pattern let multiple threads all miss the cache and each run the expensive type.GetInterfaces() scan; GetOrAdd consolidates this. Interface-matching semantics (IAsyncEnumerable<>) are unchanged. --- .../TestData/MethodDataSourceAttribute.cs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 42bd5ac3a8..b5c5ba09de 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -380,27 +380,24 @@ public MethodDataSourceAttribute( private static readonly ConcurrentDictionary IsAsyncEnumerableCache = new(); + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "The 'type' parameter is annotated with DynamicallyAccessedMemberTypes.Interfaces, so its interfaces are preserved; the closure-free factory only forwards that same Type instance.")] private static bool IsAsyncEnumerable([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type) { // Cache per-type: GetInterfaces() allocates an array on every call and the - // result is invariant for a given Type. - if (IsAsyncEnumerableCache.TryGetValue(type, out var cached)) + // result is invariant for a given Type. GetOrAdd with a static (closure-free) + // factory ensures concurrent callers don't each run the interface scan. + return IsAsyncEnumerableCache.GetOrAdd(type, static t => { - return cached; - } - - var result = false; - foreach (var i in type.GetInterfaces()) - { - if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + foreach (var i in t.GetInterfaces()) { - result = true; - break; + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + { + return true; + } } - } - IsAsyncEnumerableCache[type] = result; - return result; + return false; + }); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection usage is documented. AOT-safe path available via Factory property")]