perf: reduce source-gen JIT overhead via metadata factory helpers#5056
perf: reduce source-gen JIT overhead via metadata factory helpers#5056
Conversation
Replace inline TestMetadata<T> and MethodMetadata object initializers (~38 lines each) in generated code with calls to shared factory methods TestMetadataFactory.Create<T>() and MethodMetadataFactory.Create(). A generic factory method Create<T>() where T : class gets JIT'd once by the .NET runtime (reference types share native code), so replacing per-class inline initializers with calls to a shared factory dramatically reduces JIT-compiled native code size. Benchmark (10,000 tests, net10.0): - Cold start: 5.42s -> 4.26s (21% faster) - Warm runs: 3.30s -> 2.64s (20% faster) - Gap to reflection mode cut in half (1.3s -> 0.64s) Also folds UseRuntimeDataGeneration(testSessionId) into the factory method as an optional parameter, eliminating a separate call per test.
There was a problem hiding this comment.
Code Review: perf/metadata-factory-helpers
This is a well-motivated performance optimization. The core insight — that Create<T>() where T : class gets JIT'd once for all reference-type instantiations — is sound and the 20-21% cold-start improvement is meaningful. The PR is generally in good shape, but there are a few issues worth addressing.
🔴 Architecture Concern: String Parsing Hack in PreGeneratePropertyInjectionsExpression
The most significant problem is in TestMetadataGenerator.cs, PreGeneratePropertyInjectionsExpression:
// Use the existing method to generate, then extract the expression
var writer = new CodeWriter(includeHeader: false);
GeneratePropertyInjections(writer, typeSymbol, className);
var output = writer.ToString().Trim();
// Output format: "PropertyInjections = <expression>,"
// Strip the prefix and trailing comma
const string prefix = "PropertyInjections = ";
if (!output.StartsWith(prefix))
return null;
var expr = output[prefix.Length..].TrimEnd();
if (expr.EndsWith(","))
expr = expr[..^1].TrimEnd();
if (expr.Contains("Array.Empty<"))
return null;This is fragile output-format parsing — the method generates a string using one code path, then manually strips the prefix and trailing comma from that string. This creates invisible coupling between GeneratePropertyInjections's output format and this parser. If anyone ever changes the output format of GeneratePropertyInjections (say, the assignment format, whitespace, or the specific Array.Empty< sentinel), this silently produces wrong results.
Better approach: The PreGeneratePropertyDataSourcesExpression pattern is the right model — build the expression directly in a CodeWriter without reparsing existing methods. Either:
- Refactor
GeneratePropertyInjectionsto return the expression string directly (separate the "is empty?" check from the "generate expression" logic), or - Duplicate the generation logic (it's not large) to build the expression independently
The Array.Empty< string sniffing as a "return null" sentinel is particularly concerning — it ties correctness to an implementation detail of a different method.
🟡 Double Type Hierarchy Traversal in PreGeneratePropertyDataSourcesExpression
Both PreGeneratePropertyDataSourcesExpression and PreGeneratePropertyInjectionsExpression traverse the entire class hierarchy twice: once to check if any qualifying properties exist, then again (after clearing the set) to generate the output. This is O(2n) when O(n) is possible:
// First pass: detect existence
while (tempType != null) { /* traverse */ }
if (processedProperties.Count == 0) return null;
processedProperties.Clear(); // throw away the work
// Second pass: generate
while (currentType != null) { /* traverse again */ }Better approach: Collect the relevant (IPropertySymbol, AttributeData) pairs in a single pass into a list, then return null if empty or generate the expression from the list.
🟡 Library.props: Overly Broad AD0001 Suppression
<NoWarn Condition="'$(TargetFramework)' == 'net9.0'">$(NoWarn);AD0001</NoWarn>This suppresses all AD0001 analyzer crash diagnostics for every project targeting net9.0. AD0001 is the catch-all "analyzer threw an exception" warning, so this could hide unrelated analyzer bugs silently. Consider adding a link to the tracked SDK issue in the comment so future maintainers know when it's safe to remove, and ideally scope the suppression to only the affected project(s) rather than the shared Library.props.
🟡 filePath Always Emitted Even When Empty
In both PreGenerateMetadataBlock and PreGenerateMaterializerMethod:
// Always emit filePath (it's almost never empty)
writer.AppendLine(",");
writer.AppendLine($"filePath: @\"{filePath}\",");
writer.Append("testSessionId: testSessionId");The comment says "almost never empty", but the snapshot output shows filePath: @"" being emitted when there is no file path. Since the factory default for filePath is "", emitting filePath: @"" explicitly is dead weight in the generated code. The conditional should be:
if (!string.IsNullOrEmpty(filePath))
{
writer.AppendLine(",");
writer.AppendLine($"filePath: @\"{filePath}\",");
}
writer.Append("testSessionId: testSessionId");This is a minor but easy win that makes the generated code slightly smaller and more honest.
✅ What's Done Well
- The JIT sharing insight is correct and well-documented. The comment in
TestMetadataFactory.csexplaining why this works (reference types share JIT-compiled native code) is excellent — this is non-obvious and the explanation will help future contributors. - Hoisting
__classMetadataand__classTypeavoids reconstructing theClassMetadataper test method, which is a real win for classes with many tests. - Optional-parameter pattern for rare fields (dependencies, data sources, repeat count) keeps the common case compact while supporting all configurations.
- Snapshot tests all pass — 456 passing with correct
.verified.txtfiles committed. The behavioral equivalence with the old inline initializer approach is well-validated. UseRuntimeDataGenerationfolded cleanly into the factory'stestSessionIdparameter. The?? Guid.NewGuid().ToString()fallback correctly matches the oldTestSessionId { get; set; } = Guid.NewGuid().ToString()default.MetadataGenerationHelperpublic extensions (GenerateClassMetadataGetOrAddWithParentExpression,GenerateParameterMetadataArrayForMethodExpression) are clean additions with proper XML docs.AOT suppressions are justified— the[UnconditionalSuppressMessage]attributes have accurate justifications since these factories are only ever called from source-generated code with concrete types.- Benchmark numbers are compelling — cutting the reflection-vs-source-gen gap in half (1.3s → 0.64s) is a real UX improvement for large test suites.
Summary
The two main architectural issues to address before merging:
- Replace the string-parsing hack in
PreGeneratePropertyInjectionsExpressionwith direct expression generation - Eliminate the double-traversal pattern in the property helper methods
The Library.props and filePath issues are lower priority but worth fixing while the code is being touched.
…ce gen - Extract GeneratePropertyInjectionsExpression for direct expression generation, replacing fragile prefix-stripping from GeneratePropertyInjections output. Fixes silent bug where property injections were dropped in factory path. - Single-pass type hierarchy traversal in PreGeneratePropertyDataSourcesExpression and GeneratePropertyInjectionsExpression (was double-pass). - Conditional filePath emission — skip filePath: @"" when empty (factory default). - Add dotnet/runtime issue links to Library.props AD0001 suppression comment.
There was a problem hiding this comment.
Code Review — perf: reduce source-gen JIT overhead via metadata factory helpers
This is a well-motivated, well-benchmarked performance improvement. The core insight — that a single generic factory method Create<T>() where T : class gets JIT'd once for all reference types, eliminating per-class specialisation of ~38-line object initialisers — is correct and the 20–21% startup improvement backs it up nicely. The secondary win of hoisting __classMetadata / __classType out of the per-test loop is clean. Good work overall.
Issues found
1. Indentation inconsistency in generated __Materialize_* methods (bug, cosmetic)
In PreGenerateMaterializerMethod, the ClassMetadata object-initialiser fields are rendered at the wrong indent level:
// __Materialize_* (WRONG — 4-space indent for fields)
var __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd(..., new global::TUnit.Core.ClassMetadata
{
Type = typeof(...),
...
});
// GetTests() (correct — 8-space indent for fields)
var __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd(..., new global::TUnit.Core.ClassMetadata
{
Type = typeof(...),
...
});Root cause: GenerateClassMetadataGetOrAddWithParentExpression(testMethod.TypeSymbol, writer.IndentLevel) in PreGenerateMaterializerMethod passes the outer writer.IndentLevel (which is 2 = 8 spaces, inside the method body) to a fresh inner CodeWriter. However, when the resulting multi-line string is embedded as $"var __classMetadata = {classMetadataExpr};" the first line is placed after var __classMetadata = on the same line, so subsequent lines are indented relative to column 0 of the inner writer — not relative to the outer indentation context. PreGenerateSharedLocals uses writer.IndentLevel of a fresh writer (indentLevel = 0), so there's no mismatch there.
This is purely cosmetic (the code compiles and runs fine), but it makes the generated output visually inconsistent and harder to debug.
Suggested fix: in PreGenerateMaterializerMethod, either pass 0 (instead of writer.IndentLevel) to match how PreGenerateSharedLocals works, or adjust the inner CodeWriter to start at 0 and let the outer writer handle indentation of the whole block.
2. Public factory API has no discoverability guard
TestMetadataFactory and MethodMetadataFactory are both public — meaning they appear in Intellisense for every TUnit user — but they are purely infrastructure for the source generator. Nothing prevents a user from calling TestMetadataFactory.Create directly (possibly with incorrect arguments) or being confused by its presence.
Suggested improvement (minor but nice-to-have):
[EditorBrowsable(EditorBrowsableState.Never)]
[Obsolete("Infrastructure only — called by TUnit's source generator. Do not use directly.")]
public static class TestMetadataFactory { ... }This hides the types from Intellisense autocompletion while keeping them accessible from generated code in user assemblies (where internal wouldn't work without InternalsVisibleTo). The Obsolete message also serves as documentation for anyone who stumbles across them via reflection or browsing the API.
3. TestMetadata<T>.TestSessionId — factory falls back to Guid.NewGuid() when testSessionId is null
// TestMetadataFactory.cs
TestSessionId = testSessionId ?? Guid.NewGuid().ToString(),This is a behavioural parity with the old UseRuntimeDataGeneration, but it means that any future call-site where testSessionId is accidentally null (e.g., a new code path in the generator) will silently produce a random GUID rather than surfacing an obvious error. The non-nullable string testSessionId parameter in the generated call gives confidence the compiler will catch it at generator-time, but the runtime fallback is a silent trap.
Consider accepting string testSessionId (non-nullable, no default) to make the call-site contract explicit, or at least ArgumentNullException.ThrowIfNull(testSessionId) before the null-coalescing. The generated code always provides a real session ID today, so the Guid.NewGuid() branch should never execute in practice.
Observations (no action required)
-
Single-pass property injection — the refactor of
GeneratePropertyInjections→GeneratePropertyInjectionsExpressionis a clean improvement; the old double-pass (detect → reset → generate) was a code smell waiting for a bug. -
testNameandtestMethodNameduplicated in factory call — in virtually all generated call sites these two strings are identical (e.g.,"Test1", "Test1"). The separation is intentional (display names can differ), but it makes the positional call-site slightly noisy. Not a problem worth changing now. -
AD0001suppression inLibrary.props— well-documented with upstream dotnet/runtime issue links. Good practice. -
ClassTestGroup.SharedLocalsCoderequired property — clean model-level change. SinceClassTestGroupis source-generator–internal, therequiredkeyword enforces correctness at compile time.
Summary
The performance motivation is solid and the implementation is largely clean. The two items worth addressing before merge are:
- The indentation inconsistency in generated
__Materialize_*blocks (purely cosmetic but visible in diffs). - Adding
[EditorBrowsable(EditorBrowsableState.Never)]/[Obsolete]to the two factory classes to keep the public API surface tidy.
Neither is a blocker, but both are low-effort to fix.
Remove the Guid.NewGuid() fallback — testSessionId is always provided by generated code and should never be absent.
There was a problem hiding this comment.
Code Review — perf: reduce source-gen JIT overhead via metadata factory helpers
The original commit is a well-motivated and well-benchmarked optimisation. The two follow-up commits (7a18dfc4, ca4f28e8) address several issues raised by earlier reviews cleanly:
- ✅ String-parsing hack replaced with direct expression generation in
GeneratePropertyInjectionsExpression - ✅ Double type-hierarchy traversals collapsed into single-pass collection
- ✅ Conditional
filePathemission (no longer emitsfilePath: @""when empty) - ✅
testSessionIdmade required —Guid.NewGuid()silent fallback removed - ✅
AD0001suppression comment extended with upstream dotnet/runtime issue links
Two items from the earlier reviews remain open:
🔴 Indentation inconsistency in __Materialize_* generated code (still present)
The __classMetadata initialiser in __Materialize_* methods is still at the wrong indent level:
// __Materialize_* (wrong — { and fields at 4-space indent)
var __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd(..., new global::TUnit.Core.ClassMetadata
{
Type = typeof(global::TUnit.TestProject.AbstractTests.ConcreteClass2),
...
});
// GetTests() (correct — { and fields at 8-space indent, matching method body)
var __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd(..., new global::TUnit.Core.ClassMetadata
{
Type = typeof(global::TUnit.TestProject.AbstractTests.ConcreteClass2),
...
});Root cause is in PreGenerateMaterializerMethod: it calls MetadataGenerationHelper.GenerateClassMetadataGetOrAddWithParentExpression(testMethod.TypeSymbol, writer.IndentLevel), passing the outer writer's current indent level (2 = 8 spaces) to the inner CodeWriter. But when that multi-line string is embedded via writer.AppendLine($"var __classMetadata = {classMetadataExpr};"), only the first line aligns with the outer indent context — subsequent lines are at the inner writer's absolute indent level rather than relative to the outer indentation.
PreGenerateSharedLocals (used in GetTests()) works correctly because it uses writer.IndentLevel of a fresh writer at indent 0, so the generated string is already at baseline and gets indented correctly when the outer writer appends it.
Fix: pass 0 instead of writer.IndentLevel in PreGenerateMaterializerMethod:
// Before
var classMetadataExpr = MetadataGenerationHelper.GenerateClassMetadataGetOrAddWithParentExpression(testMethod.TypeSymbol, writer.IndentLevel);
// After
var classMetadataExpr = MetadataGenerationHelper.GenerateClassMetadataGetOrAddWithParentExpression(testMethod.TypeSymbol, 0);The generated code compiles and runs correctly regardless (C# doesn't care about whitespace), but the inconsistency appears across every .verified.txt snapshot and will continue showing up in diffs.
🟡 Factory classes are public with no discoverability guard
TestMetadataFactory and MethodMetadataFactory must be public so generated code in user assemblies can call them, but they're pure infrastructure and should not appear in Intellisense for end users. They remain without any discoverability suppression:
// Suggested addition to both factory classes
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
[Obsolete("Infrastructure only — called by TUnit source generator. Do not use directly.", error: false)]
public static class TestMetadataFactory { ... }This hides them from Intellisense autocompletion while keeping them accessible from generated code, and provides a clear signal to anyone who finds them via reflection or API browsing. (Non-blocking — the same omission exists for other infrastructure types in TUnit.Core — but worth adding while touching this file.)
Summary
The core design is sound and all significant functional issues have been addressed in the follow-up commits. The indentation bug is cosmetic (generated code is functionally correct) and the EditorBrowsable suggestion is a nice-to-have. Both are easy fixes if you want a clean final state before merge.
…elliSense - Fix classMetadata indentation in __Materialize_* methods by using AppendRaw which properly re-indents each line of multi-line expressions - Add [EditorBrowsable(Never)] to TestMetadataFactory and MethodMetadataFactory to hide infrastructure types from IntelliSense - Update snapshot tests to reflect corrected indentation
There was a problem hiding this comment.
Code Review: perf/metadata-factory-helpers (Follow-up)
The previous review's main concerns have been addressed. Here's a summary of what's fixed and what needs attention.
✅ Previous Issues Addressed
String-parsing hack in PreGeneratePropertyInjectionsExpression — Fixed. The method now uses single-pass collection into qualifyingProperties, generates the expression directly, and no longer does Array.Empty< string-sniffing as a sentinel. Clean.
Double type-hierarchy traversal — Fixed in both PreGeneratePropertyDataSourcesExpression and GeneratePropertyInjectionsExpression. Both now collect into a List<(...)> in one pass and return null if empty before generating output.
filePath always emitted — Fixed. Both PreGenerateMetadataBlock and PreGenerateMaterializerMethod now gate on !string.IsNullOrEmpty(filePath), so the common case (empty path in tests) produces cleaner output.
🔴 Bug: Public API Snapshot Does Not Match Source
The TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.*.verified.txt snapshots show:
public static .TestMetadata<T> Create<T>(
...
.MethodMetadata methodMetadata,
string filePath = , // <-- immediately after methodMetadata
int inheritanceDepth = 0,
...
string? testSessionId = null) // <-- last parameter, optional, nullable
But the actual TUnit.Core/TestMetadataFactory.cs source declares:
public static TestMetadata<T> Create<T>(
...
MethodMetadata methodMetadata,
string testSessionId, // <-- required, position 8, before filePath
string filePath = ,
...
)These are fundamentally different APIs: testSessionId is required at position 8 in the source but optional/nullable/last in the snapshot. The PR description says the intent was to fold it in as an "optional testSessionId parameter", which matches the snapshot — but the code contradicts that.
This will cause the Public API snapshot tests to fail in CI. The snapshots need to be regenerated once the source is in its final form. If testSessionId is truly meant to be required here (since every call site always passes it), then the snapshots need updating. If it was meant to be optional, the source needs updating.
🟡 Minor: Generated ClassMetadata Initializer Loses Indentation
In the generated GetTests() output (visible in all snapshot diffs), the ClassMetadata initializer properties have no indentation relative to the {:
var __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("...", new global::TUnit.Core.ClassMetadata
{
Type = typeof(global::TUnit.TestProject.BasicTests), // <-- should be indented
TypeInfo = new global::TUnit.Core.ConcreteType(...),This happens because PreGenerateSharedLocals creates an outer CodeWriter at indent level 0, then passes writer.IndentLevel to GenerateClassMetadataGetOrAddWithParentExpression, which generates the block starting at level 0. When the multi-line result is inlined into var __classMetadata = {classMetadataExpr}; in the outer writer (which writes at level 2 inside the method body), the continuation lines don't inherit the outer indentation.
The fix is straightforward: pass writer.IndentLevel + 1 (or however many levels the inline expression continues at) so the inner properties are indented relative to the opening {. It's not a functional bug but makes the generated code harder to read/debug.
✅ Architecture and Optimization Quality
The dual-path design is clean:
- Non-generic, non-inherited tests →
GeneratePerClassTestSource(new factory path) — early-returns fromGenerateTestMethodSourcevia the guard at line 302 - Generic types, generic methods, inherited tests →
GenerateTestMethodSource(old inline path)
The JIT sharing claim is correct: Create<T>() with a class constraint gets JIT-compiled once for all reference-type instantiations. The hoisting of __classMetadata and __classType prevents redundant ClassMetadata.GetOrAdd calls (which involve string hashing) for every test in the class. The benchmarks are credible.
The AD0001 suppression in Library.props is scoped to net9.0 only and has referenced issue links — acceptable as a workaround for a known SDK bug.
Summary: Fix the public API snapshot inconsistency (the testSessionId parameter position/optionality) and optionally address the indentation cosmetic. Everything else looks solid.
Use standard indent string in GenerateClassMetadataGetOrAddWithParentExpression
so properties inside ClassMetadata { ... } blocks are properly indented relative
to the opening brace in both GetTests() and __Materialize_* paths.
There was a problem hiding this comment.
Code Review — perf: reduce source-gen JIT overhead via metadata factory helpers (5th commit follow-up)
The 5th commit (16d3ade1) fixes the remaining indentation issue from the previous review. Here's where things stand:
✅ All Previous Issues Now Resolved
| Issue | Status |
|---|---|
| String-parsing hack in property injections | ✅ Fixed (commit 2) |
| Double type-hierarchy traversal | ✅ Fixed (commit 2) |
Conditional filePath emission |
✅ Fixed (commit 2) |
testSessionId made required (no Guid.NewGuid() fallback) |
✅ Fixed (commit 3) |
ClassMetadata field indentation in __Materialize_* methods |
✅ Fixed (commits 4 & 5) |
[EditorBrowsable(Never)] on factory classes |
✅ Fixed (commit 4) |
🔴 Still Open: Public API Snapshot Mismatch
This was flagged in the previous review and is still present. The TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.*.verified.txt snapshots record the Create<T> signature as:
.MethodMetadata methodMetadata,
string filePath = , // ← position 8, optional
int inheritanceDepth = 0,
...
string? testSessionId = null // ← last parameter, optional/nullable
But the actual TestMetadataFactory.cs source (unchanged in commits 3–5) declares:
MethodMetadata methodMetadata,
string testSessionId, // ← position 8, REQUIRED, non-nullable
string filePath = ,
int inheritanceDepth = 0,
...
// no testSessionId at the endThese are materially different APIs. The snapshot was generated against an older shape of the method (before commit 3 made testSessionId required) and was never regenerated. The Public API snapshot tests will fail in CI when they compare the recorded signature against the live binary.
Fix: regenerate the public API snapshots after the source is in its final form by running the snapshot test with VERIFY_SNAPSHOTS=update (or your project's equivalent accept-all command), then commit the updated *.verified.txt files.
Summary
The implementation is solid and all functional concerns from earlier reviews have been addressed. Only one item remains before merge: regenerate the public API snapshots to reflect the current string testSessionId required parameter position in TestMetadataFactory.Create<T>.
There was a problem hiding this comment.
Code Review: Follow-up on perf/metadata-factory-helpers
This revision has addressed all three items from the previous review. Here's the status:
✅ All Previous Issues Resolved
String-parsing hack — Fixed. GeneratePropertyInjectionsExpression now does a clean single-pass collection of qualifying properties and generates the expression directly. No more fragile output-format sniffing.
Double type hierarchy traversal — Fixed. Both PreGeneratePropertyDataSourcesExpression and GeneratePropertyInjectionsExpression now collect qualifying (Property, ...) pairs in a single pass, then return null if empty or generate from the collected list.
filePath always emitted when empty — Fixed. There's now a proper if (!string.IsNullOrEmpty(filePath)) guard before emitting the filePath: named argument, matching the snapshot tests.
Library.props AD0001 comment — The GitHub issue links (dotnet/runtime#109352 and dotnet/runtime#120728) are now in the comment, which is exactly what was requested. The suppression remains global to all net9.0 targets in Library.props, but given this is a genuine SDK-level bug affecting any project that opts into AOT analysis on net9.0, that scope is arguably correct.
🟡 Remaining Concern: PreGeneratePropertyInjectionsExpression Is a Redundant Wrapper
After the refactor, this method is now a one-liner:
private static string? PreGeneratePropertyInjectionsExpression(INamedTypeSymbol typeSymbol, string className)
{
return GeneratePropertyInjectionsExpression(typeSymbol, className);
}It's called in exactly one place. This wrapper adds no abstraction value and creates a ghost in the call stack — if someone later wants to understand what PropertyInjections generation does, they have to trace through an extra indirection. Consider inlining the call at the single call site:
var propInj = GeneratePropertyInjectionsExpression(testMethod.TypeSymbol, className);Minor, but worth cleaning up since it's a leftover from the prior string-parsing architecture.
🟡 Dual Write*/Generate* Pattern in MetadataGenerationHelper.cs
MetadataGenerationHelper.cs now contains parallel implementations for the same operations:
WriteMethodMetadata(ICodeWriter writer, IMethodSymbol, INamedTypeSymbol)GenerateMethodMetadata(IMethodSymbol, string classMetadataExpression, int indentLevel)— labelled "for backward compat"WriteParameterMetadataArrayForMethod+GenerateParameterMetadataArrayForMethodWriteParameterMetadataArrayForConstructor+GenerateParameterMetadataArrayForConstructorWritePropertyMetadataArray+GeneratePropertyMetadataArray
The "backward compat" comment is concerning — if these methods are only used within the generator's own source, there's no external API compatibility to maintain. The dual pattern means that two implementations of the same logic exist and can drift. The root cause seems to be that WriteMethodMetadata takes an INamedTypeSymbol (so it can recurse for class metadata), while GenerateMethodMetadata takes a pre-generated classMetadataExpression string.
Better approach: The cleanest resolution is to make the Write* path universal by supporting both cases — e.g., have WriteClassMetadataGetOrAddWithParent accept an overload that writes directly rather than serializing to a string first. The current recursive writer pattern in WriteClassMetadataGetOrAddWithParent already does this internally:
// Line 157: creates intermediate string writer just to pass to another method
var parentWriter = new CodeWriter("", includeHeader: false).SetIndentLevel(writer.IndentLevel);
WriteClassMetadataGetOrAddWithParent(parentWriter, typeSymbol.ContainingType);
WriteClassMetadataGetOrAdd(writer, typeSymbol, parentWriter.ToString());This intermediate serialization-then-pass-as-string pattern leaks through the design. Not a blocking issue, but worth tracking as tech debt.
✅ What's Done Well
The core changes are well-executed:
- The
__classMetadataand__classTypehoisting viaPreGenerateSharedLocalsis clean — the shared locals are pre-computed once per class group at analysis time and emitted verbatim, avoidingClassMetadata.GetOrAddcalls on every test in the class. GenerateMethodMetadataFactoryCallusingStringBuilderdirectly (rather thanCodeWriter) is appropriate for a single-line expression generation.- The attribute body deduplication in
GroupMethodsByClass(usingbodyToIndex/distinctBodies) is a nice additional optimization beyond the original PR description. [EditorBrowsable(EditorBrowsableState.Never)]on both factory types is the right call — these are infrastructure types that generated code calls, not user-facing API.- The
testSessionIdis now a required, non-nullable parameter (noGuid.NewGuid()fallback), which correctly makes the implicit contract explicit.
The benchmark numbers (21% cold-start improvement, gap to reflection cut in half) make this PR worthwhile. The remaining concerns are tech debt rather than correctness issues. LGTM with the minor cleanup suggestions.
There was a problem hiding this comment.
Code Review — perf/metadata-factory-helpers (Final follow-up)
This follow-up picks up from where the previous review left off (LGTM with minor cleanup suggestions). Here is the status of the two outstanding items.
✅ PreGeneratePropertyInjectionsExpression redundant wrapper — Resolved
The previous review flagged PreGeneratePropertyInjectionsExpression as a one-liner that added no abstraction value. It has been eliminated: GeneratePropertyInjections now calls GeneratePropertyInjectionsExpression directly, and the expression method itself does the full single-pass collection + generation. The call chain is clean.
🟡 Dual Write*/Generate* pattern in MetadataGenerationHelper.cs — Still present, still tech debt
MetadataGenerationHelper now exposes:
WriteClassMetadataGetOrAddWithParent(writes to a CodeWriter)GenerateClassMetadataGetOrAddWithParentExpression(returns a string)WriteParameterMetadataArrayForMethod/GenerateParameterMetadataArrayForMethodExpression
These are parallel implementations of the same logic. This was flagged in the previous review and noted as tech debt. It hasn't been addressed, and that's a reasonable call — it's a design smell, not a correctness issue. Worth tracking for a follow-up cleanup but not a blocker here.
✅ All Previous Issues Fully Resolved
| Issue | Status |
|---|---|
| String-parsing hack in property injections | ✅ Fixed |
| Double type-hierarchy traversal | ✅ Fixed |
Conditional filePath emission in factory calls |
✅ Fixed |
testSessionId made required (no Guid.NewGuid() fallback) |
✅ Fixed |
ClassMetadata field indentation in __Materialize_* methods |
✅ Fixed |
[EditorBrowsable(EditorBrowsableState.Never)] on factory classes |
✅ Fixed |
| Public API snapshot mismatch | ✅ Fixed |
Redundant PreGeneratePropertyInjectionsExpression wrapper |
✅ Fixed |
Observations (no action needed)
FilePath = @""inEnumerateTestDescriptors— The empty filepath is still emitted unconditionally inTestDescriptorobject initializers (not the factory call). This wasn't part of the factory optimization scope and is consistent with the old code path. Low-priority follow-up if desired.testName/testMethodNamepositional duplication — Factory calls still pass both as identical strings in nearly all cases (e.g.,"Test1", "Test1"). The separation is intentional (display names can differ). Not worth changing.- Snapshot coverage is thorough — 456 snapshot tests passing across all TFMs, including edge cases like matrix tests, async data sources, property setters, and inherited tests.
Summary
All functional and architectural concerns raised across the previous review iterations have been addressed. The implementation is clean, the benchmark improvement (21% cold-start, gap to reflection halved) is well-validated, and the generated code is readable. The only remaining item is the Write*/Generate* dual-pattern tech debt which is a known, tracked concern.
LGTM. Ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.18.0 to 1.18.9. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.18.9 <!-- Release notes generated using configuration in .github/release.yml at v1.18.9 --> ## What's Changed ### Other Changes * perf: reduce source-gen JIT overhead via metadata factory helpers by @thomhurst in thomhurst/TUnit#5056 * perf: add ParameterMetadataFactory and lazy ReflectionInfo resolution by @thomhurst in thomhurst/TUnit#5057 * feat: distributed trace collection for HTML report by @thomhurst in thomhurst/TUnit#5059 ### Dependencies * chore(deps): update tunit to 1.18.0 by @thomhurst in thomhurst/TUnit#5052 * chore(deps): update docker/setup-docker-action action to v5 by @thomhurst in thomhurst/TUnit#5058 **Full Changelog**: thomhurst/TUnit@v1.18.0...v1.18.9 Commits viewable in [compare view](thomhurst/TUnit@v1.18.0...v1.18.9). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
TestMetadataFactory.Create<T>()andMethodMetadataFactory.Create()factory helpers toTUnit.CoreTestMetadata<T>object initializers (~38 lines each)__classMetadataand__classTypelocals to the top of eachGetTests()method, avoiding redundant construction per test methodUseRuntimeDataGeneration(testSessionId)into the factory as an optionaltestSessionIdparameterMotivation
Each per-class
GetTests()method previously constructedTestMetadata<T>objects inline for every test method. For a class with 10 tests, this produced ~20KB of native code per method. With 1,000 test classes, that's ~20MB of JIT-compiled native code just for metadata construction.A generic factory method
Create<T>() where T : classgets JIT'd once by the .NET runtime (reference types share native code), replacing per-class inline initializers with calls to a shared factory.Benchmark (10,000 tests, net10.0)
Gap to reflection mode cut in half: 1.3s → 0.64s.
Test plan