Skip to content

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

Draft
mdaigle wants to merge 5 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 5 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.
  • 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.

Notes for reviewers:

  • The sync SteadyStateOpenQueryClose +102% is not addressed here. It comes from sync waiters serializing behind the process-wide _syncOverAsyncSemaphore (sized ProcessorCount / 2). Making it per-pool would let N pools each block that many thread pool threads and defeat its purpose as a starvation guard. Removing that regression needs the sync-over-async channel wait redesigned, which is a larger change that deserves its own measurement.
  • I investigated and ruled out redundant liveness probing as a cause. 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.
  • The 9 regressions flagged in only 1 of 3 confirmation runs are noise. All are non-pool paths (XML read, large data read, bulk copy, BeginTransaction) with ~0% allocation delta.

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.

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