Add perf experiment pipeline and fix v2 connection pool regressions - #4543
Add perf experiment pipeline and fix v2 connection pool regressions#4543mdaigle wants to merge 28 commits into
Conversation
Adds a third perf pipeline that A/B tests one runner-config switch against itself on the same source build, alongside the existing package-baseline and PR-baseline pipelines. The run scripts gain a general --switch-under-test / -SwitchUnderTest option (UseConnectionPoolV2, UseOptimizedAsyncBehaviour, UseManagedSniOnWindows) that writes two runner configs differing only in that key and hands one to each pass. These are AppContext switches latched process-wide, so they cannot be toggled between benchmarks in a single process. Both passes share one build, and the option is rejected alongside a source baseline so the delta stays attributable to one variable. The new pipeline is a separate file rather than a flag on the other two so that skipping Kusto is structural: both rows would share a DerivedRunId, PerfRun.Config is stamped once per run, and nothing marks a row as an experiment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Perf comparison of the v2 pool against v1 showed large regressions on open/close-heavy benchmarks: OpenAsyncConnection +273%, RapidOpenCloseSingleThreadAsync +98%, RapidFireOpenClose +46-85% with allocation deltas of +110-212%. Three causes, all on the path taken when the pool already holds a usable connection: - Every async open dispatched to the thread pool via Task.Run with no inline attempt first. v1 tries a non-blocking acquisition on the caller's thread and only queues on a miss. - GetInternalConnection creates a timer-backed CancellationTokenSource before it knows whether it will wait, which also contends on the shared TimerQueue lock at higher parallelism. - GetInternalConnection is an async method, so it allocates a Task<DbConnectionInternal> even when it completes synchronously. Add TryGetPooledConnectionInline, which performs the transacted-store and idle-channel lookups that GetInternalConnection starts with and returns null on a miss. Both entry points try it first, so the common case avoids the thread pool hop, the CTS, and the Task allocation. It never opens a physical connection, matching v1's allowCreate: false, so no caller thread blocks on network I/O. Does not address the sync SteadyStateOpenQueryClose regression, which comes from sync waiters serializing behind the process-wide _syncOverAsyncSemaphore. That semaphore bounds thread pool blocking process-wide and should not be made per-pool; removing the regression needs the sync-over-async channel wait redesigned. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new perf “switch experiment” pipeline to A/B test a single runner-config/AppContext switch on the same commit, and applies an optimization to the v2 channel-based connection pool to remove avoidable allocations and thread-pool dispatch on the idle-connection fast path.
Changes:
- Introduces
sqlclient-perf-experiment.ymlto run baseline/current as the same source with exactly one switch flipped (no Kusto ingestion by design). - Updates perf runner scripts (
run-perf-tests.sh/.ps1) and the interleaving orchestrator to support per-variantRUNNER_CONFIGoverrides for switch A/B. - Adds
TryGetPooledConnectionInlinetoChannelDbConnectionPool.TryGetConnectionto satisfy idle/transacted requests inline and avoidTask.Run/CTS/Task<T>allocations; adds unit tests covering sync and async inline completion.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs | Adds unit tests verifying idle-connection requests complete inline for both sync and async paths. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | Adds a pooled-connection inline fast path (TryGetPooledConnectionInline) to avoid async state machine + CTS + thread-pool dispatch when an idle/transacted connection is immediately available. |
| eng/pipelines/perf/sqlclient-perf-experiment.yml | New manual perf pipeline that runs the same commit twice with one switch forced off vs on. |
| eng/pipelines/perf/scripts/run-perf-tests.sh | Adds --switch-under-test mode, generates per-variant runner configs, and wires them into interleaved/sequential runs. |
| eng/pipelines/perf/scripts/run-perf-tests.ps1 | Windows equivalent of switch-under-test A/B mode, including per-variant runner config generation and plumbing. |
| eng/pipelines/perf/scripts/interleave_perf.py | Adds optional per-variant environment overrides so baseline/current subprocesses can use different RUNNER_CONFIG values. |
| eng/pipelines/perf/README.md | Documents the experiment pipeline, its constraints, and why it must not be ingested into Kusto. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…pletionSource The first pass at the fast path completed the caller's TaskCompletionSource and returned false. That moved the thread pool dispatch rather than removing it: returning false sends SqlConnection.InternalOpenAsync down its asynchronous completion branch, which allocates an OpenAsyncRetry, a Tuple and a CancellationTokenRegistration, then schedules the continuation with ContinueWith(..., TaskScheduler.Default). That continuation costs a thread pool hop even though the result is already available. A re-run of the experiment pipeline showed the residual cost: OpenAsyncConnection was still +145% and RapidFireOpenClose still +23-58% with allocations up 70-163%, all of them async opens against an unsaturated pool that were hitting the fast path and paying for the handoff anyway. Return true with the connection instead, which is what WaitHandleDbConnectionPool does on its own inline hit, and leave the TaskCompletionSource untouched for the caller to abandon. InternalOpenAsync then takes its synchronous branch. Exceptions now propagate synchronously, which also matches v1; InternalOpenAsync already converts them into a faulted task. Update StressTestAsync, which awaited the TaskCompletionSource unconditionally and so hung once requests began completing inline. It now checks the completed flag first, matching the pattern already used in the pool transaction tests and by the pool's real callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs:1561
GetConnection_ConcurrentHeldLoad_GrowsPoolToDemandassertsfactory.Created == parallelism, butRunWorkersAsyncdoes not synchronize worker start. Because tasks can begin at different times, some workers may start after others have already returned a connection and end up reusing it, causingfactory.Createdto be <parallelismand making this test timing/scheduling-sensitive (potentially flaky in CI). Consider adding a start barrier (e.g.,CountdownEvent+ManualResetEventSlim) so all workers contend concurrently before any can return, or relax the assertion to a range that still detects the “serialized to 1 connection” failure mode without requiring perfect simultaneity.
// Act: workers hold their connections, so none can be reused and the pool must grow.
await RunWorkersAsync(pool, parallelism, iterationsPerWorker, holdMilliseconds: 25);
// Assert: one connection per concurrent caller, and no more.
Assert.Equal(parallelism, factory.Created);
576b70c to
4c3058e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
eng/pipelines/perf/scripts/run-perf-tests.ps1:150
- The comment above the baseline-selector validation still says there are "two baseline selectors" and that "the two baseline selectors are mutually exclusive", but this script now supports three selectors (BaselineVersion, BaselineSourceRef, and SwitchUnderTest). This makes the comment misleading for future maintenance and review.
# The two baseline selectors describe different builds of the same "baseline" pass, so requesting
# both is always a mistake; fail fast rather than silently honouring one of them.
if ((-not [string]::IsNullOrEmpty($BaselineVersion)) -and (-not [string]::IsNullOrEmpty($BaselineSourceRef))) {
throw "-BaselineVersion and -BaselineSourceRef are mutually exclusive."
A switch experiment flips intended behaviour, so benchmarks that measure that behaviour regress by design. UseConnectionPoolV2 is the motivating case: ChannelDbConnectionPool opens physical connections concurrently, where WaitHandleDbConnectionPool serialises growth behind a Semaphore(1, 1), and ConnectionPoolStressRunner.RapidFireOpenClose measures a cold-start burst where the extra parallel opens have nothing to amortise against. With nowhere to record that, the same benchmarks get re-investigated every run and --fail-on-regression is unusable for experiments. Add an optional per-switch annotation file at expected-differences/<SwitchName>.json, picked up automatically by --switch-under-test. Matching entries are reported as expected differences, grouped under their reason in comparison.md, excluded from the confirmed regression count and ignored by the regression gate. Two limits keep the annotation honest: a reason is required, and only regressions are reclassified, so an annotated benchmark that comes back unchanged or improved keeps its real status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (1)
eng/pipelines/perf/README.md:270
- The README states that the switch-experiment baseline requires “no second build at all”, but the scripts only reuse a single build in the interleaved path. In sequential mode,
run-perf-tests.sh/.ps1still run the baseline and current passes separately (and therefore build twice). Clarifying this avoids misleading users who select sequential mode.
- **Baseline (switch experiment)**: no second build at all — `--switch-under-test` measures one
source tree twice, so the scripts build the `current` variant once and point both passes at it,
differing only in the runner config each pass is handed. Used by the experiment pipeline.
Sync callers on ChannelDbConnectionPool waited for an idle connection by driving Channel.ReadAsync from a blocked thread, so the wait could only be released by a thread pool continuation. Blocked sync callers occupy the very threads that continuation needs, so a static SemaphoreSlim sized ProcessorCount/2 capped how many sync callers could wait at once. That guard is process-wide and shared across every pool. IdleConnectionChannel now gates reads on a counting SemaphoreSlim, released directly by the writing thread. Sync callers block on SemaphoreSlim.Wait, which needs no continuation, so the starvation risk and the throughput cap both go away. Async callers gate on the same semaphore to keep the counts aligned, and channel completion is surfaced through a linked token so waiters still see ChannelClosedException. ConnectionPoolRampRunner covers what RapidFireOpenClose cannot. That benchmark starts cold but releases each connection immediately, so one physical connection satisfies every caller and slow pool growth wins. The new runner keeps the cold pool and holds each connection until all callers have one, so the pool must open N connections and concurrent creation is what is measured. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
eng/pipelines/perf/README.md:279
- The README claims the switch-experiment baseline does “no second build at all”, but the perf scripts only reuse a single build output in interleaved mode. In sequential mode (
RunMode=sequential), both run-perf-tests.sh and run-perf-tests.ps1 still build the baseline and current passes separately (see run-perf-tests.sh run_pass builds the project per pass, and run-perf-tests.ps1 Invoke-PerfPass likewise). This doc line should be qualified so users don’t assume sequential runs avoid the second build.
- **Baseline (switch experiment)**: no second build at all — `--switch-under-test` measures one
source tree twice, so the scripts build the `current` variant once and point both passes at it,
differing only in the runner config each pass is handed. Used by the experiment pipeline.
eng/pipelines/perf/sqlclient-perf-experiment.yml:83
- This comment says “both passes here share ONE build” as a general statement, but that’s only true when
benchmarkRunModeis interleaved. In sequential mode, the run scripts still perform a build per pass (baseline then current), even for switch experiments. Tweaking the comment to call out the interleaved-only behavior will keep the pipeline self-documentation accurate.
# Maximum time (minutes) the template waits for the benchmark run on the VM before timing out.
# Matches the nightly pipeline rather than the PR pipeline: both passes here share ONE build (the
# source is identical on both sides), so there is no second driver build to pay for.
Pooled checkout coverage had a hole. RapidFireOpenClose is async-only, and the only concurrent sync coverage was ConnectionPoolContentionRunner, which holds a connection for a query against a saturated pool. Nothing measured concurrent sync checkout against a warm, non-saturating pool - which, after the pre-warm fix, is exactly the inline fast path this branch adds. The two paths diverge inside the pool, so this is worth measuring separately: the V2 pool has no synchronous channel read, so a sync caller that misses the inline fast path runs the async wait synchronously rather than awaiting it. Workers run on threadpool threads deliberately, matching the existing runners: sync database calls in ASP.NET run there. Also documents why ConnectionPoolChurnRunner is not made redundant by this. Its inner loop is identical, but it is single-threaded against a single pooled connection, so it carries no scheduling component and is far more sensitive: its sync and async variants agreed to within 0.3 pp on the last run, while RapidFireOpenClose's duplicate-workload parameter pairs disagreed by ~20 pp. Churn answers "did per-checkout cost or allocation move", the stress runner answers "did hand-off between threads move". A regression in one with a flat result in the other is the signal that separates them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The two pools order idle connections differently: the legacy pool pops from a ConcurrentStack (LIFO), the V2 pool reads from an unbounded Channel (FIFO). The legacy pool therefore keeps handing back the connection just released, while V2 cycles through every idle connection in turn. That difference was untestable. Churn held MinPoolSize=1, where both orderings return the only connection there is. ConnectionPoolStressRunner does reach depth 50-100, but its minimum Parallelism is 10, so those rows carry scheduling noise that swamps a per-checkout locality effect. Parameterise Churn on pool depth instead, and pre-warm to that depth so every measured open is a pure checkout. Opens all connections before releasing any, since releasing as we go would let the pool serve the same connection back and collapse every depth to 1. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
taskCount was Math.Max(Parallelism, MaxPoolSize * 2). MaxPoolSize * 2 is 100 or 200 and Parallelism never exceeds 25, so the parameter had no effect: the benchmark ran six configurations that were only two distinct workloads, with the other four exact duplicates. Use MaxPoolSize + Parallelism instead. Saturating the pool already takes MaxPoolSize tasks before anyone waits, so Parallelism is meaningful here as the oversubscription amount, which is the depth of the queue of waiting callers. All six configurations are now distinct and queue depth varies as intended. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
eng/pipelines/perf/scripts/run-perf-tests.sh:832
- When -SwitchUnderTest is used with the legacy sequential run mode, this path still runs run_pass twice (baseline + current). run_pass always performs a dotnet build, so the same source is rebuilt twice even though switch A/B is intended to compare one build against itself. This adds several minutes of avoidable VM time when benchmarkRunMode=sequential and also conflicts with the experiment pipeline’s “single build” rationale.
Consider either (a) forbidding sequential mode when --switch-under-test is set, or (b) reworking the sequential path to build once (like the interleaved switch A/B path already does) and run two passes from the same output directory while only varying RUNNER_CONFIG.
run_pass "baseline" "${baselineProject}" ${baselineBuildArgs[@]+"${baselineBuildArgs[@]}"}
if [[ -n "${switchUnderTest}" ]]; then
export RUNNER_CONFIG="${CURRENT_RUNNER_CONFIG}"
fi
run_pass "current" "${PERF_PROJECT}"
eng/pipelines/perf/scripts/run-perf-tests.ps1:811
- In legacy sequential mode, -SwitchUnderTest still invokes Invoke-PerfPass twice (baseline + current). Invoke-PerfPass always performs a dotnet build, so the same source is rebuilt twice even though the switch experiment is meant to compare a single build against itself with only RUNNER_CONFIG flipped.
Consider either disallowing sequential mode when -SwitchUnderTest is set, or refactoring the sequential path to reuse a single Build-Variant output directory (similar to the interleaved switch A/B optimization) and only run two processes with different RUNNER_CONFIG values.
if (-not [string]::IsNullOrEmpty($SwitchUnderTest)) { $env:RUNNER_CONFIG = $BaselineRunnerConfig }
Invoke-PerfPass "baseline" $BaselineProject $BaselineBuildArgs
if (-not [string]::IsNullOrEmpty($SwitchUnderTest)) { $env:RUNNER_CONFIG = $CurrentRunnerConfig }
Invoke-PerfPass "current" $PerfProject @()
Pairs it with RapidFireOpenCloseSync so the two variants are named symmetrically. Updates the two cref references in ConnectionPoolChurnRunner and ConnectionPoolRampRunner that pointed at the old name. The old name exists on main, so a PR-vs-main comparison will not match this row. That is the correct outcome rather than a loss: the benchmark now pre-warms the pool where main clears it every iteration, so the two are no longer measuring the same thing and a delta between them would be spurious. compare_perf.py unions the baseline and current key sets and only computes a delta when both sides are present, so the unmatched row reports no delta instead of failing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Setup now establishes up to a hundred physical connections back to back at the larger PoolDepth, and the default 15s connect timeout is tight for that against a loaded remote server. ConnectionPoolStressRunner already uses 60 for the same reason, since it pre-warms to MaxPoolSize. This cannot move the measurement. The pool is pre-warmed and driven by a single thread against MaxPoolSize=100, so no measured open ever waits for a connection, and the timeout only bounds waiting. Baseline and current get the same value. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The sync acquire path parked on a ManualResetEventSlim behind a static ProcessorCount/2 SemaphoreSlim. Both primitives block opaquely, so the thread pool never learned the thread was stalled. Because the idle channel is created without AllowSynchronousContinuations, the continuation that hands a waiter its connection is queued to the thread pool, so a saturated pool ended up waiting on work it had given the thread pool no reason to schedule. Block on the read's Task instead. Task-based blocking notifies the thread pool, which injects a worker immediately rather than after its slow starvation heuristic. Drop the semaphore: it never prevented sync work from consuming thread pool threads, since waiting on it blocks just as opaquely, and it starved the continuations its waiters depended on. Measured on a 10-core host with 50 sync waiters against a 3-connection pool, 1000 acquires: the old shape failed to complete 1000 ops in 20s despite the thread pool growing to 48 threads, because injected threads picked up queued work items ahead of the queued continuations. Blocking on the Task completes the same workload in 1.9s. Keeping the semaphore but fixing the wait still takes 19.3s, confirming the gate is a second instance of the same problem. Deadlock exposure is unchanged: with thread injection disabled, the old and new shapes both stall, so the semaphore was not providing the protection its comment claimed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| using var conn = new SqlConnection(_connectionString); | ||
| conn.Open(); | ||
| using var cmd = conn.CreateCommand(); | ||
| cmd.CommandText = "SELECT 1"; | ||
| _ = cmd.ExecuteScalar(); | ||
| var result = cmd.ExecuteScalar(); | ||
| // Dispose returns the connection to the pool. |
ReadChannelSyncOverAsync blocks on the idle channel read, which is the classic sync-over-async deadlock shape under a single-threaded SynchronizationContext. It is safe only because IdleConnectionChannel.ReadAsync awaits with ConfigureAwait(false), so the completion never needs the context the blocked thread is holding. Nothing enforced that, and the invariant lives in a different file from the code that depends on it. Record it at both ends. Comment-only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolContentionRunner.cs:117
- This introduces an unused local
result, which will raise CS0219 (assigned but never used). The repo treats warnings as errors, so this can break the build. If the intent is just to execute the query, discard the result instead of storing it.
using var conn = new SqlConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1";
var result = cmd.ExecuteScalar();
// Dispose returns the connection to the pool.
Description
Two related changes: a new perf pipeline for A/B testing AppContext switches, and the first fix for regressions it surfaced in the v2 connection pool.
Perf experiment pipeline
The existing perf pipelines cover two use cases: PR vs main, and main vs a published baseline. In both, any AppContext switches apply to baseline and current alike, so they cannot measure the effect of the switch itself.
This adds a third use case: run the same commit against itself with a perf-sensitive switch on in one variant and off in the other.
eng/pipelines/perf/sqlclient-perf-experiment.ymlwith aswitchUnderTestdropdown (defaultUseConnectionPoolV2).run-perf-tests.sh/.ps1take a general--switch-under-test/-SwitchUnderTest.interleave_perf.pyapplies a per-variant runner-config override.Two things worth calling out:
InProcessEmitToolchaininBenchmarkConfig.cspins benchmarks to the host process, so AppContext switches cannot be varied within a single BenchmarkDotNet run.The pipeline drops the
useManagedSni/useConnectionPoolV2/useOptimizedAsyncBehaviourparameters the other pipelines expose. ADO boolean parameters always emit a value, so leaving them in would fire the "flag ignored" warning on every run. Therunnerconfig.jsoncdefaults already match what those parameters defaulted to.v2 connection pool fast path
Running the above with
switchUnderTest: UseConnectionPoolV2showed large regressions on open/close-heavy benchmarks:OpenAsyncConnection+273%,RapidOpenCloseSingleThreadAsync+98%,RapidFireOpenClose+46-85% with allocation deltas of +110-212%.Three causes, all on the path taken when the pool already holds a usable connection:
Task.Runwith no inline attempt first. v1 tries a non-blocking acquisition on the caller's thread (allowCreate: false) and only queues a pending request on a miss. The 3.6 us to 13.3 us jump onOpenAsyncConnectionis thread pool round-trip latency for trivial work.GetInternalConnectioncreates a timer-backedCancellationTokenSourcebefore it knows whether it will ever wait. That is an allocation plus aTimerQueueTimerregistration, which also contends a shared lock at higher parallelism. This is the allocation delta on the uncontended benchmarks above; the pool-stress runners are a separate cause, covered below.GetInternalConnectionis anasyncmethod, so it allocates aTask<DbConnectionInternal>even when it completes synchronously.TryGetPooledConnectionInlineperforms the transacted-store and idle-channel lookups thatGetInternalConnectionbegins with and returns null the moment neither can satisfy the request. Both the sync and async entry points try it first, so the common case avoids all three costs at once. It deliberately never callsOpenNewInternalConnection, so no caller thread blocks on network I/O.Remaining differences after the fix
Latest run: 173 benchmarks, 10% threshold, 3 interleaved confirmation runs. 10 confirmed regressions, 3 unconfirmed, 51 improvements. 38 benchmarks flag on time or allocation. Allocation is reported only and never gates the build (
compare_perf.pysets status frommeanDeltaPctalone), so of the 31 allocation increases above 10%, 24 are on benchmarks that got faster.Scoping first. Benchmarks with no pool checkout in the measured body (37) have a max allocation delta of +0.05%, and those amortising one checkout over real work (67) max at +1.41%. Zero above 10% in either group; all 31 are checkout-dominated.
ParallelAsyncConnectionRunnerparameterises pooling directly, so it isolates the pool from everything else in the same benchmark:The 38 flagged benchmarks break down as 30 concurrent pool growth, 3 threadpool saturation, 1 saturated async waiter allocation, and 4 noise. Each is covered below.
A. Concurrent pool growth (30 of 38) — benchmark defect, now fixed. The flagged
ConnectionPoolStressRunnercases plusParallelAsyncConnectionRunner.OpenConnectionsConcurrently. Both runnersClearAllPools()in[IterationCleanup], so every iteration re-established physical connections inside the measured body. v1 serialises pool growth behind aSemaphore(1, 1); v2 deliberately does not, so it creates more physical connections, finishes sooner, and allocates more. A physical connection costs the same in both pools (v1 57,275 B, v2 57,701 B), and dividing each benchmark's allocation delta by itsLogin7Countdelta lands on 55.5-59 KB in every shape tested.Rather than explain that away,
ConnectionPoolStressRunnerhas been fixed (ab21302): it now pre-warms the pool to full capacity in[GlobalSetup]and no longer clears it between iterations, so its bodies only exercise checkout and return. Establishing a connection costs milliseconds against microseconds for a pooled checkout, so any creation left in the measured body swamps the cost the class exists to measure. The 27ConnectionPoolStressRunnerrows quoted here therefore predate that fix and will be re-measured on the next pipeline run.ParallelAsyncConnectionRunneris deliberately left as-is: it reproduces #601/#979, where concurrent open storms against a cold pool are the actual subject, and it hasPooling=falsevariants.Three smaller benchmark defects were fixed alongside it, all of which also invalidate the stress rows quoted above:
RapidFireOpenCloseSyncadded andRapidFireOpenCloserenamed toRapidFireOpenCloseAsync(2ac2b2e, 2959100). The concurrent runner measured this workload on the async path only, so a sync/async split there could not be told apart from a scheduling difference. Result rows quoted elsewhere in this description predate the rename and appear under the old name. The old name still exists on main, so a PR-vs-main run will report this row without a delta rather than comparing it — which is correct, since the benchmark now pre-warms the pool where main clears it, and a delta across that change would be spurious.ConnectionPoolChurnRunnerparameterised on pool depth (2104d10). It was pinned at one pooled connection, which is the one depth at which the two pools cannot differ in reuse order: v1 pops idle connections from aConcurrentStack(LIFO) and reuses the connection just released, while v2 reads from an unboundedChannel(FIFO) and cycles through all of them. The runner now covers depth 1 and 100.PoolExhaustionRecoverynow sizes itself asMaxPoolSize + Parallelism(9ede8e0). It wasMath.Max(Parallelism, MaxPoolSize * 2), and since that second term is 100 or 200 whileParallelismnever exceeds 25, the parameter had no effect: six configurations were only two distinct workloads. Parallelism is now the oversubscription amount, which is the depth of the queue of waiting callers.ConnectionPoolRampRunnerwas added to test that claim rather than argue it. It keeps the cold pool but makes every caller hold its connection until all have connected, so both pools must create the same number of connections and the only remaining variable is how fast they get there:ColdStartRampP=10 / 25 / 50ColdStartRampAsyncP=10 / 25 / 50Holding connection count constant makes the allocation delta vanish and leaves v2 4-6x faster. That was the same behaviour
RapidFireOpenClosescored as a 33-75% regression, because it held connections for zero time and so measured a burst that never needed the extra connections.Two independent checks already agreed that the checkout path itself got faster, before any benchmark change.
ConnectionPoolChurnRunnerrunsRapidFireOpenClose's exact inner loop —new SqlConnection/OpenAsync/ dispose — single-threaded against a warm pool that is never cleared, and v2 wins there: −31.27% async and −31.54% sync, with allocation at +3.51% / +4.92% rather than +47–139%. (Those were measured at pool depth 1, before the depth axis above was added.)SqlConnectionRunner's pooled opens agree at −29% to −47%.One caveat on reading the old stress numbers precisely: every
Parallelismvalue (10, 20, 25) is below everyMaxPoolSizevalue (50, 100), and theMath.Max(20, ...)floor leavesParallelism20 and 25 running identical workloads at bothMaxPoolSizevalues. Those duplicate pairs report deltas 22.5 pp and 19.0 pp apart (+75.18% vs +52.68%, and +59.16% vs +40.12%), which puts this benchmark's noise floor at twice the 10% threshold.B. Threadpool saturation on the sync wait path (3 of 38).
SteadyStateOpenQueryCloseP=50/Max=10 at +136.27%. A sync waiter blocks inmres.Wait(), and withAllowSynchronousContinuationsoff the returning thread must queue the wake into a pool whose workers are all blocked, so it waits on thread injection. Two controls isolate that:SteadyStateOpenQueryClose(threadpool, sync)SteadyStateOpenQueryCloseDedicatedThreads(sync)SteadyStateOpenQueryCloseAsync(threadpool)Allocation is comparable across all three while time differs by 140 points, so the regression is not the pool's connection handling.
ConnectionPoolThreadPoolPressureRunnervaries only the thread floor and shows the same thing: MinWorkerThreads=8 gives +57.48% (3/3), MinWorkerThreads=128 gives +11.23% (2/3). The ample-pool variants stay under the threshold (Max=50 is −1.80%, Max=100 is +7.91%), so this is specific to Parallelism ≫ MaxPoolSize.Accepted as an application-configuration boundary: an application should keep parallelism below the threadpool worker count, and pre-warming the threadpool is not the driver's job. Enabling
AllowSynchronousContinuationswas measured (−7.8%) and rejected, becauseOpenNewInternalConnectionsits in the same retry loop and an inline resume could run a TCP connect, TLS handshake and login on a caller'sClose()thread.C. Saturated async waiter allocation (1 of 38).
SteadyStateOpenQueryCloseAsyncP=50/Max=10, +10.27% allocation while 4% faster. The only genuine per-operation allocation regression, and the only Async row with a meaningful delta (Max=50 is +0.19%, Max=100 is +0.77%). A saturation sweep holds flat at +24 B/op down to maxPool=25, then jumps to +1,241 (maxPool=10) and +1,481 (maxPool=5) on the async path only; an isolated microbenchmark predicted +10.05% for this shape against the +10.27% measured. 392 B of it is passing a cancellable token toReadAsync: 104 B defeatsUnboundedChannel's cached reader, 192 B is the CTS, 96 B its timer. Sync escapes because_syncOverAsyncSemaphorecaps concurrent readers.Not fixed here. Deferring CTS creation to the first blocking wait was implemented and measured: it saves 24 B of 1,481, because a saturated caller always blocks and so always needs the token. Removing the 392 B means reading with
CancellationToken.None, which needs an explicit waiter queue so a timed-out waiter can deregister without stranding the connection a pending read would swallow. That is the same restructuring B needs, so it is one workstream rather than two.D. Noise (4 of 38). The async large-data read benchmarks contribute the +68.23% (2/3) and two 1/3 flags, and
SequentialXmlReadRunner.ReadXmladds a fourth at +11.55% (1/3). Across all 16 large-data rows the deltas scatter from −13.92% to +68.23% with allocation pinned at ≈0.00% throughout, and three of those rows are flagged improvements of comparable size; the same 5 MB payload reads +68.23% with a 1 MB buffer and −13.92% with an 8 KB buffer. Both runners take one checkout and then read, so the switch has nothing to act on after connect. The previous run's two 1/3 flags (MarsOverheadRunner,BeginTransactionRunner) did not reproduce. (These ran asAsyncLargeDataReadRunner; that runner has since been reorganised on main intoLargeDataReadRunner/{Plaintext,AlwaysEncrypted}.)Two caveats worth stating.
SteadyStateOpenQueryCloseP=50/Max=10 moved from +66.61% to +136.27% between runs (both 3/3): this is a tail-latency effect, not a shifted median, so the magnitude is not a stable number to quote. And the MinWorkerThreads=128 control lands at +11.23% rather than parity, because its 50 blocked waiters are still threadpool threads competing for scheduling, which the dedicated-thread variant avoids.Separately, I investigated and ruled out redundant liveness probing as a cause of the original regressions. v2 calls
IsLiveConnectionup to 3x per cycle vs v1's 1x, butIsConnectionAliveis gated by a 5 ms window and a successful check resets the timer, so the extra calls collapse to a fewDateTime.UtcNowreads.Issues
N/A
Testing
Two unit tests added to
ChannelDbConnectionPoolTest:GetConnectionAsync_WithIdleConnection_ShouldCompleteInlineis the meaningful one. It asserts theTaskCompletionSourceis already completed whenTryGetConnectionreturns, which is impossible to observe if the work was dispatched viaTask.Run. Verified it fails without the fix.GetConnection_WithIdleConnection_ShouldReturnInlinecovers the sync path.The full connection pool unit test suite passes: 359 tests, green on 3 consecutive runs after merging main.
Two fixes came out of that merge and the review pass:
TryGetPooledConnectionInlineactivated the connection without recording the request, so a fast-path checkout went uncounted while its matching return still emitted a soft disconnect. Main's newDbConnectionPoolInstrumentationTestcaught it (152 counted instead of 400). The count now happens immediately before activation, matchingGetInternalConnection.ConnectionPoolRampRunnercould hang. Both ramp benchmarks only reached their rendezvous on the success path, so a caller that threw before signalling left the rest waiting forever, which would stall the perf job. Signalling now happens in afinallyand both waits are bounded.Not automated: the perf improvement itself. Plan is to re-run the experiment pipeline on this branch with
switchUnderTest: UseConnectionPoolV2and confirm the async cases move back toward parity.One gap worth flagging: the full unit test suite hangs in
SimulatedServerTestson macOS. I confirmed this is pre-existing by reproducing the identical hang on a clean tree, and those tests do not use the v2 pool.Guidelines
Please review the contribution guidelines before submitting a pull request: