Skip to content

perf: eliminate async state machine in TestCoordinator.ExecuteTestAsync#5216

Merged
thomhurst merged 3 commits intomainfrom
perf/reduce-async-chain-overhead
Mar 22, 2026
Merged

perf: eliminate async state machine in TestCoordinator.ExecuteTestAsync#5216
thomhurst merged 3 commits intomainfrom
perf/reduce-async-chain-overhead

Conversation

@thomhurst
Copy link
Owner

Summary

  • Convert TestCoordinator.ExecuteTestAsync from async ValueTask to non-async ValueTask
  • On the synchronous completion fast path (common case), avoids allocating an async state machine entirely
  • Async slow path handled by a static local function to prevent closure captures

Context

Profiling shows AsyncMethodBuilderCore.Start at 3.44% and AwaitUnsafeOnCompleted at 2.51% exclusive CPU time (~6% total). The test execution call chain is deeply nested with async wrappers. This eliminates one state machine per test execution on the fast path.

Other methods in the chain (TestRunner, TestExecutionGuard) have try/catch/finally blocks that require async and cannot be flattened.

Test plan

  • Builds successfully (0 errors)
  • BasicTests pass
  • CI passes

Remove the async/await from TestCoordinator.ExecuteTestAsync, which was
a thin wrapper that only awaited TryStartExecutionAsync and discarded
the bool result. By checking IsCompletedSuccessfully on the fast path
and using a static local function for the async fallback, we eliminate
one state machine allocation per test execution in the hot path.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

Summary

The optimization goal is sound — removing the async state machine overhead from — but there's a subtle correctness issue with the "fast path" claim, and a cleaner approach is available.


Issue: The Fast Path () Never Triggers

Looking at , both execution paths always return an incomplete task:

  1. Duplicate-execution path → — wraps a , always pending
  2. Normal execution path → — an async ValueTask<bool> that awaits executionFunc(), which is always async

The IsCompletedSuccessfully check on line 57 will never be true in practice. The "fast path" is dead code, and the actual improvement is only the slightly smaller state machine allocation in AwaitAndDiscard (which only captures t vs. the original capturing test, cancellationToken, and this).

This isn't wrong — it's a real, albeit minor, allocation reduction — but the PR description overstates the impact with "synchronous completion fast path (common case)".


Better Approach: Fix the Root Cause in

The real opportunity here is that the bool returned by TryStartExecutionAsync is never used — it's discarded in both the old and new code. Changing its return type to ValueTask would:

  1. Eliminate the type mismatch entirely (no wrapper needed)
  2. Allow ExecuteTestAsync to directly forward the task — zero state machine, zero wrapper
  3. Make the API cleaner (no misleading bool that callers must ignore)

Then becomes a pure one-liner with no state machine at all:

This is a strictly better outcome: eliminates the state machine entirely rather than replacing it with a smaller one, and removes a meaningless bool from the public API surface of TestExecutionGuard.


Minor: Exception Handling Correctness

The current PR is correct on exception handling: if the task faults synchronously (edge case), IsCompletedSuccessfully is false, so it falls through to AwaitAndDiscard which re-throws properly. No issue here.


Verdict

The change is safe and provides a small allocation reduction, but the "fast path" framing is misleading and the approach misses a simpler, more impactful refactor. Suggest revising TestExecutionGuard to return ValueTask instead of ValueTask<bool> to achieve the full optimization without the conditional complexity.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

Summary

The optimization goal is sound — removing the async state machine overhead from ExecuteTestAsync — but there's a subtle correctness issue with the "fast path" claim, and a cleaner approach is available.


Issue: The Fast Path (IsCompletedSuccessfully) Never Triggers

Looking at TestExecutionGuard.TryStartExecutionAsync, both execution paths always return an incomplete task:

  1. Duplicate-execution pathnew ValueTask<bool>(WaitForExistingExecutionAsync(existingTcs)) — wraps a Task, always pending
  2. Normal execution pathExecuteAndCompleteAsync(...) — an async method that awaits executionFunc(), which is always async

The IsCompletedSuccessfully check will never be true in practice. The "fast path" is dead code, and the actual improvement is only the slightly smaller state machine allocation in AwaitAndDiscard (which captures only t vs. the original capturing test, cancellationToken, and this).

This isn't wrong — it's a real, albeit minor, allocation reduction — but the PR description overstates the impact with "synchronous completion fast path (common case)".


Better Approach: Fix the Root Cause in TestExecutionGuard

The real opportunity here is that the bool returned by TryStartExecutionAsync is never used — it's discarded in both the old and new code. Changing its return type to ValueTask would:

  1. Eliminate the type mismatch entirely (no wrapper needed)
  2. Allow ExecuteTestAsync to directly forward the task — zero state machine, zero wrapper
  3. Make the API cleaner (no misleading bool that callers must ignore)
// TestExecutionGuard.cs
public ValueTask TryStartExecutionAsync(string testId, Func<ValueTask> executionFunc)
{
    if (_executingTests.TryGetValue(testId, out var existingTcs))
        return new ValueTask(WaitForExistingExecutionAsync(existingTcs));

    var tcs = new TaskCompletionSource<bool>();
    existingTcs = _executingTests.GetOrAdd(testId, tcs);

    if (existingTcs != tcs)
        return new ValueTask(WaitForExistingExecutionAsync(existingTcs));

    return ExecuteAndCompleteAsync(testId, tcs, executionFunc);
}

private async ValueTask ExecuteAndCompleteAsync(string testId, TaskCompletionSource<bool> tcs, Func<ValueTask> executionFunc)
{
    try
    {
        await executionFunc().ConfigureAwait(false);
        tcs.SetResult(true);
    }
    catch (Exception ex)
    {
        tcs.SetException(ex);
        throw;
    }
    finally
    {
        _executingTests.TryRemove(testId, out _);
    }
}

Then ExecuteTestAsync becomes a pure one-liner with no state machine at all:

public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
    return _executionGuard.TryStartExecutionAsync(test.TestId,
        () => ExecuteTestInternalAsync(test, cancellationToken));
}

This is a strictly better outcome: eliminates the state machine entirely rather than replacing it with a smaller one, and removes a meaningless bool from the public API surface of TestExecutionGuard.


Minor: Exception Handling Correctness

The current PR is correct on exception handling: if the task faults synchronously, IsCompletedSuccessfully is false, so it falls through to AwaitAndDiscard which re-throws properly. No issue here.


Verdict

The change is safe and provides a small allocation reduction, but the "fast path" framing is misleading and the approach misses a simpler, more impactful refactor. Suggest revising TestExecutionGuard to return ValueTask instead of ValueTask<bool> to achieve the full optimization without the conditional complexity.

…ing state machine in ExecuteTestAsync

The bool return from TryStartExecutionAsync was never used by any caller.
By returning ValueTask instead of ValueTask<bool>, ExecuteTestAsync becomes
a pure one-liner that directly returns the ValueTask — no async state machine,
no IsCompletedSuccessfully check, and no AwaitAndDiscard helper needed.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review

The previous review suggestions have been fully addressed in this PR. Here's what was done and my assessment:

Previous Feedback: Addressed ✅

The earlier review flagged that TryStartExecutionAsync returned a meaningless ValueTask<bool> that callers always discarded, and suggested changing it to ValueTask. This PR has implemented exactly that:

  • TestExecutionGuard.TryStartExecutionAsync: ValueTask<bool>ValueTask
  • WaitForExistingExecutionAsync: Task<bool>Task (removed the dead return false)
  • ExecuteAndCompleteAsync: ValueTask<bool>ValueTask (removed the dead return true)
  • ExecuteTestAsync: Now a pure expression-bodied forwarding call — zero state machine

Correctness Analysis

Exception propagation is correct: When executionFunc() throws, ExecuteAndCompleteAsync calls tcs.SetException(ex) before re-throwing. Any concurrent waiters in WaitForExistingExecutionAsync will receive the exception from tcs.Task, which is the right behavior (if a test execution fails, all waiters should observe the failure).

Duplicate-execution slow path: new ValueTask(WaitForExistingExecutionAsync(existingTcs)) wraps a Task in a ValueTask, which does allocate — but this is only the contention path (same test running concurrently), which is presumably rare. Acceptable tradeoff.

Fast path in TryStartExecutionAsync: The initial TryGetValue check before GetOrAdd is a valid optimization — avoids TaskCompletionSource allocation on the common case where the same test is already running. Clean.

Overall

This is now a clean, minimal change that achieves the stated goal: zero state machine allocation in ExecuteTestAsync on every call, with no misleading bool return cluttering the API. The code is correct, readable, and aligns well with TUnit's performance-first principle.

Approved.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Approved — previous feedback addressed, code is correct and clean.

This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to
1.21.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.21.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.6 -->

## What's Changed
### Other Changes
* perf: replace object locks with Lock type for efficient
synchronization by @​thomhurst in
thomhurst/TUnit#5219
* perf: parallelize test metadata collection for source-generated tests
by @​thomhurst in thomhurst/TUnit#5221
* perf: use GetOrAdd args overload to eliminate closure allocations in
event receivers by @​thomhurst in
thomhurst/TUnit#5222
* perf: self-contained TestEntry<T> with consolidated switch invokers
eliminates per-test JIT by @​thomhurst in
thomhurst/TUnit#5223
### Dependencies
* chore(deps): update tunit to 1.21.0 by @​thomhurst in
thomhurst/TUnit#5220


**Full Changelog**:
thomhurst/TUnit@v1.21.0...v1.21.6

## 1.21.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.0 -->

## What's Changed
### Other Changes
* perf: reduce ConcurrentDictionary closure allocations in hot paths by
@​thomhurst in thomhurst/TUnit#5210
* perf: reduce async state machine overhead in test execution pipeline
by @​thomhurst in thomhurst/TUnit#5214
* perf: reduce allocations in EventReceiverOrchestrator and
TestContextExtensions by @​thomhurst in
thomhurst/TUnit#5212
* perf: skip timeout machinery when no timeout configured by @​thomhurst
in thomhurst/TUnit#5211
* perf: reduce allocations and lock contention in ObjectTracker by
@​thomhurst in thomhurst/TUnit#5213
* Feat/numeric tolerance by @​agray in
thomhurst/TUnit#5110
* perf: remove unnecessary lock in ObjectTracker.TrackObjects by
@​thomhurst in thomhurst/TUnit#5217
* perf: eliminate async state machine in
TestCoordinator.ExecuteTestAsync by @​thomhurst in
thomhurst/TUnit#5216
* perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync
by @​thomhurst in thomhurst/TUnit#5215
* perf: consolidate module initializers into single .cctor via partial
class by @​thomhurst in thomhurst/TUnit#5218
### Dependencies
* chore(deps): update tunit to 1.20.0 by @​thomhurst in
thomhurst/TUnit#5205
* chore(deps): update dependency nunit3testadapter to 6.2.0 by
@​thomhurst in thomhurst/TUnit#5206
* chore(deps): update dependency cliwrap to 3.10.1 by @​thomhurst in
thomhurst/TUnit#5207


**Full Changelog**:
thomhurst/TUnit@v1.20.0...v1.21.0

## 1.20.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.20.0 -->

## What's Changed
### Other Changes
* Fix inverted colors in HTML report ring chart due to locale-dependent
decimal formatting by @​Copilot in
thomhurst/TUnit#5185
* Fix nullable warnings when using Member() on nullable properties by
@​Copilot in thomhurst/TUnit#5191
* Add CS8629 suppression and member access expression matching to
IsNotNullAssertionSuppressor by @​Copilot in
thomhurst/TUnit#5201
* feat: add ConfigureAppHost hook to AspireFixture by @​thomhurst in
thomhurst/TUnit#5202
* Fix ConfigureTestConfiguration being invoked twice by @​thomhurst in
thomhurst/TUnit#5203
* Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by
@​thomhurst in thomhurst/TUnit#5204
### Dependencies
* chore(deps): update dependency gitversion.tool to v6.6.2 by
@​thomhurst in thomhurst/TUnit#5181
* chore(deps): update dependency gitversion.msbuild to 6.6.2 by
@​thomhurst in thomhurst/TUnit#5180
* chore(deps): update tunit to 1.19.74 by @​thomhurst in
thomhurst/TUnit#5179
* chore(deps): update verify to 31.13.3 by @​thomhurst in
thomhurst/TUnit#5182
* chore(deps): update verify to 31.13.5 by @​thomhurst in
thomhurst/TUnit#5183
* chore(deps): update aspire to 13.1.3 by @​thomhurst in
thomhurst/TUnit#5189
* chore(deps): update dependency stackexchange.redis to 2.12.4 by
@​thomhurst in thomhurst/TUnit#5193
* chore(deps): update microsoft/setup-msbuild action to v3 by
@​thomhurst in thomhurst/TUnit#5197


**Full Changelog**:
thomhurst/TUnit@v1.19.74...v1.20.0

## 1.19.74

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.74 -->

## What's Changed
### Other Changes
* feat: per-hook activity spans with method names by @​thomhurst in
thomhurst/TUnit#5159
* fix: add tooltip to truncated span names in HTML report by @​thomhurst
in thomhurst/TUnit#5164
* Use enum names instead of numeric values in test display names by
@​Copilot in thomhurst/TUnit#5178
* fix: resolve CS8920 when mocking interfaces whose members return
static-abstract interfaces by @​lucaxchaves in
thomhurst/TUnit#5154
### Dependencies
* chore(deps): update tunit to 1.19.57 by @​thomhurst in
thomhurst/TUnit#5157
* chore(deps): update dependency gitversion.msbuild to 6.6.1 by
@​thomhurst in thomhurst/TUnit#5160
* chore(deps): update dependency gitversion.tool to v6.6.1 by
@​thomhurst in thomhurst/TUnit#5161
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5163
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5162
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5166
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5167
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5168
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5169
* chore(deps): update dependency coverlet.collector to 8.0.1 by
@​thomhurst in thomhurst/TUnit#5177

## New Contributors
* @​lucaxchaves made their first contribution in
thomhurst/TUnit#5154

**Full Changelog**:
thomhurst/TUnit@v1.19.57...v1.19.74

Commits viewable in [compare
view](thomhurst/TUnit@v1.19.57...v1.21.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](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>
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