Skip to content

perf(source-gen): bound generated test-entry methods (data-driven startup JIT -45%) - #6909

Merged
thomhurst merged 3 commits into
mainfrom
perf/chunk-complex-test-entries
Sep 28, 2026
Merged

thomhurst merged 3 commits into
mainfrom
perf/chunk-complex-test-entries

Conversation

@thomhurst

@thomhurst thomhurst commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

For data-driven tests, TUnit's generated test-source static constructors were extremely expensive to JIT. At 10,000 [Arguments] tests, the generated code cost 3.5 seconds of JIT CPU during startup. The equivalent plain tests cost 0.8 seconds. This is a large part of TUnit's lag behind MSTest in the DataDriven scenario of meziantou's framework benchmark.

Root cause

Each Entries element is a TestEntryFactory.CreateWithClassMetadata(...) call. For data-driven tests, some of its arguments build nested objects: new IDataSourceAttribute[] { new ArgumentsAttribute(...) }, ParameterMetadataFactory.Create(typeof(...), ..., new ConcreteType(...), ...), and typeof(...) return types. Each of these nested calls runs while the outer call's already-evaluated arguments are still on the IL evaluation stack. The JIT spills those pending arguments into new temporaries at every nested call.

As a result, the number of temporaries grows with the number of entries in the method, and tier-0 JIT time grows faster than linearly:

Per 100-test class cctor IL JIT time
Plain [Test] 7.3 KB ~6 ms
[Test, Arguments(n)] 17 KB ~60 ms

Plain entries pass only constants and cached fields, so they are unaffected.

Fix

When a class has more than 10 entries and its entries contain nested construction (data sources, parameters, dependencies, or a non-void return type), Entries is now filled by __FillEntriesN methods of 10 entries each:

public static readonly TestEntry<T>[] Entries = __CreateEntries();
private static TestEntry<T>[] __CreateEntries()
{
    var entries = new TestEntry<T>[100];
    __FillEntries0(entries);
    // ...
    return entries;
}
private static void __FillEntries0(TestEntry<T>[] entries)
{
    entries[0] = TestEntryFactory.CreateWithClassMetadata<T>(...);
    // ... 10 entries per method
}

Plain test classes and classes with 10 or fewer entries are emitted exactly as before. Only 5 snapshot tests change: the large parameterised classes.

I chose the chunk size by measuring a 10,000-test data-driven suite. Generated-code JIT CPU for each chunk size:

Chunk size Generated-code JIT
none (today) 3.3 s
1 (method per entry) 3.0 s (per-method overhead dominates)
5 1.9 s
10 1.7 s
25 2.3 s
50 3.8 s

Benchmarks

Packages built from main vs this branch; meziantou's harness project shapes; 10,000 tests, 100 per class.

JIT CPU from runtime JIT events (MethodJittingStarted to MethodLoadVerbose). This metric is robust to machine load; two runs each:

Scenario Generated-code JIT before Generated-code JIT after Whole-process JIT before Whole-process JIT after
DataDriven 10k 3.63 s / 3.47 s 1.93 s / 2.00 s 6.15 s / 6.57 s 4.80 s / 4.84 s
Bare 10k (unchanged code) 0.77 s / 0.81 s 0.58 s / 0.71 s — —

Wall clock, DataDriven 10k, interleaved A/B (4 rounds of 5 runs, medians in ms). Another heavy process was running on the machine, so absolute values are inflated; the relative difference was consistent across rounds:

Round Before After
1 3249 2962
2 2897 2670
3 2869 2680
4 2971 2796

Test plan

  • TUnit.Core.SourceGenerator.Tests pass on net10.0, net9.0, net8.0 and net472; 5 snapshots updated (the large parameterised classes: ArgsAsArrayTests, ConflictingNamespaceTests, DecimalArgumentTests, MatrixTests, STAThreadTests)
  • TUnit.TestProject (source-gen mode): MatrixTests (275), ArgsAsArrayTests (15), DecimalArgumentTests (18) and STAThreadTests (1515) all pass, with the same test counts as main
  • Reflection mode is unaffected; this change only touches source-generated output.

Summary by CodeRabbit

  • Performance
    • Large test classes now have generated test entries populated in smaller batches, while preserving existing test metadata and behavior.
  • Tests
    • Added coverage for different entry counts and combinations of data-driven and plain test entries.

…ethods

Entries for data-driven tests construct nested objects (ArgumentsAttribute,
ParameterMetadata, return types) while the outer factory call's arguments sit
on the evaluation stack. The JIT spills them to fresh temporaries at every
nested call, so a 100-test class's static constructor (~17KB IL) took ~60ms
to JIT versus ~6ms for plain tests: superlinear in entry count.

Classes with more than 10 such entries now fill the Entries array from
__FillEntriesN methods of 10 entries each. Data-driven 10k suite: generated
code JIT 3.55s -> 1.96s CPU. Plain test classes are emitted unchanged.
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 15:34 — with GitHub Actions Active
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 15:34 — with GitHub Actions Active
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 15:34 — with GitHub Actions Active
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T15:37:37.985566Z 23a3ee3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: b3f9591b-6189-48a7-9908-cef4cb577155

📥 Commits

Reviewing files that changed from the base of the PR and between 6694858 and 2fcf2b3.

📒 Files selected for processing (3)
  • src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
  • tests/TUnit.Core.SourceGenerator.Tests/EntryChunkingTests.Test.verified.txt
  • tests/TUnit.TestProject/EntryChunkingTests.cs

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The source generator uses a direct array initializer unless nested-construction entries exceed 10. Above that threshold, it allocates the entry array and populates it through fill methods. New tests cover chunking limits, entry ordering, and generated output.

Changes

Test-entry generation

Layer / File(s) Summary
Select and emit entry construction
src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
The generator classifies entries that require nested construction and applies limits of 10 nested entries and 100 total entries per fill method. A shared writer emits entry factory calls for direct and chunked construction.
Test chunking boundaries and ordering
tests/TUnit.TestProject/EntryChunkingTests.cs, tests/TUnit.Core.SourceGenerator.Tests/EntryChunkingTests.cs
New test cases cover 10 and 11 data-driven entries, plain entries, interleaved entries, and the 100-entry fill-method limit. The source-generator test runs the fixtures and checks that generated files are present.
Verify generated output
tests/TUnit.Core.SourceGenerator.Tests/*.verified.txt
Updated snapshots show arrays populated through fill methods instead of inline initializers. Existing entries and associated metadata remain represented across target frameworks.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to 2fcf2

No identified issue needs resolution before merging; the changed generation paths retain the intended entry ordering and chunking behavior.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 66948

The generated entries are now built in smaller methods, but they retain the existing registration and factory behavior. No new security exposure was identified. Security-review coverage is incomplete, so the assessment is low risk rather than minimal risk.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The affected runtime scope is entry construction and discovery for generated test classes, rather than a newly exposed service or privileged sink.

Trust Boundaries and Controls

  • inferred — Entry creation still passes through the existing generator-to-Core factory and registration path. The new private fill methods do not establish a separate trust or identity transition.

Resilience and Maintainability Implications

  • inferred — Sequential initialization and completed-result caching preserve the existing boundary against discovery observing a partially filled array.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.06% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 194 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the source-generator performance change and its effect on data-driven test startup JIT time. It is concise and related to the main changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks each entry row
Ten nested hops before we go
A hundred fit within each run
Fill methods set them one by one
The snapshots show the paths in view
And tests count every burrow through

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23a3ee3235

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs Outdated
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Refactors test entry generation to split large initializers into chunks.

The PR appears safe to merge; no new actionable issue or outstanding finding remains.

Summary

The PR splits generated test-entry initialization into smaller methods for data-driven classes. Since the previous review, it also caps each fill method at 100 total entries and adds a fixture covering a long run of plain tests.

  • The new boundary preserves entry order and fills the generated array.
  • No new actionable issue was identified.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Class methods] --> B{More than 10 nested entries?}
  B -- No --> C[Single array initializer]
  B -- Yes --> D[Allocate Entries array]
  D --> E[Fill methods: at most 10 nested and 100 total entries each]
Loading

Reviews (3) · Last reviewed commit: "Cap total entries per fill method at 100"

@github-actions

Copy link
Copy Markdown
Contributor

Code Review — PR #6909

This PR splits the per-class generated `Entries` array into chunked `__FillEntriesN` methods (batches of ~10 entries) to reduce JIT compilation time for data-driven test classes with many entries. Changes are confined to `src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs` plus 11 mechanically regenerated `.verified.txt` snapshots.

Review approach: Traced the chunk-boundary math, `CodeWriter` indentation/brace state across the refactor, the `HasNestedEntryConstruction` heuristic against every `PreGenerate*` helper it depends on (confirming `categories`/`properties`/`dependsOn` array literals are correctly excluded since they contain no nested calls), naming-collision risk for the new `__CreateEntries`/`__FillEntriesN` methods, evaluation-order/side-effect preservation, and AOT/trim-safety parity with the existing `__CreateInstance`/`__Invoke`/`__Attributes` generated methods. Cross-checked the regenerated `ArgsAsArrayTests.Test.verified.txt` and `MatrixTests` snapshots (13-entry classes splitting correctly into indices 0–9 and 10–12) to confirm the generator's actual output matches the intended behavior.

Findings: None. No correctness bugs, dropped invariants, or violations of the project's AGENTS.md constraints (source-generator/engine parity doesn't apply here since this is generator-only output shaping; snapshot `.verified.txt` files were updated as required, no `.received.txt` committed).

Minor observation (non-blocking): There's no test targeting the exact chunk boundary (10 vs. 11 entries, with/without nested construction) in isolation — coverage is incidental via existing large test-project classes that happen to cross the boundary. Not a defect in the diff as written, just a potential gap if the chunk size constant is ever tuned in the future.

🤖 Generated with Claude Code

Only entries that build nested objects add JIT spill temporaries, so plain
entries no longer count toward a fill method's budget. A class is chunked
only when it has more than 10 nested-construction entries, and runs of plain
entries stay in the current fill method. Fully data-driven classes produce
the same output as before, while a large class of plain tests with a few
data-driven ones keeps its single array initializer instead of being split
into one method per 10 entries.

Adds snapshot coverage for the 10/11 boundary with and without nested
construction, a plain-heavy class with one data-driven test, and interleaved
plain and data-driven entries.
Comment thread src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code Review — PR #6909

Reviewed the source-generator chunking change in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs (ShouldChunkEntries, WriteChunkedEntries, WriteTestEntryFactoryCall, HasNestedConstruction), the new EntryChunkingTests fixtures, and all updated .verified.txt snapshots.

What I checked

  • Hand-traced the chunkStarts boundary math against the checked-in snapshots (InterleavedEntryTests, ElevenDataDrivenEntryTests, ArgsAsArrayTests/MatrixTests/DecimalArgumentTests) — matches exactly, including the interleaved-plain-runs edge case.
  • Confirmed CodeWriter indentation semantics support the new entries[i] = ... + WriteTestEntryFactoryCall pattern correctly.
  • TestEntryFactory.CreateWithClassMetadata has no ordering side effects, so splitting the single array initializer into __FillEntriesN methods preserves identical evaluation order/indices.
  • Static field initialization order (__classMetadata, __createInstance, __invoke, __attributes before Entries) is unchanged, so Entries = __CreateEntries(); still runs after its dependencies.
  • This is source-generator-only; nothing about collected metadata changes (only how generated code populates the array), so no TUnit.Engine reflection-mode parity work is needed per AGENTS.md, and none was touched.
  • EquatableArray<T>.AsArray() is a cheap no-alloc accessor, not LINQ.
  • Snapshot updates are committed as .verified.txt only; no stray .received.txt.
  • Test coverage is good: boundary cases at 10 vs 11 entries, plain-only, mixed, and interleaved constructions.

Result: No correctness bugs, regressions, or convention violations found. This aligns with the existing Codex and CodeRabbit automated reviews on this PR, which also reported no actionable findings — nothing outstanding from those to reconcile.

Approving from a correctness/design standpoint; nice minimal fix for the IL-stack-spill issue with solid boundary-condition test coverage.

Plain entries did not count toward a fill method's budget, so a chunked class
with a long run of plain tests could put hundreds of factory calls in one
__FillEntriesN method. A fill method now also closes at 100 entries in total.

100 matches the measured plain-class shape (~6ms JIT per 100 plain entries),
and each extra fill method costs ~0.15ms (from the 1-entry-per-method
measurement), about 2.5% of such a chunk. Classes with 10 or fewer
nested-construction entries are still never chunked, and fully data-driven
classes are unchanged.

Adds a fixture with 11 data-driven entries around 100 plain entries, split at
the total cap.
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 16:47 — with GitHub Actions Active
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 16:47 — with GitHub Actions Active
@thomhurst
thomhurst deployed to Pull Requests September 28, 2026 16:47 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown
Contributor

Review: bound generated test-entry methods (#6909)

Solid, well-measured performance fix. Summary of what I checked:

Correctness of the chunking logic (TestMetadataGenerator.cs, WriteChunkedEntries/ShouldChunkEntries/HasNestedConstruction): traced the boundary math by hand for 0/1/10/11 nested entries and for mixed plain/nested runs. Chunk starts are always monotonic, the first chunk always begins at 0, and the final chunk always ends at methods.Length, so there are no gaps, overlaps, or empty __FillEntriesN methods. WriteTestEntryFactoryCall is a faithful extraction of the previous inline loop body — every constructor argument (data sources, dependencies, method metadata, classMetadata/createInstance/invokeBody/methodIndex/createAttributes/attributeGroupIndex) is preserved identically between the chunked and non-chunked paths, just parameterized by the terminator (, vs ;).

Iteration history: the PR's three commits show the design tightening itself in exactly the right direction — from "chunk whenever nested-construction entries exceed 10" to also capping total entries per fill method at 100, which correctly closes the gap where a chunked class with a long run of cheap plain tests could otherwise dump hundreds of factory calls into a single __FillEntriesN. That was the one real edge case in the design, and it's already fixed with a dedicated fixture (ManyPlainOneDataDrivenEntryTests-style test with ~100 plain entries) and a clear commit message ("Cap total entries per fill method at 100") explaining the reasoning with numbers.

Engine/generator parity: TestEntryFactory.CreateWithClassMetadata and the Entries field's lazy-factory registration in SourceRegistrar were unchanged and untouched by reflection-mode code — this only changes the shape of generated source, not runtime metadata, so no TUnit.Engine counterpart is needed here, consistent with AGENTS.md.

AOT/trimming/hot-path constraints: no reflection introduced; all new logic (ShouldChunkEntries, WriteChunkedEntries) runs at compile time inside the Roslyn generator, not in any discovery/execution hot path.

Tests: good coverage — snapshot tests for the 10/11 nested-entry boundary, plain-only, interleaved plain/nested, and the new 100-entry total cap, plus TUnit.TestProject fixtures marked [EngineTest(ExpectedResult.Pass)] that exercise the generated code at runtime, not just via generator snapshots.

I didn't find any functional bugs, ordering hazards, or convention violations. No blocking changes requested.

Minor, non-blocking observations:

  • NestedEntriesPerFillMethod (10) and EntriesPerFillMethod (100) are similarly named constants with very different roles (one bounds expensive entries, the other bounds all entries in a chunk). The doc comments already explain this well, but a future reader skimming just the names could conflate them — not worth blocking on given the existing comments.
  • The tests/TUnit.TestProject/EntryChunkingTests.cs fixtures are necessarily repetitive (many near-identical [Test] methods to hit exact entry counts); that's inherent to this kind of boundary testing and reads fine as-is.

Nice work isolating the JIT-cost root cause (stack-spilled temporaries from nested constructor calls) and validating the chunk size empirically rather than guessing.

This was referenced Sep 29, 2026

This branch was successfully deployed

1 active deployment
Pull Requests — 2fcf2b36 Deployed Sep 28, 2026 by thomhurst via modularpipeline (ubuntu-latest) #19555
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.

1 participant