Skip to content

perf: Optimize concurrency gate with sync TryEnter fast path#89

Merged
rian-be merged 1 commit into
mainfrom
develop
Jul 27, 2026
Merged

perf: Optimize concurrency gate with sync TryEnter fast path#89
rian-be merged 1 commit into
mainfrom
develop

Conversation

@rian-be

@rian-be rian-be commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem Statement

The MutationExecutionConcurrencyGate is called on every mutation execution. Every EnterAsync allocated an async state machine (ValueTask + task), even when both the global and per state semaphores were immediately available. In the common case (low contention, distinct states), this overhead was pure waste.

Additionally, stateless mutations (stateId is null/empty) still entered the full async ValueTask path, incurring the same overhead for a trivial no op state check.


Solution Overview

1. Split Stateless and Stateful Paths

if (string.IsNullOrWhiteSpace(stateId))
    return EnterGlobalOnlyAsync(cancellationToken);
return EnterWithStateAsync(stateId, cancellationToken);
  • Stateless path: skips ConcurrentDictionary lookup and state gate entirely
  • Stateful path: tries sync fast path before falling back to async

2. Sync TryEnter Fast Path

if (_globalGate.Wait(0, cancellationToken))
    return new ValueTask<Lease>(new Lease(_globalGate, null));
return SlowEnterGlobalOnlyAsync(cancellationToken);
  • Wait(0) is non blocking interlocked operation
  • Returns completed ValueTask<Lease> without async state machine allocation
  • Falls back to WaitAsync only when contended

3. Combined Global + State Gate Fast Path

if (_stateGates.TryGetValue(stateId, out var existing) && _globalGate.Wait(0, ct))
{
    if (existing.Wait(0, ct))
        return new Lease(_globalGate, existing);
    _globalGate.Release();
}
  • Single dictionary lookup + two non blocking acquires
  • On any contention, releases global gate and falls through to async path

Performance Impact

Benchmark Results
Benchmark Before After Delta
BatchScheduling (2 × 4) 18.12 us / 27.09 KB 13.54 us / 26.16 KB -25.3%
BatchScheduling (2 × 16) 71.95 us / 105.09 KB 52.21 us / 101.34 KB -27.4%
BatchScheduling (4 × 4) 36.71 us / 54.09 KB 27.13 us / 52.22 KB -26.1%
BatchScheduling (4 × 16) 140.05 us / 210.10 KB 104.11 us / 202.59 KB -25.7%
ParallelExecution (2) 4.324 us / 6.42 KB 3.237 us / 6.19 KB -25.1%
ParallelExecution (8) 17.557 us / 25.41 KB 12.537 us / 24.47 KB -28.6%
Optimized Path Analysis
Path Before After
Stateless, uncontended async state machine + ValueTask sync return, 0 alloc
Stateful, gate exists, uncontended async state machine + GetOrAdd + 2x WaitAsync sync return, 1 dict lookup + 2x Wait(0)
Any path, contended async state machine + 2x WaitAsync same async fallback

Key Design Decisions

Why Wait(0) instead of TryEnter pattern?

SemaphoreSlim.Wait(0) is the built in try enter API. It performs an Interlocked.CompareExchange on the semaphore count - returns true if available, false immediately if not. Zero blocking, zero allocation.

Why split EnterAsync into three methods?

Avoids one size fits-all async ValueTask method. The JIT can inline the non async helpers and elide the state machine allocation entirely when the fast path is taken. The slow path keeps the async keyword only where actually needed.

Why check dictionary existence before Wait(0) on global gate?

If the per state semaphore doesn't exist yet, the sync fast path can not succeed (no state gate to acquire). Checking TryGetValue first avoids acquiring the global gate only to immediately release it - small optimization that prevents unnecessary global gate traffic.

Why not AsyncLocal or callback based approach?

The gate is synchronous coordination primitive (semaphore based). AsyncLocal adds per await overhead. A callback based approach would change the public API surface. The current design preserves ValueTask<Lease> return type with zero API change.


Files Changed

MutationExecutionConcurrencyGate.cs

  • Split EnterAsync into stateless/stateful + fast/slow paths
  • Added Wait(0) sync fast path for uncontended scenario
  • Lease struct keeps direct SemaphoreSlim references
  • Removed unused ConcurrentBag pool field
  • Removed redundant XML comments (internal code)

Migration

Note

No breaking changes. The concurrency gate is internal. Public API (IMutationEngine, ExecuteAsync, Lease) is unchanged. No migration steps required.

closes #88

@github-actions github-actions Bot added the performance Performance improvements or regressions label Jul 27, 2026
@github-actions github-actions Bot added the runtime Runtime implementation and execution flow label Jul 27, 2026
@rian-be rian-be changed the title [Perf]: Optimize concurrency gate with sync TryEnter fast path [perf]: Optimize concurrency gate with sync TryEnter fast path Jul 27, 2026
@rian-be rian-be changed the title [perf]: Optimize concurrency gate with sync TryEnter fast path perf: Optimize concurrency gate with sync TryEnter fast path Jul 27, 2026
@rian-be
rian-be merged commit bfad86f into main Jul 27, 2026
37 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance improvements or regressions runtime Runtime implementation and execution flow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Perf]: Optimize concurrency gate with sync TryEnter fast path

1 participant