Add perf experiment pipeline and fix v2 connection pool regressions - #4542
Closed
mdaigle wants to merge 4 commits into
Closed
Add perf experiment pipeline and fix v2 connection pool regressions#4542mdaigle wants to merge 4 commits into
mdaigle wants to merge 4 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
|
Reopening from a branch in the repo instead of a fork. |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new Azure DevOps perf “switch experiment” pipeline to A/B test a single runner/AppContext-style switch on the same commit, and uses it to drive an optimization that restores v2 connection-pool steady-state performance by avoiding thread-pool dispatch and unnecessary allocations when an idle pooled connection is immediately available.
Changes:
- Added
sqlclient-perf-experiment.ymland corresponding script updates to support--switch-under-test/-SwitchUnderTestruns (same source, switch off vs on; never ingested into Kusto). - Added a pooled-connection inline fast path in
ChannelDbConnectionPool.TryGetConnectionfor both sync and async callers, reducingTask.Run/CTS/Task allocations in the common “idle connection available” case. - Added unit tests to lock down “completes inline” behavior for both sync and async acquisition from an idle pool.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs | Adds unit tests ensuring idle pooled connections are returned/completed inline for sync and async acquisition. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | Introduces TryGetPooledConnectionInline and uses it to avoid async state machine/CTS/thread-pool dispatch on the steady-state pooled path. |
| eng/pipelines/perf/sqlclient-perf-experiment.yml | New manual perf pipeline to A/B a single switch on the same commit (no Kusto ingestion). |
| eng/pipelines/perf/scripts/run-perf-tests.sh | Adds --switch-under-test baseline mode, generates per-variant runner configs, and wires per-variant config into interleaving/sequential runs. |
| eng/pipelines/perf/scripts/run-perf-tests.ps1 | Windows equivalent of -SwitchUnderTest, including per-variant runner config generation and wiring. |
| eng/pipelines/perf/scripts/interleave_perf.py | Adds per-variant RUNNER_CONFIG overrides so baseline/current can differ by runner config while sharing the same build. |
| eng/pipelines/perf/README.md | Documents the new experiment pipeline, its intent, constraints, and why it must not ingest to Kusto. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (taskCompletionSource is null) | ||
| { | ||
| // We're on the caller's thread, so the ambient transaction is directly observable. | ||
| Transaction? currentTransaction = ADP.GetCurrentTransaction(); |
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: