perf: generate per-class helper to reduce JIT compilations by ~18,000#5048
perf: generate per-class helper to reduce JIT compilations by ~18,000#5048
Conversation
Generate a shared ClassHelper per test class that consolidates: - CreateInstance method (was duplicated per test method) - ModuleInitializer registration (was one companion class per test method) For the 10,000-test benchmark (1,000 classes x 10 methods), this reduces TUnit.Generated JIT compilations from 60,000 to ~42,000: - __CreateInstance: 10,000 → 0 (moved to 1,000 class helpers) - Initialize (companion): 10,000 → 0 (batched into 1,000 class helpers) - ClassHelper.CreateInstance: +1,000 - ClassHelper.Initialize: +1,000 Generic types/methods and inherited tests continue using the per-method pattern as they require method-specific handling.
There was a problem hiding this comment.
Code Review: per-class helper to reduce JIT compilations
This is a well-designed performance optimization with clear goals and thorough test coverage. The core idea — amortising per-method boilerplate into a single per-class artefact — is sound, and the PR description with the JIT reduction table makes the trade-off immediately obvious. A few things worth discussing before merging:
1. Collect() defeats per-class incremental caching (Medium)
ClassTestGroup is carefully designed to hold only primitives so Roslyn can cache it, and that's exactly right. However, the pipeline:
var classHelperProvider = testMethodsProvider
.Select(static (data, _) => data.Left)
.Where(...)
.Collect() // ← fan-in here
.SelectMany(static (methods, _) => GroupMethodsByClass(methods))
.Combine(enabledProvider);The Collect() call gathers all test methods into a single node before grouping. Any change to any test method invalidates the entire collected node, which forces all class helpers to be regenerated on the next build. For the 10k-test benchmark that's ~1,000 helper files regenerated on every keystroke in the IDE — the opposite of what the primitive-only model was designed for.
A truly incremental alternative would produce one ClassTestGroup per method (not per class) from an upstream Select, then deduplicate during output — or accept this trade-off but document it explicitly with a comment so the next maintainer understands why the caching model is intentionally coarse here.
2. Implicit coupling between useClassHelper and GenerateTypedInvokers (Medium)
In GenerateTestMetadata:
var useClassHelper = \!needsList && testMethod.InheritanceDepth == 0; // guards generics via needsListBut in GenerateTypedInvokers (called later):
if (testMethod.InheritanceDepth == 0) // no \!needsList guard here
{
writer.AppendLine($"InstanceFactory = {classHelperName}.CreateInstance,");
}This works today because needsList = IsGenericType || IsGenericMethod (line 354), so generics always have needsList = true → useClassHelper = false. However, GenerateTypedInvokers only sees InheritanceDepth == 0 and relies on the outer useNamedMethods guard to implicitly exclude generics. If the condition for needsList ever changes, a generic test with InheritanceDepth == 0 would silently reference a ClassHelper that is never generated, producing a compile error in user code.
Making the coupling explicit is cheap:
if (testMethod is { InheritanceDepth: 0, IsGenericType: false, IsGenericMethod: false })
{
writer.AppendLine($"InstanceFactory = {classHelperName}.CreateInstance,");
}3. Non-deterministic ordering of Register calls (Low)
TestSourceClassNames is populated via LINQ GroupBy + Select, which does not guarantee stable ordering between compilation runs. Roslyn's incremental engine uses value equality on the output to decide whether to re-emit, so a non-deterministic order on TestSourceClassNames would cause spurious cache misses and unnecessary file re-emissions. The snapshot tests pass today (likely because Collect() returns items in source-declaration order in the test harness), but this could behave differently under parallel Roslyn analysis or when files are added/removed.
Sorting before materialising the array would make this deterministic:
TestSourceClassNames = g
.Select(m => FileNameHelper.GetDeterministicFileNameForMethod(m\!.TypeSymbol, m.MethodSymbol)
.Replace(".g.cs", "_TestSource"))
.OrderBy(static s => s, StringComparer.Ordinal) // ← add this
.ToEquatableArray(),4. Minor: containingTypes list naming is slightly misleading
In GetSafeClassHelperName, the local list is named containingTypes but actually starts by adding typeSymbol.Name itself (the target type), then walks up via ContainingType. After reversing the list therefore contains [outermost … typeSymbol], i.e., it's really "all types including self". The logic is correct, but the name suggests "only the enclosing types". A quick rename to typeHierarchy or a brief comment would remove the ambiguity.
What works well
- The
ClassTestGroupprimitive-only model is the right abstraction for incremental caching. EquatableArray<string>used correctly for structural equality.GetSafeClassHelperNamecorrectly handles nested types, long names (hash truncation), and namespaced types.- Per-method
__CreateInstanceduplication is eliminated cleanly; only generic types and inherited tests keep the old per-method pattern — a sensible conservative scope for this change. - Error handling with a dedicated
TUNIT0998diagnostic rather than silently swallowing exceptions is good practice. - 109 snapshot files updated with 114/114 snapshot tests passing is thorough coverage.
The runtime JIT improvement is real and worth pursuing. Items 1 and 2 above are the ones most worth addressing before merge; 3 and 4 are nice-to-haves.
- Add explicit generic guard in GenerateTypedInvokers (IsGenericType/IsGenericMethod check alongside InheritanceDepth) to prevent silent breakage if needsList logic changes - Sort TestSourceClassNames with OrderBy(StringComparer.Ordinal) to ensure deterministic Register call ordering and prevent spurious incremental cache misses - Document the Collect() fan-in trade-off with a comment explaining why all class helpers re-evaluate when any test method changes - Rename misleading containingTypes variable to typeHierarchy in GetSafeClassHelperName - Update snapshot files for reordered Register calls
There was a problem hiding this comment.
Follow-up Review: per-class helper generation
All four items from the previous review have been addressed in the follow-up commit. Here's a summary of each, plus two new observations found during this pass.
Previous review items — all resolved ✅
1. Collect() fan-in trade-off — Documented with an accurate comment that also references the precedent in PropertyInjectionSourceGenerator. This is the right call: the trade-off (GroupMethodsByClass re-runs on every edit, only changed files re-emitted due to ClassTestGroup value equality) is now explicit and intentional.
2. Implicit coupling in GenerateTypedInvokers — Fixed with the pattern match guard:
if (testMethod is { InheritanceDepth: 0, IsGenericType: false, IsGenericMethod: false })This is now robust against future changes to the needsList conditions.
3. Non-deterministic Register ordering — Fixed. OrderBy(static s => s, StringComparer.Ordinal) on TestSourceClassNames makes the output stable. The snapshot diffs confirm the reordering took effect.
4. Misleading variable name — Renamed containingTypes → typeHierarchy in GetSafeClassHelperName. The comment explaining the walk direction is clear.
New observations
Dead code in GetSafeClassHelperName (Minor)
// Add generic parameters if any (for the innermost type)
if (typeSymbol.TypeArguments.Length > 0) { ... }Both call sites guarantee IsGenericType == false before invoking GetSafeClassHelperName, so this branch is unreachable. For a INamedTypeSymbol, IsGenericType is true iff TypeArguments.Length > 0, so the guard is equivalent to if (false). This is harmless, and the code was clearly ported from GetDeterministicFileNameForMethod for consistency, but it's misleading dead code. A comment like // Note: only called for non-generic types; TypeArguments.Length is always 0 here (or a guard-and-throw) would prevent future confusion.
classHelperProvider filter doesn't include InheritanceDepth == 0 (Very Minor)
.Where(static m => m is not null && !m!.IsGenericType && !m.IsGenericMethod)Because testMethodsProvider is sourced from ForAttributeWithMetadataName("TUnit.Core.TestAttribute") and GetTestMethodMetadata never sets InheritanceDepth (it always defaults to 0), all methods in this pipeline will have depth 0. However, the comment says "collect non-generic direct test methods" — adding && m.InheritanceDepth == 0 would make that intent explicit and guard against a future change where inherited methods could flow through testMethodsProvider. Not a bug today, but a cheap safety net.
Overall assessment
The core optimization is sound and cleanly implemented. The per-class helper correctly consolidates CreateInstance and ModuleInitializer registrations. The ClassTestGroup primitive-only design, the sorted TestSourceClassNames, the explicit generic guards, and the defensive TUNIT0998 diagnostic are all correct. The internal visibility on Initialize() (previously public) is a welcome reduction of generated public API surface. The snapshot test coverage (109 files, 4 TFMs) gives high confidence the output is correct.
The two new observations above are minor; the PR is ready to merge as-is.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.17.36 to 1.18.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.18.0 <!-- Release notes generated using configuration in .github/release.yml at v1.18.0 --> ## What's Changed ### Other Changes * refactor: convert 15 manual assertions to [GenerateAssertion] by @thomhurst in thomhurst/TUnit#5029 * Fix invisible chart labels on benchmark pages by @Copilot in thomhurst/TUnit#5033 * docs: fix position of `--results-directory` in documentation by @vbreuss in thomhurst/TUnit#5038 * fix: IsEquivalentTo falls back to Equals() for types with no public members by @thomhurst in thomhurst/TUnit#5041 * perf: make test metadata creation fully synchronous by @thomhurst in thomhurst/TUnit#5045 * perf: eliminate <>c display class from generated TestSource classes by @thomhurst in thomhurst/TUnit#5047 * perf: generate per-class helper to reduce JIT compilations by ~18,000 by @thomhurst in thomhurst/TUnit#5048 * perf: consolidate per-method TestSource into per-class TestSource (~27k fewer JITs) by @thomhurst in thomhurst/TUnit#5049 * perf: eliminate per-class TestSource .ctor JITs via delegate registration by @thomhurst in thomhurst/TUnit#5051 * feat: rich HTML test reports by @thomhurst in thomhurst/TUnit#5044 ### Dependencies * chore(deps): update tunit to 1.17.54 by @thomhurst in thomhurst/TUnit#5028 * chore(deps): update dependency polyfill to 9.13.0 by @thomhurst in thomhurst/TUnit#5035 * chore(deps): update dependency polyfill to 9.13.0 by @thomhurst in thomhurst/TUnit#5036 **Full Changelog**: thomhurst/TUnit@v1.17.54...v1.18.0 ## 1.17.54 <!-- Release notes generated using configuration in .github/release.yml at v1.17.54 --> ## What's Changed ### Other Changes * docs: restructure, deduplicate, and clean up documentation by @thomhurst in thomhurst/TUnit#5019 * docs: trim, deduplicate, and restructure sidebar by @thomhurst in thomhurst/TUnit#5020 * fix: add newline to github reporter summary to fix rendering by @robertcoltheart in thomhurst/TUnit#5023 * docs: consolidate hooks, trim duplication, and restructure sidebar by @thomhurst in thomhurst/TUnit#5024 * Redesign mixed tests template by @thomhurst in thomhurst/TUnit#5026 * feat: add IsAssignableFrom<T>() and IsNotAssignableFrom<T>() assertions by @thomhurst in thomhurst/TUnit#5027 ### Dependencies * chore(deps): update tunit to 1.17.36 by @thomhurst in thomhurst/TUnit#5018 * chore(deps): update actions/upload-artifact action to v7 by @thomhurst in thomhurst/TUnit#5015 * chore(deps): update dependency microsoft.testing.extensions.codecoverage to 18.5.1 by @thomhurst in thomhurst/TUnit#5025 **Full Changelog**: thomhurst/TUnit@v1.17.36...v1.17.54 Commits viewable in [compare view](thomhurst/TUnit@v1.17.36...v1.18.0). </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
__ClassHelperper test class that consolidatesCreateInstanceandModuleInitializerregistration, eliminating ~18,000 JIT compilations in the 10,000-test benchmarkCreateInstancemethod and a batchedInitialize()that registers all test sources for that classInheritanceDepth > 0) continue using the per-method pattern unchangedProfiling results (1,000 classes × 10 methods = 10,000 tests)
Total JIT compilations: 75,311 → 57,251 (24% reduction)
TUnit.Generated method breakdown:
.ctor(TestSource)GetTests__CreateAttributes__InvokeTest__CreateInstance(per-method)Initialize(companion class)CreateInstance(ClassHelper)Initialize(ClassHelper)Runtime (10,000 tests, 5 runs, Release):
New files
TUnit.Core.SourceGenerator/Models/ClassTestGroup.cs— per-class grouping model (primitives only, no ISymbol)Modified files
TestMetadataGenerator.cs— per-class pipeline (GroupMethodsByClass+GenerateClassHelper), conditional skip of__CreateInstanceandModuleInitializerfor direct non-generic testsInstanceFactoryGenerator.cs—GenerateInstanceFactoryBody()to pre-generate factory code as stringFileNameHelper.cs—GetSafeClassHelperName()for deterministic class helper naming.verified.txtsnapshot files updated across all 4 TFMsTest plan
TUnit.TestProjectbuilds with 0 errors on net10.0