Skip to content

Conversation

@thomhurst
Copy link
Owner

Summary

  • Reduce allocations in test building hot path by making TestBuilderContext properties lazy-initialized
  • Remove pre-allocations in TestBuilder.cs that were eagerly creating StateBag and Events instances

Changes

TestBuilderContext.cs

Made three properties lazy-initialized:

Property Before After
DefinitionId = Guid.NewGuid().ToString() (eager) Lazy - only generated on first access
StateBag = new ConcurrentDictionary<>() (eager) Lazy - only allocated when accessed
Events = new TestContextEvents() (eager) Lazy - only allocated when accessed

TestBuilder.cs

Removed pre-allocations in 7 locations that were explicitly passing new ConcurrentDictionary<>() and new TestContextEvents() when creating contexts.

Why This Matters

Before these changes, for every test iteration during building:

  • new ConcurrentDictionary<string, object?>() - Very expensive due to internal lock arrays
  • new TestContextEvents() - 7 nullable event delegate slots
  • Guid.NewGuid().ToString() - GUID generation + string allocation

Now these allocations only happen when the properties are actually accessed, which for many tests during the build phase, may not happen at all.

Test plan

  • TUnit.Engine.Tests - 290 tests pass (270 succeeded, 20 skipped)
  • TUnit.PerformanceBenchmarks - 60 tests pass

🤖 Generated with Claude Code

thomhurst and others added 3 commits January 11, 2026 12:40
Add comprehensive design document for optimizing test discovery
through lazy materialization. Key improvements:

- Lightweight TestDescriptor struct for fast enumeration
- Two-phase discovery: filter first, materialize matching tests only
- Pre-computed filter hints at compile time
- Deferred data source evaluation

This architectural change targets ~20% improvement in single test
execution time by avoiding eager materialization of tests that
won't run due to filtering.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Reduce allocations in the test building hot path by making three
properties in TestBuilderContext lazy-initialized:

- DefinitionId: Guid.NewGuid().ToString() only generated on first access
- StateBag: ConcurrentDictionary only allocated when accessed
- Events: TestContextEvents only allocated when accessed

Also removed pre-allocations in TestBuilder.cs (7 locations) that were
eagerly creating StateBag and Events instances.

This reduces memory pressure during test discovery/building, especially
beneficial for large test suites.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@thomhurst
Copy link
Owner Author

Summary

This PR optimizes test building performance by making TestBuilderContext properties (DefinitionId, StateBag, Events) lazy-initialized to avoid unnecessary allocations.

Critical Issues

None found ✅

Suggestions

Consider thread-safety documentation

While the implementation is correct (AsyncLocal ensures per-context isolation), consider adding a comment explaining why the non-atomic ??= operator is safe for DefinitionId:

/// <summary>
/// Gets the unique definition ID for this context. Generated lazily on first access.
/// Thread-safe due to AsyncLocal isolation - each async context has its own instance.
/// </summary>
public string DefinitionId => _definitionId ??= Guid.NewGuid().ToString();

Record semantics consideration (informational)

The private backing fields don't participate in record equality, but this appears intentional since TestBuilderContext equality comparisons aren't used in the codebase. If record equality becomes important later, be aware that two contexts with the same property values but different lazy initialization states won't compare equal based on the backing fields.

Verdict

APPROVE - No critical issues

The implementation correctly:

  • Preserves StateBag sharing patterns (lines 443, 1727 with explicit assignment)
  • Maintains existing behavior (DefinitionId caching verified by UniqueBuilderContextsOnEnumerableDataGeneratorTests.cs:19-30)
  • Doesn't change public API surface (no snapshot updates needed)
  • Follows TUnit performance-first principles

The performance improvement is solid - avoiding ConcurrentDictionary allocations and GUID generation for tests that don't use these properties.

@thomhurst thomhurst merged commit 1b535a5 into main Jan 11, 2026
11 of 14 checks passed
@thomhurst thomhurst deleted the perf/lazy-testbuildercontext branch January 11, 2026 21:34
@TimothyMakkison
Copy link
Contributor

TimothyMakkison commented Jan 11, 2026

Nice, change. Looks like you've saved around 150MB 60MB. There may be other problem ConcurrentDictionary allocations as they are pretty common and large.

Could this use the field keyword?

Edit: I think I made a mistake calculating the memory saved mb

This was referenced Jan 12, 2026
This was referenced Jan 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants