Skip to content

Commit baef6f3

Browse files
authored
perf: reduce allocations in source-gen test building hot paths (#6228)
* 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<T> + ToArray (both former branches were identical) - DataSourceHelpers.InvokeIfFunc: fetch GetType() once * refactor: deduplicate attribute lookup and tuple copy loops - Add internal FirstOfType<T> 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
1 parent 53a249f commit baef6f3

4 files changed

Lines changed: 55 additions & 61 deletions

File tree

TUnit.Core/Helpers/DataSourceHelpers.cs

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ public static class DataSourceHelpers
2323
return null;
2424
}
2525

26-
if(value.GetType().IsGenericType && value.GetType().GetGenericTypeDefinition() == typeof(Func<>))
26+
var type = value.GetType();
27+
if(type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Func<>))
2728
{
2829
return ((Delegate)value).DynamicInvoke();
2930
}
@@ -74,6 +75,18 @@ public static T InvokeIfFunc<T>(object? value)
7475
return [() => Task.FromResult<object?>(value)];
7576
}
7677

78+
#if NET5_0_OR_GREATER || NETCOREAPP3_0_OR_GREATER
79+
private static object?[] CopyTupleElements(ITuple tuple, int count)
80+
{
81+
var result = new object?[count];
82+
for (var i = 0; i < count; i++)
83+
{
84+
result[i] = tuple[i];
85+
}
86+
return result;
87+
}
88+
#endif
89+
7790
/// <summary>
7891
/// AOT-compatible tuple unwrapping that handles common tuple types without reflection
7992
/// </summary>
@@ -88,13 +101,7 @@ public static T InvokeIfFunc<T>(object? value)
88101
// Try to use ITuple interface first for any ValueTuple type (available in .NET Core 3.0+)
89102
if (value is ITuple tuple)
90103
{
91-
var length = tuple.Length;
92-
var result = new object?[length];
93-
for (var i = 0; i < length; i++)
94-
{
95-
result[i] = tuple[i];
96-
}
97-
return result;
104+
return CopyTupleElements(tuple, tuple.Length);
98105
}
99106
#endif
100107

@@ -426,30 +433,8 @@ public static T InvokeIfFunc<T>(object? value)
426433
// Try to use ITuple interface first for any ValueTuple type
427434
if (value is ITuple tuple)
428435
{
429-
var result = new List<object?>();
430-
var typeIndex = 0;
431-
432-
for (var i = 0; i < tuple.Length && typeIndex < expectedTypes.Length; i++)
433-
{
434-
var element = tuple[i];
435-
var expectedType = expectedTypes[typeIndex];
436-
437-
// Check if the expected type is a tuple type
438-
if (TupleHelper.IsTupleType(expectedType) && IsTuple(element))
439-
{
440-
// Keep nested tuple as-is
441-
result.Add(element);
442-
typeIndex++;
443-
}
444-
else
445-
{
446-
// Add element normally
447-
result.Add(element);
448-
typeIndex++;
449-
}
450-
}
451-
452-
return result.ToArray();
436+
// Elements are copied 1:1, capped at the expected parameter count
437+
return CopyTupleElements(tuple, Math.Min(tuple.Length, expectedTypes.Length));
453438
}
454439
#endif
455440

TUnit.Engine/Building/TestBuilder.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
173173
TestBuilderContext.Current = testBuilderContext;
174174

175175
// Check for ClassConstructor attribute and set it early if present (reuse already created attributes)
176-
ClassConstructorAttribute? classConstructorAttribute = null;
177-
foreach (var attr in attributes)
178-
{
179-
if (attr is ClassConstructorAttribute cca)
180-
{
181-
classConstructorAttribute = cca;
182-
break;
183-
}
184-
}
176+
var classConstructorAttribute = attributes.FirstOfType<ClassConstructorAttribute>();
185177
if (classConstructorAttribute != null)
186178
{
187179
testBuilderContext.ClassConstructor = (IClassConstructor)Activator.CreateInstance(classConstructorAttribute.ClassConstructorType)!;
@@ -1605,10 +1597,7 @@ public async IAsyncEnumerable<AbstractExecutableTest> BuildTestsStreamingAsync(
16051597
// Check for ClassConstructor attribute and set it early if present
16061598
// Look for any attribute that inherits from ClassConstructorAttribute
16071599
// This handles both ClassConstructorAttribute and ClassConstructorAttribute<T>
1608-
var classConstructorAttribute = attributes
1609-
.Where(a => a is ClassConstructorAttribute)
1610-
.Cast<ClassConstructorAttribute>()
1611-
.FirstOrDefault();
1600+
var classConstructorAttribute = attributes.FirstOfType<ClassConstructorAttribute>();
16121601

16131602
if (classConstructorAttribute != null)
16141603
{

TUnit.Engine/Building/TestBuilderPipeline.cs

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using TUnit.Core.Interfaces;
88
using TUnit.Core.Services;
99
using TUnit.Engine.Building.Interfaces;
10+
using TUnit.Engine.Extensions;
1011
using TUnit.Engine.Services;
1112
using TUnit.Engine.Utilities;
1213

@@ -56,15 +57,7 @@ private TestBuilderContext CreateTestBuilderContext(TestMetadata metadata)
5657

5758
// Look for any attribute that inherits from ClassConstructorAttribute
5859
// This handles both ClassConstructorAttribute and ClassConstructorAttribute<T>
59-
ClassConstructorAttribute? classConstructorAttribute = null;
60-
foreach (var attr in attributes)
61-
{
62-
if (attr is ClassConstructorAttribute cca)
63-
{
64-
classConstructorAttribute = cca;
65-
break;
66-
}
67-
}
60+
var classConstructorAttribute = attributes.FirstOfType<ClassConstructorAttribute>();
6861

6962
if (classConstructorAttribute != null)
7063
{
@@ -210,6 +203,11 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
210203
// Get dynamic test metadata for DisplayName support
211204
var dynamicTestMetadata = metadata as IDynamicTestMetadata;
212205

206+
// Hoist loop-invariant state so repeat iterations share one attribute dictionary and factory delegate
207+
var attributes = metadata.GetOrCreateAttributes();
208+
var attributesByType = attributes.ToAttributeDictionary();
209+
Func<Task<object>> instanceFactory = () => Task.FromResult(metadata.InstanceFactory(Type.EmptyTypes, []));
210+
213211
return await Enumerable.Range(0, repeatCount + 1)
214212
.SelectAsync(async repeatIndex =>
215213
{
@@ -218,7 +216,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
218216
var dynamicTestIndex = dynamicTestMetadata?.DynamicTestIndex ?? 0;
219217
var testData = new TestBuilder.TestData
220218
{
221-
TestClassInstanceFactory = () => Task.FromResult(metadata.InstanceFactory(Type.EmptyTypes, [])),
219+
TestClassInstanceFactory = instanceFactory,
222220
ClassDataSourceAttributeIndex = 0,
223221
ClassDataLoopIndex = 0,
224222
ClassData = [],
@@ -239,9 +237,6 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
239237
? $"{baseDisplayName} (Repeat {repeatIndex + 1}/{repeatCount + 1})"
240238
: baseDisplayName;
241239

242-
// Get attributes first
243-
var attributes = metadata.GetOrCreateAttributes();
244-
245240
// Create TestDetails for dynamic tests
246241
var testDetails = new TestDetails(attributes)
247242
{
@@ -259,7 +254,7 @@ private async Task<AbstractExecutableTest[]> GenerateDynamicTests(TestMetadata m
259254
TestEndColumnNumber = metadata.EndColumnNumber,
260255
ReturnType = typeof(Task),
261256
MethodMetadata = metadata.MethodMetadata,
262-
AttributesByType = attributes.ToAttributeDictionary()
257+
AttributesByType = attributesByType
263258
};
264259

265260
var testBuilderContext = CreateTestBuilderContext(metadata);
@@ -339,6 +334,10 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
339334
// Get attributes for test details
340335
var attributes = resolvedMetadata.GetOrCreateAttributes();
341336

337+
// Hoist loop-invariant state so repeat iterations share one attribute dictionary and factory delegate
338+
var attributesByType = attributes.ToAttributeDictionary();
339+
Func<Task<object>> instanceFactory = () => Task.FromResult(resolvedMetadata.InstanceFactory(Type.EmptyTypes, []));
340+
342341
// Dynamic tests need to honor attributes like RepeatCount, RetryCount, etc.
343342
// We'll create multiple test instances based on RepeatCount
344343
// Use DynamicTestIndex from the metadata to ensure unique test IDs for multiple dynamic tests
@@ -348,7 +347,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
348347
// Create a simple TestData for ID generation
349348
var testData = new TestBuilder.TestData
350349
{
351-
TestClassInstanceFactory = () => Task.FromResult(resolvedMetadata.InstanceFactory(Type.EmptyTypes, [])),
350+
TestClassInstanceFactory = instanceFactory,
352351
ClassDataSourceAttributeIndex = 0,
353352
ClassDataLoopIndex = 0,
354353
ClassData = [],
@@ -385,7 +384,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> BuildTestsFromSingleMetad
385384
TestEndColumnNumber = resolvedMetadata.EndColumnNumber,
386385
ReturnType = typeof(Task),
387386
MethodMetadata = resolvedMetadata.MethodMetadata,
388-
AttributesByType = attributes.ToAttributeDictionary()
387+
AttributesByType = attributesByType
389388
};
390389

391390
var context = _contextProvider.CreateTestContext(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace TUnit.Engine.Extensions;
2+
3+
internal static class AttributeArrayExtensions
4+
{
5+
/// <summary>
6+
/// Returns the first attribute assignable to <typeparamref name="T"/>, or null.
7+
/// Allocation-free alternative to OfType&lt;T&gt;().FirstOrDefault() for hot paths.
8+
/// </summary>
9+
public static T? FirstOfType<T>(this Attribute[] attributes) where T : Attribute
10+
{
11+
foreach (var attribute in attributes)
12+
{
13+
if (attribute is T typed)
14+
{
15+
return typed;
16+
}
17+
}
18+
19+
return null;
20+
}
21+
}

0 commit comments

Comments
 (0)