Skip to content

Add perf experiment pipeline and fix v2 connection pool regressions - #4543

Draft
mdaigle wants to merge 15 commits into
mainfrom
dev/mdaigle/perf-switch-experiment-pipeline
Draft

Add perf experiment pipeline and fix v2 connection pool regressions#4543
mdaigle wants to merge 15 commits into
mainfrom
dev/mdaigle/perf-switch-experiment-pipeline

Conversation

@mdaigle

@mdaigle mdaigle commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

  • New eng/pipelines/perf/sqlclient-perf-experiment.yml with a switchUnderTest dropdown (default UseConnectionPoolV2).
  • run-perf-tests.sh / .ps1 take a general --switch-under-test / -SwitchUnderTest.
  • interleave_perf.py applies a per-variant runner-config override.

Two things worth calling out:

  • This pipeline never ingests into Kusto. Its two variants are the same commit, so the results are not comparable to the trend data the other pipelines produce and would pollute it.
  • Two processes are required. InProcessEmitToolchain in BenchmarkConfig.cs pins benchmarks to the host process, so AppContext switches cannot be varied within a single BenchmarkDotNet run.

The pipeline drops the useManagedSni / useConnectionPoolV2 / useOptimizedAsyncBehaviour parameters the other pipelines expose. ADO boolean parameters always emit a value, so leaving them in would fire the "flag ignored" warning on every run. The runnerconfig.jsonc defaults already match what those parameters defaulted to.

v2 connection pool fast path

Running the above with switchUnderTest: UseConnectionPoolV2 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 (allowCreate: false) and only queues a pending request on a miss. The 3.6 us to 13.3 us jump on OpenAsyncConnection is thread pool round-trip latency for trivial work.
  • GetInternalConnection creates a timer-backed CancellationTokenSource before it knows whether it will ever wait. That is an allocation plus a TimerQueueTimer registration, 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.
  • GetInternalConnection is an async method, so it allocates a Task<DbConnectionInternal> even when it completes synchronously.

TryGetPooledConnectionInline performs the transacted-store and idle-channel lookups that GetInternalConnection begins 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 calls OpenNewInternalConnection, 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.py sets status from meanDeltaPct alone), 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.81%, and those amortising one checkout over real work (67) max at +1.41%. Zero above 10% in either group; all 31 are checkout-dominated. ParallelAsyncConnectionRunner parameterises pooling directly: Pooling=False gives +0.01%/+0.04%/+0.11% allocation, Pooling=True on the same concurrencies gives +70.55%/+113.16%/+120.56%.

A. Concurrent pool growth (28 of 38), by design. The flagged ConnectionPoolStressRunner cases plus ParallelAsyncConnectionRunner.OpenConnectionsConcurrently. Both runners ClearAllPools() in [IterationCleanup], so every iteration is a cold burst where connection creation dominates. v1 serialises pool growth behind a Semaphore(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 its Login7Count delta lands on 55.5-59 KB in every shape tested.

ConnectionPoolRampRunner was 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:

time Δ alloc Δ
ColdStartRamp P=10 / 25 / 50 −75.23% / −80.62% / −82.20% +0.08% / +0.58% / +0.64%
ColdStartRampAsync P=10 / 25 / 50 −73.42% / −80.93% / −82.89% −2.62% / −0.17% / +0.80%

Holding connection count constant makes the allocation delta vanish and leaves v2 4-6x faster. That is the same behaviour RapidFireOpenClose scores as a 33-75% regression, because it holds connections for zero time and so measures a burst that never needed the extra connections.

B. Threadpool saturation on the sync wait path (2 of 38). SteadyStateOpenQueryClose P=50/Max=10 at +136.27%. A sync waiter blocks in mres.Wait(), and with AllowSynchronousContinuations off 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:

variant, P=50 / Max=10 time Δ alloc Δ
SteadyStateOpenQueryClose (threadpool, sync) +136.27% +5.87%
SteadyStateOpenQueryCloseDedicatedThreads (sync) +5.61% +8.00%
SteadyStateOpenQueryCloseAsync (threadpool) −4.20% +10.27%

Allocation is comparable across all three while time differs by 140 points, so the regression is not the pool's connection handling. ConnectionPoolThreadPoolPressureRunner varies 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 (Max=50, Max=100) are unchanged or faster, 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 AllowSynchronousContinuations was measured (−7.8%) and rejected, because OpenNewInternalConnection sits in the same retry loop and an inline resume could run a TCP connect, TLS handshake and login on a caller's Close() thread.

C. Saturated async waiter allocation (1 of 38). SteadyStateOpenQueryCloseAsync P=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 to ReadAsync: 104 B defeats UnboundedChannel's cached reader, 192 B is the CTS, 96 B its timer. Sync escapes because _syncOverAsyncSemaphore caps 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 (3 of 38). AsyncLargeDataReadRunner contributes the +68.23% (2/3) and two 1/3 flags. Across all 16 of its 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. It takes one checkout and then reads, so the switch has nothing to act on after connect. The previous run's two 1/3 flags (MarsOverheadRunner, BeginTransactionRunner) did not reproduce.

Two caveats worth stating. SteadyStateOpenQueryClose P=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 IsLiveConnection up to 3x per cycle vs v1's 1x, but IsConnectionAlive is gated by a 5 ms window and a successful check resets the timer, so the extra calls collapse to a few DateTime.UtcNow reads.

Issues

N/A

Testing

Two unit tests added to ChannelDbConnectionPoolTest:

  • GetConnectionAsync_WithIdleConnection_ShouldCompleteInline is the meaningful one. It asserts the TaskCompletionSource is already completed when TryGetConnection returns, which is impossible to observe if the work was dispatched via Task.Run. Verified it fails without the fix.
  • GetConnection_WithIdleConnection_ShouldReturnInline covers the sync path.

The full connection pool unit test suite passes (320 tests, previously 318).

Not automated: the perf improvement itself. Plan is to re-run the experiment pipeline on this branch with switchUnderTest: UseConnectionPoolV2 and confirm the async cases move back toward parity.

One gap worth flagging: the full unit test suite hangs in SimulatedServerTests on 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:

mdaigle and others added 4 commits August 13, 2026 14:23
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>
Copilot AI lite review requested due to automatic review settings August 14, 2026 17:14
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 14, 2026
@mdaigle

mdaigle commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.yml to 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-variant RUNNER_CONFIG overrides for switch A/B.
  • Adds TryGetPooledConnectionInline to ChannelDbConnectionPool.TryGetConnection to satisfy idle/transacted requests inline and avoid Task.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.

@mdaigle mdaigle added this to the 7.1.0-preview3 milestone Aug 14, 2026
…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>
Copilot AI review requested due to automatic review settings August 14, 2026 19:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 17, 2026 17:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_GrowsPoolToDemand asserts factory.Created == parallelism, but RunWorkersAsync does 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, causing factory.Created to be < parallelism and 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);

@mdaigle
mdaigle force-pushed the dev/mdaigle/perf-switch-experiment-pipeline branch from 576b70c to 4c3058e Compare August 17, 2026 17:20
Copilot AI review requested due to automatic review settings August 17, 2026 17:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
Copilot AI review requested due to automatic review settings August 17, 2026 17:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/.ps1 still 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>
Copilot AI review requested due to automatic review settings August 17, 2026 18:33
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The semaphore-gated wait serves waiters in roughly arrival order but does not
guarantee it, so the docs should not promise first-come, first-served.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/IdleConnectionChannelTest.cs:344

  • This test mutates process-wide ThreadPool limits (SetMinThreads/SetMaxThreads). The UnitTests configuration enables parallel test collections, so running this concurrently with other tests can cause hangs/timeouts unrelated to the channel behavior being validated. This should be placed in a non-parallel xUnit collection (DisableParallelization=true) or otherwise isolated so no other tests execute while the ThreadPool is restricted.
                Assert.True(ThreadPool.SetMinThreads(1, minIo));
                Assert.True(ThreadPool.SetMaxThreads(1, maxIo));

                // Occupy the single available worker thread for the duration of the wait.
                ThreadPool.QueueUserWorkItem(_ => blocked.Wait());

Copilot AI review requested due to automatic review settings August 17, 2026 18:47
Measurement does not support it. On a saturated pool (50 sync workers,
MaxPoolSize 10, so 40 concurrent waiters against 5 permits), raising the
static semaphore from 5 to 100 permits changes nothing: 302 ms vs 280 ms at
5 ms hold, 60 ms vs 59 ms at 1 ms hold. The permit is released as soon as a
connection arrives, so it caps how many callers are blocked, not throughput.

Replacing the wait with a semaphore-gated channel measured identically to the
original in every regime tested (saturated and unsaturated, 0/1/5 ms hold,
thread pool and dedicated threads). It bought nothing and cost the FIFO
fairness the pool documents as a headline property, since SemaphoreSlim does
not guarantee release ordering and acquisition became two-stage.

The residual v2 sync penalty is a fixed per-handoff cost on the wait path,
visible only when the pool is saturated: +67% to +167% with zero work per
operation, falling to +5% to +9% once each operation does 1 ms of work, and
zero when Parallelism <= MaxPoolSize. That is a different and much smaller
problem than the one this change addressed.

Keeps ConnectionPoolRampRunner, which is unrelated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 17, 2026 18:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment on lines 435 to +438

# Apply the optional SqlClient behaviour overrides supplied by the pipeline. An empty value leaves
# the checked-in default untouched; otherwise the flag is forced to the requested boolean so the
# benchmarks run with (and PerfRun.Config records) exactly the requested behaviour.
# benchmarks run with (and PerfRun.Config records) exactly the requested behaviour. The
Comment on lines +356 to +357
$cfg.ConnectionString = "Server=tcp:$SqlServer,1433;User ID=sa;Password=$escapedPassword;Initial Catalog=$DbName;TrustServerCertificate=True;Encrypt=False;"
# Apply the optional SqlClient behaviour overrides supplied by the pipeline. An empty value
A sync Open() that waits blocks its thread. On threadpool threads that
competes with the threadpool itself, so a pool whose waiter wake-up needs a
queued continuation stalls until thread injection runs. The existing
benchmarks could not separate that from an intrinsic pool cost.

Adds a dedicated-thread variant of SteadyStateOpenQueryClose alongside the
threadpool one, and a ConnectionPoolThreadPoolPressureRunner that pins the
threadpool floor above and below the worker count so the effect is
reproducible rather than dependent on hill-climbing timing.

Existing benchmarks and their params are unchanged, to keep trend continuity.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 19:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolThreadPoolPressureRunner.cs:103

  • ThreadPool.SetMinThreads returns a bool indicating whether the requested minimums were applied. Since this benchmark depends on pinning the thread pool floor for reproducibility, it should fail fast if the call fails (and likewise if restoring the original values fails).
            ThreadPool.GetMinThreads(
                out _originalMinWorkerThreads, out _originalMinCompletionPortThreads);
            ThreadPool.SetMinThreads(MinWorkerThreads, _originalMinCompletionPortThreads);

SteadyStateOpenQueryClose runs 50 synchronous workers on thread pool threads
against a pool of 10, so most of them block in Open() and the thread pool has
no free worker left to run the waiter wake-up. That is thread pool saturation,
not pool throughput: medians match within 2% and the whole delta is tail
latency, which disappears when the thread pool floor is raised.

Keeping parallelism below the thread pool's worker count is the application's
responsibility, as is pre-warming the thread pool, so this is annotated rather
than fixed. ConnectionPoolThreadPoolPressureRunner characterises the boundary.

Enabling AllowSynchronousContinuations on the idle channel was considered and
rejected: it would resume the waiter's retrieval loop on the returning thread,
and that loop can reach OpenNewInternalConnection, which opens a physical
connection synchronously. A caller's Close() could then pay for another
caller's connect, TLS handshake and login.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 20:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:148

  • ConnectionPoolRampRunner.ColdStartRamp() can deadlock if any worker throws before calling allConnected.Signal() (e.g., Open fails). Other workers then block forever in allConnected.Wait(), stalling the perf run. Consider using a CancellationTokenSource and passing its token to Wait(...), cancelling it on any worker failure so the benchmark fails fast instead of hanging indefinitely.
        public void ColdStartRamp()
        {
            using var allConnected = new CountdownEvent(Parallelism);

            var tasks = new Task[Parallelism];
            for (int i = 0; i < Parallelism; i++)
            {
                tasks[i] = Task.Factory.StartNew(() =>
                {
                    using var conn = new SqlConnection(_connectionString);
                    conn.Open();

                    allConnected.Signal();
                    allConnected.Wait();
                    // Dispose returns the connection to the pool.
                }, TaskCreationOptions.LongRunning);
            }

            Task.WaitAll(tasks);
        }

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolThreadPoolPressureRunner.cs:109

  • ThreadPool.SetMinThreads returns a bool indicating whether the new minimums were applied. If this call fails, the benchmark is no longer running under the intended (pinned) thread pool floor, which can make results noisy/misleading. Consider checking the return value in both setup and cleanup and failing the benchmark run if the thread pool settings can't be applied/restored.
            ThreadPool.GetMinThreads(
                out _originalMinWorkerThreads, out _originalMinCompletionPortThreads);
            ThreadPool.SetMinThreads(MinWorkerThreads, _originalMinCompletionPortThreads);

            var builder = new SqlConnectionStringBuilder(s_config.ConnectionString)
            {
                Pooling = true,
                MaxPoolSize = MaxPoolSize,
                MinPoolSize = 0
            };
            _connectionString = builder.ConnectionString;
        }

        [GlobalCleanup]
        public void Cleanup()
        {
            ThreadPool.SetMinThreads(
                _originalMinWorkerThreads, _originalMinCompletionPortThreads);
        }

Comment on lines +103 to +117
tasks[i] = Task.Run(async () =>
{
using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();

// Hold the connection until every caller has one, forcing the pool to
// grow to Parallelism physical connections.
if (allConnected.Signal())
{
release.TrySetResult(true);
}

await release.Task;
// Dispose returns the connection to the pool.
});
Measured against the in-process TDS server. A physical connection costs ~57 KB
and both pools allocate the same per connection (v1 57,275 B, v2 57,701 B).
Across all six RapidFireOpenClose parameter sets, the allocation delta divided
by the extra Login7 count lands at 55.6-56.2 KB, so connection count accounts
for the whole delta with nothing left for per-operation overhead.

On a warm pool where neither implementation opens anything, v2's checkout and
return path allocates 24-48 B/op more than v1 (+0.2% to +0.5%).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:116

  • If any worker throws (e.g., OpenAsync fails), other workers can end up awaiting release.Task forever because release is only completed when the CountdownEvent reaches 0. That can hang the perf run instead of failing fast. Consider completing release with the exception so all workers unblock and the benchmark fails deterministically.
                        release.TrySetResult(true);
                    }

                    await release.Task;
                    // Dispose returns the connection to the pool.

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:143

  • If conn.Open() throws in any worker, that worker never signals the CountdownEvent and the remaining workers can block forever in allConnected.Wait(), hanging the run. Signaling in a finally avoids deadlocks and still preserves the intended steady-state behavior on success.
                    using var conn = new SqlConnection(_connectionString);
                    conn.Open();

                    allConnected.Signal();
                    allConnected.Wait();
                    // Dispose returns the connection to the pool.

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolThreadPoolPressureRunner.cs:94

  • ThreadPool.SetMinThreads returns a bool indicating whether the requested minimum was applied. If it fails (e.g., due to runtime limits), the benchmark no longer measures the intended configuration and results become misleading. Consider checking the return value and failing fast.
            ThreadPool.GetMinThreads(
                out _originalMinWorkerThreads, out _originalMinCompletionPortThreads);
            ThreadPool.SetMinThreads(MinWorkerThreads, _originalMinCompletionPortThreads);

Switch experiments surface intended behaviour changes as regressions. The
annotation file was one way to record that verdict; explaining it in review
is another, and it avoids a mute mechanism that has to be trusted.

Removes the per-switch JSON rules, their loader and matcher, the
expected-difference status and report section, and the --expected-differences
plumbing through both run-perf-tests wrappers.

The README guidance on writing benchmarks that measure the intended behaviour
directly (ConnectionPoolRampRunner, the dedicated-thread and threadpool-pressure
variants) is kept, since it stands on its own.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 23:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

eng/pipelines/perf/scripts/run-perf-tests.ps1:356

  • The generated connection string is missing the password key/value: it currently inserts a literal "******" segment, which will produce an invalid connection string and break Windows perf runs. This also leaves $escapedPassword unused even though it’s computed just above.
    $cfg.ConnectionString = "Server=tcp:$SqlServer,1433;User ID=sa;Password=$escapedPassword;Initial Catalog=$DbName;TrustServerCertificate=True;Encrypt=False;"

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:107

  • If any worker throws before signaling allConnected (e.g., OpenAsync fails), release is never completed and the successfully-opened workers will await release.Task forever, hanging the benchmark run. The runner should fault release (or otherwise unblock peers) on error so failures don’t deadlock the harness.
                tasks[i] = Task.Run(async () =>
                {
                    using var conn = new SqlConnection(_connectionString);
                    await conn.OpenAsync();

Comment on lines +131 to +145
using var allConnected = new CountdownEvent(Parallelism);

var tasks = new Task[Parallelism];
for (int i = 0; i < Parallelism; i++)
{
tasks[i] = Task.Factory.StartNew(() =>
{
using var conn = new SqlConnection(_connectionString);
conn.Open();

allConnected.Signal();
allConnected.Wait();
// Dispose returns the connection to the pool.
}, TaskCreationOptions.LongRunning);
}
…dance

The section still read as if 'intended differences' were a category the
pipeline recognised. It no longer is. Retitles it, states plainly that there
is no mute mechanism and why, and points at writing a benchmark that measures
the intended behaviour instead.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 23:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:143

  • ColdStartRamp can deadlock if any worker throws before calling allConnected.Signal() (e.g., conn.Open fails). Other workers then block forever in allConnected.Wait(), stalling the run until timeout. Signal the CountdownEvent in a finally block so failures don’t strand other threads.
                tasks[i] = Task.Factory.StartNew(() =>
                {
                    using var conn = new SqlConnection(_connectionString);
                    conn.Open();

                    allConnected.Signal();
                    allConnected.Wait();
                    // Dispose returns the connection to the pool.

src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolRampRunner.cs:107

  • ColdStartRampAsync can hang indefinitely if any worker fails before signaling the CountdownEvent (e.g., OpenAsync throws). In that case release is never set and the other workers await release.Task forever, stalling the perf run until the outer timeout. Ensure the CountdownEvent is always signaled (even on failure) so the run fails fast instead of deadlocking.
                tasks[i] = Task.Run(async () =>
                {
                    using var conn = new SqlConnection(_connectionString);
                    await conn.OpenAsync();

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

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

2 participants