Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
perf(engine/core): reduce per-test allocations (#5688)
Addresses the six per-test allocation/reflection sources called out in
#5688 — individually <1% CPU, collectively driving gen0 churn and
steady-state latency.

1. TestCoordinator: guard Console.Out/Error.FlushAsync on the new
   Context.HasCapturedOutput check so the SynchronizedTextWriter lock
   is skipped for passing tests with no output. Include the console
   interceptor line buffers in the signal so partial writes still
   force a flush.
2. ObjectGraphDiscoverer.GetTypeHierarchy: cache results in a static
   ConcurrentDictionary<Type, HashSet<string>> — hierarchy is
   invariant per type and was allocating a fresh HashSet<string> per
   test. Cache is cleared alongside PropertyCacheManager in ClearCache.
3. TestMetadata<T>.CreateExecutableTestFactory: replace the closure-
   allocating lambda with a static method reference. Per-test state
   now lives on a new internal ExecutableTest<T> subclass instead of
   captured-variable closures, saving ~2 delegate allocations per
   test.
4. TestRegistry expression trees: bridge ValueTask/ValueTask<T> through
   a new ValueTaskBridge helper that checks IsCompletedSuccessfully
   before calling AsTask() — synchronously-completed values avoid the
   Task allocation entirely.
5. TestMetadata.TestSessionId: drop the eager Guid.NewGuid().ToString()
   default (every engine factory immediately overwrites it) and
   default to string.Empty.
6. PropertyInjectionMetadata.GetProperty: add a compile-time-generated
   Func<object, object?> getter so the engine hot path in
   ObjectGraphDiscoverer.TraverseSourceGeneratedProperties and
   PropertyInjectionPlan.GetPropertyValues no longer does a
   Type.GetProperty reflection lookup per test invocation. The source
   generator emits a direct strongly-typed lambda; hand-authored or
   older metadata falls back to a cached PropertyInfo.GetValue via
   GetOrCreateGetter so both source-gen and reflection modes benefit.
  • Loading branch information
thomhurst committed Apr 24, 2026
commit 658edd8dd99da8bfdd3c6c8eec2c32a929c96ef1
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,18 @@ private static void GeneratePropertyMetadata(StringBuilder sb, PropertyDataSourc
sb.AppendLine(" return dataSource;");
sb.AppendLine(" },");

// GetProperty delegate — direct strongly-typed read so the engine hot path
// doesn't fall back to Type.GetProperty reflection. Static instance-typed cast
// mirrors SetProperty and stays AOT-friendly.
if (prop.IsStatic)
{
sb.AppendLine($" GetProperty = static _ => {prop.ContainingTypeFullyQualified}.{prop.PropertyName},");
}
else
{
sb.AppendLine($" GetProperty = static instance => (({classTypeName})instance).{prop.PropertyName},");
}

// SetProperty delegate
sb.AppendLine(" SetProperty = (instance, value) =>");
sb.AppendLine(" {");
Expand Down
10 changes: 8 additions & 2 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,14 @@ public void AddAsyncLocalValues()

// Fast path for callers that need to know whether anything was ever captured —
// lets the result-building code skip the reader/writer lock acquisition entirely
// for the (very common) case of a passing test with no output.
internal virtual bool HasCapturedOutput => _outputWriter != null || _errorOutputWriter != null;
// for the (very common) case of a passing test with no output. Includes the
// console interceptor line buffers so partial writes (Console.Write without a
// newline) still force a flush at end-of-test.
internal virtual bool HasCapturedOutput =>
_outputWriter != null
|| _errorOutputWriter != null
|| _consoleStdOutLineBuffer != null
|| _consoleStdErrLineBuffer != null;

public DefaultLogger GetDefaultLogger()
{
Expand Down
45 changes: 26 additions & 19 deletions TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ void Recurse(object value, int depth)
public static void ClearCache()
{
PropertyCacheManager.ClearCache();
TypeHierarchyCache.Clear();
ClearDiscoveryErrors();
}

Expand Down Expand Up @@ -339,13 +340,11 @@ private static void TraverseSourceGeneratedProperties(
foreach (var metadata in sourceGeneratedProperties)
{
cancellationToken.ThrowIfCancellationRequested();
var property = metadata.ContainingType.GetProperty(metadata.PropertyName);
if (property == null || !property.CanRead)
{
continue;
}

var value = property.GetValue(obj);
// Use the compile-time-generated getter when the source generator emitted one,
// otherwise fall back to a cached reflection-based getter. Eliminates the
// per-test Type.GetProperty reflection lookup on the hot path.
var getter = metadata.GetOrCreateGetter();
var value = getter(obj);
if (value != null && tryAdd(value, currentDepth))
{
recurse(value, currentDepth + 1);
Expand Down Expand Up @@ -604,26 +603,34 @@ private static void CollectRootObjects(
}
}

/// <summary>
/// Cache of type hierarchy sets keyed by <see cref="Type"/>. Per-type hierarchy is invariant,
/// and each invocation otherwise allocates a fresh <see cref="HashSet{T}"/> of strings.
/// Populated on first access per type and read-only thereafter.
/// </summary>
private static readonly ConcurrentDictionary<Type, HashSet<string>> TypeHierarchyCache = new();

/// <summary>
/// Gets all types in the inheritance hierarchy from the given type up to (but not including) object.
/// </summary>
private static HashSet<string> GetTypeHierarchy(Type type)
{
var result = new HashSet<string>();
var currentType = type;

while (currentType != null && currentType != typeof(object))
=> TypeHierarchyCache.GetOrAdd(type, static t =>
{
if (currentType.FullName != null)
var result = new HashSet<string>();
var currentType = t;

while (currentType != null && currentType != typeof(object))
{
result.Add(currentType.FullName);
}
if (currentType.FullName != null)
{
result.Add(currentType.FullName);
}

currentType = currentType.BaseType;
}
currentType = currentType.BaseType;
}

return result;
}
return result;
});

/// <summary>
/// Determines if a cache key represents a direct property (belonging to test class hierarchy)
Expand Down
22 changes: 16 additions & 6 deletions TUnit.Core/ExecutableTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@ namespace TUnit.Core;
/// Replaces ExecutableTest<T> and DynamicExecutableTest with a single implementation.
/// All mode-specific logic is handled during delegate creation, not execution.
/// </summary>
public sealed class ExecutableTest : AbstractExecutableTest
public class ExecutableTest : AbstractExecutableTest
{
private readonly Func<TestContext, Task<object>> _createInstance;
private readonly Func<object, object?[], TestContext, CancellationToken, Task> _invokeTest;
// Null only inside typed subclasses that override CreateInstanceAsync/InvokeTestAsync and
// therefore never invoke the delegates. The parameterless constructor enforces that invariant.
private readonly Func<TestContext, Task<object>>? _createInstance;
private readonly Func<object, object?[], TestContext, CancellationToken, Task>? _invokeTest;

/// <summary>
/// Creates a UnifiedExecutableTest where all mode-specific behavior is encapsulated in the delegates.
/// Constructor for subclasses that supply their own invocation logic by overriding
/// <see cref="CreateInstanceAsync"/> and <see cref="InvokeTestAsync"/> directly.
/// </summary>
private protected ExecutableTest()
{
}

/// <summary>
/// Creates an ExecutableTest where mode-specific behavior is encapsulated in the delegates.
/// Both AOT and reflection modes provide delegates with identical signatures.
/// </summary>
/// <param name="createInstance">Delegate that creates the test instance with all necessary initialization</param>
Expand All @@ -26,11 +36,11 @@ public ExecutableTest(

public override async Task<object> CreateInstanceAsync()
{
return await _createInstance(Context);
return await _createInstance!(Context);
}

public override async Task InvokeTestAsync(object instance, CancellationToken cancellationToken)
{
await _invokeTest(instance, Arguments, Context, cancellationToken);
await _invokeTest!(instance, Arguments, Context, cancellationToken);
}
}
59 changes: 59 additions & 0 deletions TUnit.Core/ExecutableTest`1.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Helpers;

namespace TUnit.Core;

/// <summary>
/// Strongly typed <see cref="ExecutableTest"/> that stores the per-test delegates from
/// <see cref="TestMetadata{T}"/> as fields instead of capturing them in closures. This avoids the
/// two closure allocations (<c>createInstance</c> and <c>invokeTest</c>) that the generic factory
/// would otherwise allocate per test invocation — on a 1000-test run that is 2000 delegates saved.
/// </summary>
/// <typeparam name="T">The test class type.</typeparam>
internal sealed class ExecutableTest<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
T> : ExecutableTest where T : class
{
// Fields that the previous closures captured. ClassArguments and Arguments live on the
// base AbstractExecutableTest, so only the metadata reference plus the two ExecutableTestCreationContext
// values that aren't mirrored on the base need to be stored here.
private readonly TestMetadata<T> _metadata;
private readonly Func<Task<object>>? _testClassInstanceFactory;
private readonly Type[] _resolvedClassGenericArguments;

public ExecutableTest(
TestMetadata<T> metadata,
ExecutableTestCreationContext context)
{
_metadata = metadata;
_testClassInstanceFactory = context.TestClassInstanceFactory;
_resolvedClassGenericArguments = context.ResolvedClassGenericArguments;
}

public override async Task<object> CreateInstanceAsync()
{
if (_testClassInstanceFactory != null)
{
return await _testClassInstanceFactory();
}

var attributes = _metadata.GetOrCreateAttributes();
var instance = await ClassConstructorHelper.TryCreateInstanceWithClassConstructor(
attributes,
_metadata.TestClassType,
_metadata.TestSessionId,
Context);

if (instance != null)
{
return instance;
}

return _metadata.InstanceFactory!(_resolvedClassGenericArguments, ClassArguments);
}

public override async Task InvokeTestAsync(object instance, CancellationToken cancellationToken)
{
await _metadata.InvokeTypedTest!((T)instance, Arguments, cancellationToken).ConfigureAwait(false);
}
}
41 changes: 41 additions & 0 deletions TUnit.Core/Interfaces/SourceGenerator/PropertyInjectionMetadata.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace TUnit.Core.Interfaces.SourceGenerator;

Expand All @@ -7,6 +8,8 @@ namespace TUnit.Core.Interfaces.SourceGenerator;
/// </summary>
public sealed class PropertyInjectionMetadata
{
private Func<object, object?>? _getProperty;

/// <summary>
/// Gets the name of the property that needs injection.
/// </summary>
Expand Down Expand Up @@ -37,4 +40,42 @@ public sealed class PropertyInjectionMetadata
/// Handles type casting and property setting (including init-only properties).
/// </summary>
public required Action<object, object?> SetProperty { get; init; }

/// <summary>
/// Optional compile-time-generated getter that returns the property value from an instance.
/// When not supplied (e.g. older source-gen output or hand-authored metadata) the callers
/// fall back to a cached <see cref="PropertyInfo.GetValue(object)"/> call. Supplying a
/// direct delegate removes a <see cref="Type.GetProperty(string)"/> reflection lookup from
/// the per-test hot path in both source-gen and reflection modes.
/// </summary>
public Func<object, object?>? GetProperty
{
get => _getProperty;
init => _getProperty = value;
}

/// <summary>
/// Returns a delegate that reads the property value from a given instance. Uses the
/// source-generated <see cref="GetProperty"/> delegate when available, otherwise compiles
/// a reflection-based getter once and caches it on the metadata instance.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2075",
Justification = "ContainingType is annotated to preserve properties; reflection fallback is only used when source-gen did not emit a delegate.")]
internal Func<object, object?> GetOrCreateGetter()
{
if (_getProperty != null)
{
return _getProperty;
}

var property = ContainingType.GetProperty(PropertyName);
if (property is null || !property.CanRead)
{
// Memoize a stub that mirrors the behavior the previous reflection-based
// call sites expected (null when the property can't be read).
return _getProperty = static _ => null;
}

return _getProperty = property.GetValue;
}
}
9 changes: 4 additions & 5 deletions TUnit.Core/PropertyInjection/PropertyInjectionPlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,18 +224,17 @@ public Task ForEachPropertyParallelAsync(

/// <summary>
/// Gets property values from an instance, abstracting source-gen vs reflection.
/// Uses the source-generated getter delegate on <see cref="PropertyInjectionMetadata"/>
/// when available and caches a reflection-based getter otherwise — no per-call
/// <c>Type.GetProperty</c> lookup on the hot path.
/// </summary>
public IEnumerable<object?> GetPropertyValues(object instance)
{
if (SourceGeneratedProperties.Length > 0)
{
foreach (var metadata in SourceGeneratedProperties)
{
var property = metadata.ContainingType.GetProperty(metadata.PropertyName);
if (property?.CanRead == true)
{
yield return property.GetValue(instance);
}
yield return metadata.GetOrCreateGetter()(instance);
}
}
else if (ReflectionProperties.Length > 0)
Expand Down
4 changes: 3 additions & 1 deletion TUnit.Core/TestContext.Output.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ public override string GetErrorOutput()
internal string GetOutputError() => CombineOutputs(_buildTimeErrorOutput, base.GetErrorOutput());

internal override bool HasCapturedOutput =>
base.HasCapturedOutput || _buildTimeOutput != null || _buildTimeErrorOutput != null;
base.HasCapturedOutput
|| !string.IsNullOrEmpty(_buildTimeOutput)
|| !string.IsNullOrEmpty(_buildTimeErrorOutput);

private static string CombineOutputs(string? buildTimeOutput, string runtimeOutput)
{
Expand Down
6 changes: 4 additions & 2 deletions TUnit.Core/TestMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ internal Attribute[] GetOrCreateAttributes()
public PropertyInjectionData[] PropertyInjections { get; init; } = [];

/// <summary>
/// Test session ID used for data generation
/// Test session ID used for data generation. Callers must assign a real session id before
/// any data-generation code reads it; defaulting to an empty string avoids the per-instance
/// <see cref="Guid.NewGuid"/> allocation that every engine caller immediately overwrites.
/// </summary>
public string TestSessionId { get; set; } = Guid.NewGuid().ToString();
public string TestSessionId { get; set; } = string.Empty;

/// <summary>
/// The depth of inheritance for this test method.
Expand Down
Loading
Loading