Skip to content

perf: investigate 14% queued-handoff slowdown with UnpairedAsyncSemaphore 2.1.0 #588

Description

@thomhurst

Observed regression

Replacing the wake-up semaphore in Kevlar's concurrency limiter with UnpairedAsyncSemaphore 2.1.0 increased the time for a guaranteed queued handoff from 2.081 us to 2.378 us (+14.3%). Repeating the original baseline returned 2.083 us. Allocation improved from 1.62 KB to 1.53 KB per pair of executions.

Phase Mean Error Allocated per pair
SemaphoreSlim baseline 2.081 us 0.0722 us 1.62 KB
UnpairedAsyncSemaphore 2.1.0 2.378 us 0.2109 us 1.53 KB
SemaphoreSlim repeat 2.083 us 0.0647 us 1.62 KB

This exercises the signal-style API added following #581. Permit ownership remains in the limiter's existing interlocked state; the semaphore only wakes queued callers. No limiter accounting rewrite is involved.

Reproduction

Start from Kevlar commit 3f078a96. Add this benchmark to benchmarks/Kevlar.Benchmarks for both versions:

using BenchmarkDotNet.Attributes;

namespace Kevlar.Benchmarks;

[MemoryDiagnoser]
public class ConcurrencyLimitContentionBenchmarks
{
    private readonly Shield _shield = Shield.ConcurrencyLimit(maxConcurrency: 1, queueLimit: 1);

    [Benchmark]
    public async Task<int> QueuedHandoff()
    {
        var release = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
        var running = _shield.ExecuteAsync(
            release,
            static (source, _) => new ValueTask<int>(source.Task));
        var queued = _shield.ExecuteAsync(static _ => new ValueTask<int>(42));
        release.SetResult(1);
        await running.ConfigureAwait(false);
        return await queued.ConfigureAwait(false);
    }
}

The completion source ensures the second execution queues before the first is released. One invocation measures the complete pair, including completion-source/task overhead, rather than just the semaphore handoff.

For the candidate:

  1. Add an AsyncSemaphore package reference to src/Kevlar/Kevlar.csproj, with central version 2.1.0 in Directory.Packages.props. Raise the existing Microsoft.Bcl.AsyncInterfaces central version to 9.0.8 to satisfy the netstandard2.0 dependency.
  2. In ConcurrencyLimitStrategy.cs, add using Semaphores;, change the field and constructor from SemaphoreSlim to UnpairedAsyncSemaphore, retaining initial count 0, and replace _semaphore.Wait(0) with _semaphore.TryWait().
  3. Leave all state accounting, cancellation handling, waits, and releases otherwise unchanged.

Run sequentially for baseline, candidate, then baseline:

dotnet run --project benchmarks/Kevlar.Benchmarks -c Release -- --filter '*ConcurrencyLimitContentionBenchmarks.QueuedHandoff' --job Short --iterationCount 10 --warmupCount 5 --noOverwrite

Measurement conditions and limitations

Measured on 2026-09-19: AsyncSemaphore 2.1.0, package source commit 008f1ea6205e5c2b90ea35c91e327fafba981660; BenchmarkDotNet 0.15.8; .NET SDK 10.0.401 / runtime 10.0.12; Windows 11 25H2; Intel Core i7-12700K, 20 logical cores.

Saved Release DLLs were measured sequentially as baseline, candidate, then baseline again on the same machine, with identical benchmark sources/settings. One launch, five warmups, ten measured iterations, MemoryDiagnoser. Error values are half the 99.9% confidence interval; KB values are rounded. The candidate was a local working-tree migration, not a published commit.

A shared performance reservation prevented cooperating builds/tests/benchmarks from overlapping. Desktop, WSL, Docker, and antivirus background CPU activity remained. These are local diagnostic observations, not isolated primitive measurements or CI acceptance. The comparison is against SemaphoreSlim, not an older AsyncSemaphore version. The final migration passed the solution build, 2,978 relevant tests, unchanged allocation gates, and net48 compatibility.

Requested investigation

Add equivalent signal-style handoff coverage to the package benchmarks, isolate primitive cost from the Kevlar pipeline, and profile queue publication, waiter completion, and continuation scheduling before choosing an optimization. Preserve cancellation behavior and the reduced allocation. The integration measurements do not establish which internal operation causes the slowdown.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions