Conversation
…gistration Switch generated per-class TestSource classes from sealed instance classes to static classes with static methods. Registration now passes method groups as delegates to a shared DelegateTestSource adapter, eliminating ~1,000 unique .ctor JIT compilations in the 10,000-test repro scenario. JIT reduction: 15,000 → ~14,000 (7% reduction on top of PR #5049).
There was a problem hiding this comment.
Code Review: perf: eliminate per-class TestSource .ctor JITs via delegate registration
The core optimization is well-targeted and the implementation is clean. Here are my observations:
What's good
- JIT claim is valid: Moving from N unique
internal sealed classtypes (each with its own.ctorthe JIT compiles independently) to a single sharedDelegateTestSourcetype is a real JIT win. The JIT compiles each unique generic/class.ctorseparately, so N classes → N JIT compilations becomes 1. DelegateTestSourceas an adapter is a clear, minimal pattern that preserves the existingITestSource/ITestDescriptorSourceinterface contract everywhere the engine consumes these sources.- AOT safety: Static classes with static methods are Native AOT–safe.
Func<>delegates wrapping static methods have no reflection concerns. - Snapshot coverage: All 4 TFM variants are consistently updated.
Concern: Net allocation cost went up, not down
Each call to the new Register overload allocates 3 objects instead of 1:
Func<string, IReadOnlyList<TestMetadata>>(method group → delegate)Func<IEnumerable<TestDescriptor>>(method group → delegate)DelegateTestSource(wrapping both)
vs. the old new TestSourceXxx() which allocated 1 object (a zero-field class, just an object header + vtable pointer).
The PR title says "eliminate per-class .ctor JITs" — that claim is accurate. But the description could be clearer that this trades JIT compilation overhead for slightly more startup allocations. Since all of this is at module-init time (not hot paths), the tradeoff is very reasonable — just worth calling out explicitly.
Architectural suggestion: skip DelegateTestSource entirely
If reducing allocations and JIT overhead both matter, there's a further step available: store the delegates directly in the Sources collections without the wrapper object:
Then the engine's consumption path would handle both collections. This avoids the DelegateTestSource allocation (3 objects → 2 per class). This is a more invasive change, so it may be worth a follow-up PR rather than blocking this one.
Minor: ITestDescriptorSource doc vs. implementation
The ITestDescriptorSource.EnumerateTestDescriptors doc says it "should be fast and allocation-free where possible". The DelegateTestSource implementation adds one level of delegate indirection compared to direct static method calls. Not a real concern in practice (module init only), just a slight inconsistency with the documented intent.
Summary
This is a solid, well-scoped optimization that correctly reduces JIT startup cost. The snapshot tests are all updated correctly, and the approach is AOT-compatible. The allocation trade-off is acceptable given this is startup-only. Consider the "store delegates directly" approach as a follow-up for a further win if allocation reduction becomes a priority.
Approved with the above observations noted.
There was a problem hiding this comment.
Code Review: perf: eliminate per-class TestSource .ctor JITs via delegate registration
The core optimization is well-targeted and the implementation is clean. Here are my observations:
What's good
- JIT claim is valid: Moving from N unique
internal sealed classtypes (each with its own.ctorthe JIT compiles independently) to a single sharedDelegateTestSourcetype is a real JIT win. The JIT compiles each unique class.ctorseparately, so N classes → N JIT compilations becomes 1. DelegateTestSourceas an adapter is a clear, minimal pattern that preserves the existingITestSource/ITestDescriptorSourceinterface contract everywhere the engine consumes these sources.- AOT safety: Static classes with static methods are Native AOT–safe.
Func<>delegates wrapping static methods have no reflection concerns. - Snapshot coverage: All 4 TFM variants are consistently updated.
Concern: Net allocation count went up, not down
Each call to the new Register overload allocates 3 objects instead of 1:
Func<string, IReadOnlyList<TestMetadata>>(method group → delegate)Func<IEnumerable<TestDescriptor>>(method group → delegate)DelegateTestSource(wrapping both)
vs. the old new TestSourceXxx() which allocated 1 object (a zero-field class — just an object header + vtable pointer).
The PR title says "eliminate per-class .ctor JITs" — that claim is accurate. But the description could be clearer that this trades JIT compilation overhead for slightly more startup allocations. Since all of this happens at module-init time (not on any hot path), the tradeoff is very reasonable — just worth calling out explicitly.
Architectural suggestion: skip DelegateTestSource entirely
If reducing allocations and JIT overhead both matter, there's a further step available: store the delegates directly in the Sources collections without the wrapper object. Instead of wrapping in DelegateTestSource, the engine's consumption path could accept a parallel collection of raw delegate pairs. This would cut 3 allocations per class down to 2.
This is a more invasive change (touching the engine's consumption path), so it may be worth a follow-up PR rather than blocking this one.
Minor: ITestDescriptorSource doc vs. DelegateTestSource behaviour
The ITestDescriptorSource.EnumerateTestDescriptors doc says it "should be fast and allocation-free where possible". The DelegateTestSource implementation adds one level of delegate indirection compared to a direct static method call. Not a real concern in practice (module init only), but a slight inconsistency with the documented intent worth noting.
Summary
This is a solid, well-scoped optimization that correctly reduces JIT startup cost for large test suites. The snapshot tests are all updated correctly, and the approach is AOT-compatible. The allocation trade-off is acceptable given this is startup-only code. Consider the "store delegates directly without a wrapper type" approach as a follow-up for a further win if allocation reduction at startup becomes a priority.
Approved with the above observations noted.
There was a problem hiding this comment.
Code Review: perf: eliminate per-class TestSource .ctor JITs via delegate registration
Two previous reviews from claude[bot] were posted on this PR (likely from duplicate runs). The concerns raised in those reviews remain unaddressed, so I'll carry them forward and add a few new observations.
What's Good
- JIT reduction is real and correctly measured: N unique
sealed classtypes each requiring a separate.ctorJIT compilation → 1 sharedDelegateTestSourcetype. For a 1,000-class test suite, this is a genuine startup win. - AOT-safe:
static classwith static methods +Func<>delegates over static targets = no reflection, no dynamic code. Fully AOT-compatible. - Minimal blast radius: The engine's consumption of
ITestSource/ITestDescriptorSourceis unchanged.DelegateTestSourceis a clean adapter. - Snapshot hygiene: All 4 TFMs updated consistently.
Concern (Carried Forward): Allocation Count Went Up, Not Down
The previous reviews flagged this correctly. The new path allocates 3 objects per class instead of 1:
| Old | New | |
|---|---|---|
| Allocations per class at module-init | 1 (, zero-field object) | 3 ( #1 + #2 + ) |
A possible nuance: modern .NET caches delegate objects for static method groups, but the cache is keyed per-target-method — and each generated class has its own distinct / method. So each Initialize() call allocates all 3 objects. The tradeoff (more GC-tracked objects at startup for fewer JIT compilations) is still reasonable given this is module-init-only code, but it's the opposite of what the PR description implies.
Suggestion: The PR description says "Eliminates ~1,000 unique .ctor JIT compilations" — which is accurate. It would be clearer to also state that this trades JIT compilation reduction for a 3× increase in startup object allocations. This helps future contributors understand the tradeoff.
Architectural Suggestion (Carried Forward): Skip Entirely
The previous review suggested storing delegate pairs directly in rather than wrapping them in a object. This would cut 3 allocations per class to 2:
The engine would then handle this collection alongside the existing collection. This is more invasive and may not be worth it given the startup-only context — but if you're already in this area, it's worth considering as a follow-up.
New Observation: Public API Surface Expansion
The new Register(Type, Func<string, IReadOnlyList<TestMetadata>>, Func<IEnumerable<TestDescriptor>>) overload appears in all 4 PublicAPI snapshots. While SourceRegistrar is already decorated with [EditorBrowsable(Never)] in non-debug builds, this is still a public method, which means:
- It could be called by third-party code referencing TUnit.Core directly.
- The
Func<>signatures are now a public contract. If the method signature ofGetTestsorEnumerateTestDescriptorsever changes, the overload would need a version bump.
No action required for this PR, but worth keeping in mind when stabilising the public API surface.
Minor: Dispatch Overhead
At test discovery time, becomes (delegate invocation) instead of a direct virtual dispatch on the interface. One extra indirection. Not measurable in practice given this is discovery-only code — just noting it for completeness.
Summary
This is a valid, well-scoped startup optimization. The JIT reduction claim is accurate. The concerns from the previous reviews (allocation count increase, architectural alternative) are still open but not blocking — they could be addressed in a follow-up. The snapshot tests are correctly updated across all TFMs.
Approved. Consider noting the allocation trade-off explicitly in the PR description or a follow-up issue for completeness.
There was a problem hiding this comment.
Code Review: perf: eliminate per-class TestSource .ctor JITs via delegate registration
Two previous reviews from claude[bot] were posted on this PR (likely from duplicate runs). The concerns raised remain unaddressed, so I'm carrying them forward and adding a few new observations.
What's Good
- JIT reduction is real and correctly measured: N unique
sealed classtypes each requiring a separate.ctorJIT compilation → 1 sharedDelegateTestSourcetype. For a 1,000-class test suite, this is a genuine startup win. - AOT-safe:
static classwith static methods +Func<>delegates over static targets = no reflection, no dynamic code. Fully AOT-compatible. - Minimal blast radius: The engine's consumption of
ITestSource/ITestDescriptorSourceis unchanged.DelegateTestSourceis a clean adapter. - Snapshot hygiene: All 4 TFMs updated consistently.
Concern (Carried Forward): Allocation Count Went Up, Not Down
The previous reviews flagged this correctly. The new path allocates 3 objects per class instead of 1:
| Old | New | |
|---|---|---|
| Allocations per class at module-init | 1 (zero-field class instance) | 3 (delegate #1 + delegate #2 + DelegateTestSource) |
A possible nuance: modern .NET caches delegate objects for static method groups, but the cache is keyed per-target-method — and each generated class has its own distinct GetTests/EnumerateTestDescriptors method. So each Initialize() call allocates all 3 objects.
The tradeoff (more GC-tracked objects at startup for fewer JIT compilations) is reasonable since this is module-init-only code, but the PR description implies a pure win when it's actually an asymmetric tradeoff. Worth noting explicitly.
Architectural Suggestion (Carried Forward): Skip DelegateTestSource Entirely
Storing delegate pairs directly in Sources rather than wrapping them in a DelegateTestSource object would cut 3 allocations per class to 2, and remove the need for the wrapper type entirely. The engine would handle a parallel delegate collection alongside the existing ITestSource collection.
This is more invasive and touching the engine consumption path makes it a reasonable follow-up rather than a blocker here.
New Observation: Public API Surface Expansion
The new Register(Type, Func<string, IReadOnlyList<TestMetadata>>, Func<IEnumerable<TestDescriptor>>) overload appears in all 4 PublicAPI snapshots. While SourceRegistrar is decorated with [EditorBrowsable(Never)] in non-debug builds, this is still a public method. The Func<> signatures are now a public contract — if the method signatures of GetTests or EnumerateTestDescriptors ever change, this overload would need a version bump.
No action needed for this PR, but worth tracking when stabilising the public API surface.
Minor: Extra Dispatch in Hot(ish) Path
At test discovery time, DelegateTestSource.GetTests(id) becomes _getTests(id) (delegate invocation) rather than a direct interface virtual dispatch. One extra indirection. Not measurable in practice for discovery-only code — just noting for completeness.
Summary
This is a valid, well-scoped startup optimization that correctly reduces JIT compilation overhead for large test suites. The snapshot tests are correctly updated across all TFMs, and the approach is AOT-compatible.
Approved. The allocation trade-off is acceptable for module-init code. Consider documenting the tradeoff in the PR description and tracking the "skip DelegateTestSource" approach as a follow-up if startup allocation reduction becomes a priority.
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
TestSourceclasses frominternal sealed class(implementingITestSource+ITestDescriptorSource) tointernal static classwith static methodsDelegateTestSourceadapter inTUnit.Corethat wrapsFunc<string, IReadOnlyList<TestMetadata>>andFunc<IEnumerable<TestDescriptor>>delegates into the existingITestSource/ITestDescriptorSourceinterfacesGetTests, EnumerateTestDescriptors) instead ofnew TestSource(), so no per-class constructor is JIT-compiled.ctorJIT compilations in the 10,000-test repro (1,000 classes × 10 methods), replacing them with a single sharedDelegateTestSource..ctorJIT reduction: 15,000 → ~14,000 (~7% reduction), building on #5049.
Test plan
TUnit.Corebuilds cleanlyTUnit.Core.SourceGeneratorbuilds cleanlyTUnit.TestProjectbuilds with 0 errorsBasicTestsandDataDrivenTestspass at runtime on net10.0