Add perf experiment pipeline and fix v2 connection pool regressions - #4543
Draft
mdaigle wants to merge 5 commits into
Draft
Add perf experiment pipeline and fix v2 connection pool regressions#4543mdaigle wants to merge 5 commits into
mdaigle wants to merge 5 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>
Contributor
Author
Contributor
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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.Notes for reviewers:
SteadyStateOpenQueryClose+102% is not addressed here. It comes from sync waiters serializing behind the process-wide_syncOverAsyncSemaphore(sizedProcessorCount / 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.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.BeginTransaction) with ~0% allocation delta.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 (320 tests, previously 318).
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: