perf(engine): skip second object-graph walk in ObjectLifecycleService (#5718)#5748
perf(engine): skip second object-graph walk in ObjectLifecycleService (#5718)#5748thomhurst wants to merge 1 commit into
Conversation
…leService (#5718) InitializeTrackedObjectsAsync now relies on the sorted TrackedObjects list populated at registration and calls ObjectInitializer.InitializeAsync directly, eliminating the redundant DiscoverNestedObjectGraph walk. The class instance is still walked once at the end since it is not in TrackedObjects and may have non-data-source IAsyncInitializer properties set in the constructor or by BeforeClass hooks.
There was a problem hiding this comment.
Code Review: perf(engine): skip second object-graph walk in ObjectLifecycleService (#5718)
Summary
This PR makes a targeted performance improvement to ObjectLifecycleService.InitializeTrackedObjectsAsync, eliminating a redundant DiscoverNestedObjectGraph call per tracked object by relying on the fact that the depth-sorted TestContext.TrackedObjects list already contains all nested dependencies — discovered and sorted at registration time.
The change is small (12 additions / 4 deletions) but the reasoning requires careful understanding of the invariant being exploited. Overall, this is a sound optimization. A few observations below.
What the invariant actually is (and why it's safe)
The key insight is:
- At registration time,
ObjectTracker.TrackObjects→DiscoverAndTrackObjectspopulatesTestContext.TrackedObjectsas a depth-sortedSortedList<int, HashSet<object>>covering all data-source-injected objects and their nestedIAsyncInitializerdependencies. InitializeTrackedObjectsAsynciterates from deepest to shallowest, so by the time an object at depth n is initialized, all its dependencies at depth n+1... are already initialized.- Therefore, calling
InitializeNestedObjectsForExecutionAsync(which callsDiscoverNestedObjectGraphon each tracked object) is indeed redundant — it re-walks a graph that is already covered.
The PR correctly preserves the one place where the graph walk is still needed: the test class instance itself, which is not in TrackedObjects. Constructor-assigned or BeforeClass-hook-assigned IAsyncInitializer properties on the class instance are not known at registration time, so a fresh walk is required there.
Positive aspects
- Correct reasoning. The PR description precisely articulates why the optimization is safe and what the single remaining exception is. This is non-trivial to get right.
- Documentation quality. The new XML doc comment on
InitializeObjectWithSpanAsyncclearly states the precondition ("Callers are responsible for ensuring nested dependencies are already initialized") and explains howInitializeTrackedObjectsAsyncsatisfies it. This is important because the method can now be called on either path (tracked objects OR the class instance), and the caller must understand the difference. - Comprehensive test plan. The PR description lists a wide range of existing test scenarios verified, including the 4-level deep chain case that would immediately break if the invariant were violated.
Concerns and questions
1. Implicit contract on InitializeObjectWithSpanAsync is now fragile
The removal of InitializeNestedObjectsForExecutionAsync from inside InitializeObjectWithSpanAsync makes the method's correctness dependent on call-site ordering. The XML doc states the precondition, but there is nothing in the code to enforce it. If a future caller invokes InitializeObjectWithSpanAsync directly on an object whose nested graph has not been pre-walked, those nested initializers will be silently skipped.
A mitigation worth considering: rename the method to InitializeObjectOnlyWithSpanAsync or similar to make the "no nested walk" contract explicit in the name itself, rather than only in the doc comment. Alternatively, add a #if DEBUG assert that verifies the object's properties are already initialized (via ObjectInitializer.IsInitialized). Either approach would surface the broken precondition at the call site rather than producing a silent lifecycle failure at runtime.
2. The class instance walk still uses DiscoverNestedObjectGraph, which allocates
var classInstance = testContext.Metadata.TestDetails.ClassInstance;
await InitializeNestedObjectsForExecutionAsync(classInstance, cancellationToken);
await InitializeObjectWithSpanAsync(classInstance, testContext, cancellationToken);InitializeNestedObjectsForExecutionAsync calls DiscoverNestedObjectGraph, which allocates a new Dictionary<int, HashSet<object>> and HashSet<object> on every test. For tests where the class instance has no constructor/hook-assigned IAsyncInitializer properties (the vast majority), this is wasted allocation.
A lightweight guard would help:
// Only do the nested walk if the class has IAsyncInitializer properties not in TrackedObjects.
// For most tests (no such properties), skip the walk entirely.The ObjectInitializer.IsInitialized check could be used here, or a flag on the test context to indicate whether a nested walk is needed. This is a follow-up opportunity rather than a blocker, since the PR's goal is already met (removing the per-tracked-object walk).
3. InitializeObjectForExecutionAsync still does a nested walk (intentionally)
// ObjectLifecycleService.cs line 173–182
public async Task InitializeObjectForExecutionAsync(object? obj, ...)
{
await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken);
await ObjectInitializer.InitializeAsync(obj, cancellationToken);
}This path is called from TestBuilder.CreateInstance for class constructor arguments, and correctly keeps its nested walk because those objects are not in TrackedObjects. The PR leaves this path untouched, which is the right decision. The asymmetry between this path and the now-changed tracked-objects path is worth a brief comment to make the distinction clear to future readers — the current doc comment says "Used to initialize data source objects before they are passed to the test constructor" which is accurate but doesn't explicitly say "these are NOT in TrackedObjects, hence the walk is needed".
4. ValueListBuilder<Task> in InitializeTrackedObjectsAsync vs List<Task>
The hot path in InitializeTrackedObjectsAsync (lines 250–258) uses List<Task> with a capacity hint (objectsAtLevel.Count). Given that the surrounding code already uses ValueListBuilder<Task> in RegisterArgumentsAsync for similar patterns, it might be consistent to use the same here. This is a minor style/performance nit rather than a design concern, and out of scope for this PR.
Verdict
Approved with suggestions. The optimization is correct, well-motivated, and well-tested. The main actionable suggestion is to make the implicit precondition on InitializeObjectWithSpanAsync more visible — either via a naming change or a debug-mode assertion — to protect against future misuse. The remaining points are follow-up opportunities.
Up to standards ✅🟢 Issues
|
|
Closing — the optimization is unsafe in reflection mode. Root cause discovered while investigating CI failuresThe premise (depth-sorted
Today, Source-gen mode passes because of the existing skip-if-set guard. Suggested follow-up (separate issue)Fix the underlying reflection-injection asymmetry first:
Once that lands, the perf improvement attempted here can be re-attempted safely. Worth tracking in a fresh issue rather than holding this PR open. |
Closes #5718
Summary
InitializeTrackedObjectsAsyncnow relies on the sortedTrackedObjectslist populated at registration time, removing the per-tracked-object call toDiscoverNestedObjectGraphinsideInitializeObjectWithSpanAsync.IAsyncInitializerdependencies are already inTrackedObjectsat higher depths and are initialized first by the outer depth-descending loop, so no per-object walk is needed.TrackedObjects; it still gets one nested graph walk at the end so non-data-sourceIAsyncInitializerproperties (set in the constructor or byBeforeClasshooks) are still picked up.InitializeObjectWithSpanAsync/TraverseInitializerPropertiescost and a chunk ofHashSet<string>allocations from the per-test hot path.Test plan
dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release -f net10.0TUnit.UnitTests(180/180 pass)TUnit.TestProjectlifecycle / data-source / init slices:PropertyInitializationTests,InitializableTestClassTests,CombinedDataSourceTests(198),AbstractBaseClassPropertyInjectionTests,Bugs.Bug3266(4-level deep chain ordering, multi-fixture),Bugs._3597,Bugs._1187,Bugs._2955,Bugs._3072,InjectedClassDataSourceWithAsyncInitializerTests,NestedClassDataSourceDrivenTests3,KeyedDataSourceTests,ParallelPropertyInjectionTests,ClassDataSourceSharedNoneRegressionTests,GenericPropertyInjectionTests,PropertySetterTests,PropertyInitTest— all pass.Bugs.Bug3266.FourLevelDeepChainShouldInitializeInCorrectOrder(deepest dependency must initialize first across 4 levels) passes — equivalence with prior behavior.