From 621daf1c6d6d3cbe25e2b39eaf490d35bbdfebe4 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Wed, 15 Jul 2026 17:46:34 -0700 Subject: [PATCH 1/3] Expand connection pool benchmark with warm/cold, sync/async, SNI, and pooling matrix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConnectionPoolStressRunner.cs | 339 +++++++----------- 1 file changed, 121 insertions(+), 218 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index c40b3f5b71..3dc781e4e3 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -3,281 +3,184 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Threading; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; namespace Microsoft.Data.SqlClient.PerformanceTests { /// - /// Stress-tests the connection pool with randomized parallel access patterns: - /// - Massive concurrent open/close churn - /// - Randomized hold durations simulating real workloads - /// - Mixed sync/async callers competing for pooled connections - /// - Connection reuse with interleaved queries - /// - Pool exhaustion and recovery under pressure + /// Captures benchmarks for SqlConnection pool checkout throughput across a matrix of: + /// - Warm vs. cold pools + /// - Sync vs. async callers + /// - Native vs. managed SNI (Windows-only distinction) + /// - Disabled, new (V2), and legacy pooling implementations /// /// Related issues: #601, #979, #3356 /// public class ConnectionPoolStressRunner : BaseRunner { - private string _connectionString; - private string _tableName; - - /// - /// Number of concurrent tasks hammering the pool. - /// - [Params(10, 20, 25)] - public int Parallelism { get; set; } - - /// - /// Max pool size — controls how many physical connections the pool can hold. - /// When Parallelism exceeds this, tasks must wait for a free connection. - /// - [Params(50, 100)] - public int MaxPoolSize { get; set; } - - [GlobalSetup] - public void Setup() + public enum PoolBehavior { - _connectionString = s_config.ConnectionString + - $";Pooling=True;Max Pool Size={MaxPoolSize};Min Pool Size=5;Connect Timeout=60"; - - // Create a small table for query workloads. - // Hash the machine name instead of using it verbatim: hostnames can be long enough - // to push the identifier past SQL Server's 128-character limit. Cast to uint rather - // than using Math.Abs, which throws OverflowException when the hash is int.MinValue. - string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); - _tableName = $"[perf_PoolStress_{machineHash}_{Guid.NewGuid():N}]"; - using var conn = new SqlConnection(_connectionString); - conn.Open(); - using var cmd = new SqlCommand( - $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Val INT)", conn); - cmd.ExecuteNonQuery(); - - // Seed a few rows so SELECT queries return data - using var insert = new SqlCommand( - $"INSERT INTO {_tableName} (Val) VALUES (1),(2),(3),(4),(5)", conn); - insert.ExecuteNonQuery(); + Disabled, + New, + Legacy } - [IterationCleanup] - public void IterationCleanup() + public enum AsyncBehavior { - SqlConnection.ClearAllPools(); + Sync, + Async } - [GlobalCleanup] - public void Cleanup() + public enum SniBehavior { - using var conn = new SqlConnection(_connectionString); - conn.Open(); - using var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_tableName}", conn); - cmd.ExecuteNonQuery(); - conn.Close(); - SqlConnection.ClearAllPools(); + Native, + Managed } /// - /// Pure open/close churn — every task opens a pooled connection, immediately closes it, - /// and repeats. Measures raw pool checkout/return throughput under contention. + /// Whether the pool is pre-warmed before the measured run. /// - [Benchmark] - public async Task RapidFireOpenClose() - { - var tasks = new Task[Parallelism]; - for (int i = 0; i < Parallelism; i++) - { - tasks[i] = Task.Run(async () => - { - for (int j = 0; j < 20; j++) - { - using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); - // immediate return to pool - } - }); - } - await Task.WhenAll(tasks); - } + [ParamsAllValues] + public bool PoolIsWarm { get; set; } /// - /// Randomized hold — each task opens a connection, holds it for a random duration - /// (0-50ms), optionally runs a query, then returns it. Simulates realistic mixed - /// workloads where some connections are held briefly and others longer. + /// Whether callers open connections synchronously or asynchronously. /// - [Benchmark] - public async Task RandomizedHoldAndQuery() - { - var tasks = new Task[Parallelism]; - for (int i = 0; i < Parallelism; i++) - { - int seed = i; - tasks[i] = Task.Run(async () => - { - var rng = new Random(seed); - for (int j = 0; j < 10; j++) - { - using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); + [ParamsAllValues] + public AsyncBehavior Async { get; set; } - // ~50% of the time, execute a lightweight query while holding the connection - if (rng.Next(2) == 0) - { - using var cmd = new SqlCommand($"SELECT TOP 1 Val FROM {_tableName}", conn); - _ = await cmd.ExecuteScalarAsync(); - } + /// + /// Whether to use native or managed SNI. Only meaningful on Windows; + /// managed SNI is always used on non-Windows platforms. + /// + [ParamsAllValues] + public SniBehavior Sni { get; set; } - // Random hold time: 0-50ms - int holdMs = rng.Next(51); - if (holdMs > 0) - { - await Task.Delay(holdMs); - } - } - }); - } - await Task.WhenAll(tasks); - } + /// + /// Number of connections opened in parallel per iteration. + /// + [Params(100)] + public int NumConnectionsToOpen { get; set; } /// - /// Mixed sync and async — half the tasks use sync Open/ExecuteReader, - /// the other half use async. Stresses the pool lock paths that differ - /// between sync and async checkout. + /// Which pooling implementation to exercise. /// - [Benchmark] - public async Task MixedSyncAsyncContention() + [ParamsAllValues] + public PoolBehavior Pooling { get; set; } + + private string _connectionString; + private SqlConnectionStringBuilder _connectionStringBuilder = new(); + private IProducerConsumerCollection _connections = new ConcurrentBag(); + + [GlobalSetup] + public void Setup() { - var tasks = new Task[Parallelism]; - for (int i = 0; i < Parallelism; i++) - { - bool useAsync = i % 2 == 0; - tasks[i] = Task.Run(async () => - { - for (int j = 0; j < 10; j++) - { - using var conn = new SqlConnection(_connectionString); - if (useAsync) - { - await conn.OpenAsync(); - using var cmd = new SqlCommand($"SELECT COUNT(*) FROM {_tableName}", conn); - _ = await cmd.ExecuteScalarAsync(); - } - else - { - conn.Open(); - using var cmd = new SqlCommand($"SELECT COUNT(*) FROM {_tableName}", conn); - _ = cmd.ExecuteScalar(); - } - } - }); - } - await Task.WhenAll(tasks); + _connectionString = s_config.ConnectionString; } - /// - /// Connection reuse with multiple commands — each task opens one connection and - /// executes many sequential queries before returning it. Measures pool efficiency - /// when connections are held for multi-step operations (like EF SaveChanges). - /// - [Benchmark] - public async Task MultiCommandReuse() + [IterationSetup] + public void IterationSetup() { - var tasks = new Task[Parallelism]; - for (int i = 0; i < Parallelism; i++) + _connections = new ConcurrentBag(); + _connectionStringBuilder = new SqlConnectionStringBuilder(_connectionString) { - int seed = i; - tasks[i] = Task.Run(async () => - { - var rng = new Random(seed); - using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); + MaxPoolSize = 1000, + WorkstationID = Guid.NewGuid().ToString(), + Pooling = Pooling is not PoolBehavior.Disabled + }; - // Execute a burst of 5-15 commands on the same connection - int commandCount = rng.Next(5, 16); - for (int c = 0; c < commandCount; c++) - { - using var cmd = new SqlCommand($"SELECT Val FROM {_tableName} WHERE Id = @id", conn); - cmd.Parameters.AddWithValue("@id", rng.Next(1, 6)); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) { } - } - }); + // PoolBehavior.New opts into the ChannelDbConnectionPool (V2) implementation; + // the default (false) uses the legacy pool. + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", Pooling is PoolBehavior.New); + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", Sni is SniBehavior.Managed); + + if (PoolIsWarm) + { + OpenConnectionsInParallel(_connectionStringBuilder.ConnectionString, NumConnectionsToOpen); } - await Task.WhenAll(tasks); } - /// - /// Pool exhaustion and recovery — spawns more tasks than MaxPoolSize so some must - /// wait. Measures how well the pool handles back-pressure when all connections are - /// checked out and callers are queued. - /// - [Benchmark] - public async Task PoolExhaustionRecovery() + [IterationCleanup] + public void Cleanup() { - // Ensure we exceed pool capacity - int taskCount = Math.Max(Parallelism, MaxPoolSize * 2); - var tasks = new Task[taskCount]; - for (int i = 0; i < taskCount; i++) + // Shut down the pool so that connections are physically closed + // when they are returned to the pool. + if (_connections.TryTake(out var first)) { - int seed = i; - tasks[i] = Task.Run(async () => - { - var rng = new Random(seed); - using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); + first.Close(); - // Hold the connection for 10-100ms to create pool pressure - using var cmd = new SqlCommand($"SELECT TOP 1 Val FROM {_tableName}", conn); - _ = await cmd.ExecuteScalarAsync(); + while (_connections.TryTake(out var conn)) + { + conn.Close(); + } - await Task.Delay(rng.Next(10, 101)); - }); + SqlConnection.ClearPool(first); } - await Task.WhenAll(tasks); } - /// - /// Bursty traffic pattern — sends waves of connections with pauses between bursts, - /// simulating real web server traffic patterns where requests cluster. - /// [Benchmark] - public async Task BurstyTrafficPattern() + public int ConnectionPoolWarmupBenchmark() { - int burstCount = 5; - int tasksPerBurst = Parallelism / burstCount; - if (tasksPerBurst < 1) - { - tasksPerBurst = 1; - } + return OpenConnectionsInParallel( + _connectionStringBuilder.ConnectionString, + NumConnectionsToOpen, + runCommand: false, + returnToPool: false); + } + + private int OpenConnectionsInParallel( + string connectionString, + int numConnectionsToOpen, + bool runCommand = false, + bool returnToPool = true) + { + var tasks = new Task[numConnectionsToOpen]; - for (int burst = 0; burst < burstCount; burst++) + for (int i = 0; i < numConnectionsToOpen; i++) { - var tasks = new Task[tasksPerBurst]; - for (int i = 0; i < tasksPerBurst; i++) + tasks[i] = Task.Run(async () => { - int seed = burst * tasksPerBurst + i; - tasks[i] = Task.Run(async () => + var conn = new SqlConnection(connectionString); + + if (Async is AsyncBehavior.Async) { - var rng = new Random(seed); - using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(); + await conn.OpenAsync(SqlConnectionOverrides.OpenWithoutRetry, CancellationToken.None); + } + else + { + conn.Open(SqlConnectionOverrides.OpenWithoutRetry); + } + + if (runCommand) + { + using var command = conn.CreateCommand(); + command.CommandText = "SELECT 1"; + command.CommandType = System.Data.CommandType.Text; + + int result = Async is AsyncBehavior.Async + ? (int)(await command.ExecuteScalarAsync() ?? 0) + : (int)(command.ExecuteScalar() ?? 0); - // Each connection in the burst does 1-5 queries - int queryCount = rng.Next(1, 6); - for (int q = 0; q < queryCount; q++) + if (result != 1) { - using var cmd = new SqlCommand($"SELECT Val FROM {_tableName}", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) { } + throw new Exception("Unexpected result from command"); } - }); - } - await Task.WhenAll(tasks); + } + + _connections.TryAdd(conn); - // Brief pause between bursts (simulates request clustering) - await Task.Delay(5); + if (returnToPool) + { + conn.Close(); + } + }); } + + Task.WaitAll(tasks); + return tasks.Length; } } } From a3be0a5f515c01e2eb9805045aecd075bc80bc17 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Thu, 16 Jul 2026 12:04:52 -0700 Subject: [PATCH 2/3] Fix pool benchmark correctness and add pool-implementation config flag The connection pool implementation switch (UseConnectionPoolV2) and the SNI switch are read and cached on first use in LocalAppContextSwitches. Because BenchmarkConfig runs every case in one process via InProcessEmitToolchain, toggling these switches per iteration had no effect and would have produced misleading Legacy/New/Native/Managed rows. Select the pool implementation once per process via a new UseConnectionPoolV2 config flag (set in Program.SetupConfigurations), matching how SNI and async behavior are already configured. Comparing pools is done with two runs. Keep only the genuinely per-iteration dimensions as benchmark parameters: warm/cold pool, sync/async, pooling on/off, and connection count. This aligns the benchmark with issue #3356's evaluation of the ChannelDbConnectionPool. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConnectionPoolStressRunner.cs | 132 +++++++++--------- .../tests/PerformanceTests/Config/Config.cs | 14 ++ .../tests/PerformanceTests/Program.cs | 8 ++ .../tests/PerformanceTests/runnerconfig.jsonc | 6 + 4 files changed, 92 insertions(+), 68 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index 3dc781e4e3..b5358e66a2 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -11,37 +11,47 @@ namespace Microsoft.Data.SqlClient.PerformanceTests { /// - /// Captures benchmarks for SqlConnection pool checkout throughput across a matrix of: - /// - Warm vs. cold pools - /// - Sync vs. async callers - /// - Native vs. managed SNI (Windows-only distinction) - /// - Disabled, new (V2), and legacy pooling implementations + /// Measures the throughput of opening connections in parallel — the core scenario + /// the channel-based connection pool (ChannelDbConnectionPool / UseConnectionPoolV2) + /// is designed to improve (see issue #3356). The design goals are parallel connection + /// opening, reduced thread contention, and lower managed threadpool pressure, so this + /// runner sweeps the dimensions that expose those characteristics: + /// + /// - : cold pool (physical connects dominate) vs. warm pool + /// (checkout throughput dominates). The warm-pool case is where the new pool shows + /// the largest wins. + /// - : sync vs. async callers. The new pool follows async best + /// practices, so async checkout is a primary target for improvement. + /// - : pooling on vs. off. Pooling off is the no-pool baseline. + /// - : how many connections are opened in parallel. + /// + /// The pool implementation itself (legacy WaitHandleDbConnectionPool vs. new + /// ChannelDbConnectionPool) is NOT a benchmark parameter. The UseConnectionPoolV2 + /// AppContext switch is read and cached the first time a pool is created, and every + /// benchmark case runs in the same process under the in-process toolchain, so the + /// implementation cannot be toggled per iteration. It is selected once per process via + /// the "UseConnectionPoolV2" flag in runnerconfig.jsonc. To compare the two pools, run + /// the benchmark twice: once with UseConnectionPoolV2=false (legacy) and once with + /// UseConnectionPoolV2=true (new). Likewise, native vs. managed SNI is selected once + /// per process via the "UseManagedSniOnWindows" config flag (Windows-only). + /// + /// Pair this with the ThreadingDiagnoser (enabled in BenchmarkConfig) to observe + /// threadpool completed-work-item counts and lock contention across the two pools. /// /// Related issues: #601, #979, #3356 /// public class ConnectionPoolStressRunner : BaseRunner { - public enum PoolBehavior - { - Disabled, - New, - Legacy - } - public enum AsyncBehavior { Sync, Async } - public enum SniBehavior - { - Native, - Managed - } - /// - /// Whether the pool is pre-warmed before the measured run. + /// Whether the pool is pre-warmed (connections opened and returned) before the + /// measured run. A warm pool isolates checkout/return throughput; a cold pool + /// includes the cost of establishing physical connections. /// [ParamsAllValues] public bool PoolIsWarm { get; set; } @@ -53,61 +63,63 @@ public enum SniBehavior public AsyncBehavior Async { get; set; } /// - /// Whether to use native or managed SNI. Only meaningful on Windows; - /// managed SNI is always used on non-Windows platforms. + /// Whether connection pooling is enabled. When disabled, every open establishes a + /// new physical connection — the no-pool baseline. /// - [ParamsAllValues] - public SniBehavior Sni { get; set; } + [Params(true, false)] + public bool Pooling { get; set; } /// - /// Number of connections opened in parallel per iteration. + /// Number of connections opened in parallel per measured invocation. /// [Params(100)] public int NumConnectionsToOpen { get; set; } - /// - /// Which pooling implementation to exercise. - /// - [ParamsAllValues] - public PoolBehavior Pooling { get; set; } - private string _connectionString; - private SqlConnectionStringBuilder _connectionStringBuilder = new(); private IProducerConsumerCollection _connections = new ConcurrentBag(); [GlobalSetup] public void Setup() { - _connectionString = s_config.ConnectionString; + // Report which pool implementation is active for this process so the results + // summary is self-describing (the implementation is a process-level choice, + // not a benchmark parameter — see the class remarks). + Console.WriteLine( + "[ConnectionPoolStressRunner] Pool implementation: " + + (s_config.UseConnectionPoolV2 + ? "ChannelDbConnectionPool (V2)" + : "WaitHandleDbConnectionPool (legacy)")); } [IterationSetup] public void IterationSetup() { _connections = new ConcurrentBag(); - _connectionStringBuilder = new SqlConnectionStringBuilder(_connectionString) + + // A fresh WorkstationID per iteration produces a distinct connection string, + // and therefore a fresh, isolated pool — so a "cold" iteration truly starts + // with an empty pool and iterations don't share pooled connections. + var builder = new SqlConnectionStringBuilder(s_config.ConnectionString) { MaxPoolSize = 1000, WorkstationID = Guid.NewGuid().ToString(), - Pooling = Pooling is not PoolBehavior.Disabled + Pooling = Pooling }; + _connectionString = builder.ConnectionString; - // PoolBehavior.New opts into the ChannelDbConnectionPool (V2) implementation; - // the default (false) uses the legacy pool. - AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", Pooling is PoolBehavior.New); - AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", Sni is SniBehavior.Managed); - - if (PoolIsWarm) + if (PoolIsWarm && Pooling) { - OpenConnectionsInParallel(_connectionStringBuilder.ConnectionString, NumConnectionsToOpen); + // Open and return NumConnectionsToOpen connections so the measured run is + // served from a warm pool. + OpenConnectionsCore(_connectionString, NumConnectionsToOpen, returnToPool: true); } } [IterationCleanup] public void Cleanup() { - // Shut down the pool so that connections are physically closed - // when they are returned to the pool. + // Close every connection opened during setup and the measured run, then clear + // the pool so pooled connections are physically closed before the next iteration. if (_connections.TryTake(out var first)) { first.Close(); @@ -121,21 +133,21 @@ public void Cleanup() } } + /// + /// Opens connections in parallel and holds them + /// open (does not return them to the pool) so the measurement captures the cost of + /// acquiring that many connections concurrently. + /// [Benchmark] - public int ConnectionPoolWarmupBenchmark() + public int OpenConnectionsInParallel() { - return OpenConnectionsInParallel( - _connectionStringBuilder.ConnectionString, - NumConnectionsToOpen, - runCommand: false, - returnToPool: false); + return OpenConnectionsCore(_connectionString, NumConnectionsToOpen, returnToPool: false); } - private int OpenConnectionsInParallel( + private int OpenConnectionsCore( string connectionString, int numConnectionsToOpen, - bool runCommand = false, - bool returnToPool = true) + bool returnToPool) { var tasks = new Task[numConnectionsToOpen]; @@ -154,22 +166,6 @@ private int OpenConnectionsInParallel( conn.Open(SqlConnectionOverrides.OpenWithoutRetry); } - if (runCommand) - { - using var command = conn.CreateCommand(); - command.CommandText = "SELECT 1"; - command.CommandType = System.Data.CommandType.Text; - - int result = Async is AsyncBehavior.Async - ? (int)(await command.ExecuteScalarAsync() ?? 0) - : (int)(command.ExecuteScalar() ?? 0); - - if (result != 1) - { - throw new Exception("Unexpected result from command"); - } - } - _connections.TryAdd(conn); if (returnToPool) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs index 7a93f65090..551ce9676f 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs @@ -12,6 +12,20 @@ public class Config public string ConnectionString; public bool UseManagedSniOnWindows; public bool UseOptimizedAsyncBehaviour; + + /// + /// When true, selects the new channel-based connection pool + /// (ChannelDbConnectionPool) by enabling the + /// Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2 AppContext switch. + /// When false (the default), the legacy WaitHandleDbConnectionPool is used. + /// + /// This is a process-level setting: the switch is read and cached the first time + /// a pool is created, so it cannot be toggled per benchmark iteration. To compare + /// the two implementations, run the benchmark once with this flag false and once + /// with it true. + /// + public bool UseConnectionPoolV2; + public bool WaitForProfiler; public bool UseNativeMemoryAndETWProfiler; public Benchmarks Benchmarks; diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs index 8f64c7448a..53886647a6 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs @@ -48,6 +48,14 @@ private void SetupConfigurations() AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); } + // If the config file specifies to use the new channel-based connection pool, + // enable the UseConnectionPoolV2 AppContext switch. This must be set before any + // connection is opened because the switch is read and cached when a pool is first + // created; it cannot be changed later in the process lifetime. + AppContext.SetSwitch( + "Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", + _config.UseConnectionPoolV2); + // If the config file specifies to use optimized async behavior, // enable packet multiplexing feature and other optimizations in SqlClient // by setting the appropriate AppContext switches. diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc index 34d7a511aa..ac3ec2a2e4 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc @@ -2,6 +2,12 @@ "ConnectionString": "Server=tcp:localhost; Integrated Security=true; Initial Catalog=sqlclient-perf-db;", // Enable this flag to enable managed SNI on Windows. "UseManagedSniOnWindows": false, + // Enable this flag to select the new channel-based connection pool + // (ChannelDbConnectionPool) instead of the legacy WaitHandleDbConnectionPool. + // This is a process-level setting (the pool implementation switch is cached the + // first time a pool is created), so comparing the two pools requires two runs: + // once with this flag false (legacy) and once true (new). See issue #3356. + "UseConnectionPoolV2": false, // Enable this flag to enable optimized async behavior in SqlClient. // This is useful for benchmarking packet multiplexing and async performance improvements. "UseOptimizedAsyncBehaviour": true, From 0468bebac352cc6b22c178a35c61a57c2889f1ea Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Thu, 16 Jul 2026 12:12:33 -0700 Subject: [PATCH 3/3] Add contention and churn pool benchmarks for fuller coverage Round out connection pool benchmark coverage for evaluating ChannelDbConnectionPool (UseConnectionPoolV2) vs the legacy WaitHandleDbConnectionPool per issue #3356. - ConnectionPoolContentionRunner: steady-state open->SELECT 1->close across 50 concurrent workers on a warm pool, with MaxPoolSize >= Parallelism (no wait) and MaxPoolSize << Parallelism (back-pressure). Exercises the async waiter wake path and lock contention that most differentiate the two pools under exhaustion (#601, #979). - ConnectionPoolChurnRunner: single-threaded rapid open/close on a warm pool. Low- noise measure of raw per-checkout overhead and per-op allocations. Both honor the process-level UseConnectionPoolV2 config flag (compare via two runs) and are wired into Config.Benchmarks, Program.cs, and runnerconfig.jsonc following the existing runner pattern. The existing ConnectionPoolStressRunner covers burst parallel open (cold/warm x sync/async x pooling on/off). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ConnectionPoolChurnRunner.cs | 104 ++++++++++++ .../ConnectionPoolContentionRunner.cs | 153 ++++++++++++++++++ .../tests/PerformanceTests/Config/Config.cs | 2 + .../tests/PerformanceTests/Program.cs | 18 +++ .../tests/PerformanceTests/runnerconfig.jsonc | 16 ++ 5 files changed, 293 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolChurnRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolContentionRunner.cs diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolChurnRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolChurnRunner.cs new file mode 100644 index 0000000000..a69015a702 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolChurnRunner.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Measures the raw per-checkout overhead of the connection pool: a single thread + /// repeatedly opens and closes a connection against a warm pool, with no contention. + /// + /// With only one caller and a pre-warmed pool, every open is a pure pool checkout + /// (no physical connect, no waiting), so this benchmark isolates the CPU and + /// allocation cost of the pool's acquire/return path itself. It is the low-noise + /// counterpart to the parallel and contention runners and is the most sensitive + /// measure of per-operation allocations (watch the Allocated / Gen0 columns) — a key + /// concern for the new ChannelDbConnectionPool, which aims to avoid extra allocations + /// on the hot path (issue #3356). + /// + /// The pool implementation (legacy vs V2) is a process-level choice — see the remarks + /// on . Run twice (UseConnectionPoolV2 false + /// then true) to compare. + /// + /// Related issue: #3356 + /// + public class ConnectionPoolChurnRunner : BaseRunner + { + public enum AsyncBehavior + { + Sync, + Async + } + + /// + /// Whether the connection is opened synchronously or asynchronously. + /// + [ParamsAllValues] + public AsyncBehavior Async { get; set; } + + /// + /// Number of sequential open/close operations performed per invocation. Kept high + /// so each measured invocation captures many pool checkouts, yielding a stable + /// per-operation cost. + /// + [Params(1000)] + public int OpsPerInvocation { get; set; } + + private string _connectionString; + + [GlobalSetup] + public void Setup() + { + Console.WriteLine( + "[ConnectionPoolChurnRunner] Pool implementation: " + + (s_config.UseConnectionPoolV2 + ? "ChannelDbConnectionPool (V2)" + : "WaitHandleDbConnectionPool (legacy)")); + + var builder = new SqlConnectionStringBuilder(s_config.ConnectionString) + { + Pooling = true, + MaxPoolSize = 100, + // Pre-warm a single connection so the very first checkout is served from + // the pool rather than establishing a physical connection. + MinPoolSize = 1 + }; + _connectionString = builder.ConnectionString; + + // Force the pool to exist and hold at least one idle connection. + using var warm = new SqlConnection(_connectionString); + warm.Open(); + warm.Close(); + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(_connectionString); + SqlConnection.ClearPool(conn); + } + + [Benchmark] + public async Task RapidOpenCloseSingleThread() + { + for (int i = 0; i < OpsPerInvocation; i++) + { + using var conn = new SqlConnection(_connectionString); + + if (Async is AsyncBehavior.Async) + { + await conn.OpenAsync(); + } + else + { + conn.Open(); + } + // Dispose returns the connection to the pool. + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolContentionRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolContentionRunner.cs new file mode 100644 index 0000000000..384ebca71c --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolContentionRunner.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Measures steady-state pool throughput under concurrent load — the hot path for + /// real applications such as web servers, where many workers repeatedly check a + /// connection out of a warm pool, do a small unit of work, and return it. + /// + /// This is where the legacy WaitHandleDbConnectionPool and the new + /// ChannelDbConnectionPool differ most (issue #3356): + /// + /// - When the pool is large enough to serve every worker, the benchmark isolates + /// checkout/return overhead and lock contention on the hot path. + /// - When the pool is smaller than the worker count ( less + /// than ), workers must wait for a connection to be + /// returned. This exercises the waiter wake path: the legacy pool parks on + /// WaitHandle.WaitAny (a blocking wait that consumes a threadpool thread for async + /// callers), while the new pool awaits the idle channel asynchronously. The + /// ThreadingDiagnoser's "Completed Work Items" and "Lock Contentions" columns make + /// the difference visible. + /// + /// The pool implementation (legacy vs V2) and SNI are process-level choices — see the + /// remarks on . Run twice (UseConnectionPoolV2 + /// false then true) to compare. + /// + /// Related issues: #601, #979, #3356 + /// + public class ConnectionPoolContentionRunner : BaseRunner + { + public enum AsyncBehavior + { + Sync, + Async + } + + /// + /// Whether workers check connections out synchronously or asynchronously. + /// + [ParamsAllValues] + public AsyncBehavior Async { get; set; } + + /// + /// Number of concurrent workers competing for pooled connections. + /// + [Params(50)] + public int Parallelism { get; set; } + + /// + /// Maximum pool size. A value greater than or equal to + /// means no contention for physical connections; a smaller value forces workers to + /// wait for connections to be returned (pool exhaustion / back-pressure). + /// + [Params(50, 10)] + public int MaxPoolSize { get; set; } + + /// + /// Number of open/query/close operations each worker performs per invocation. + /// + [Params(20)] + public int OpsPerWorker { get; set; } + + private string _connectionString; + + [GlobalSetup] + public void Setup() + { + Console.WriteLine( + "[ConnectionPoolContentionRunner] Pool implementation: " + + (s_config.UseConnectionPoolV2 + ? "ChannelDbConnectionPool (V2)" + : "WaitHandleDbConnectionPool (legacy)")); + + var builder = new SqlConnectionStringBuilder(s_config.ConnectionString) + { + Pooling = true, + MaxPoolSize = MaxPoolSize, + MinPoolSize = 0 + }; + _connectionString = builder.ConnectionString; + } + + [IterationSetup] + public void IterationSetup() + { + // Warm the pool up to MaxPoolSize so the measured run reflects steady-state + // checkout/return rather than first-time physical connection establishment. + WarmPool(MaxPoolSize); + } + + [IterationCleanup] + public void IterationCleanup() + { + using var conn = new SqlConnection(_connectionString); + SqlConnection.ClearPool(conn); + } + + [Benchmark] + public async Task SteadyStateOpenQueryClose() + { + var tasks = new Task[Parallelism]; + for (int i = 0; i < Parallelism; i++) + { + tasks[i] = Task.Run(async () => + { + for (int op = 0; op < OpsPerWorker; op++) + { + using var conn = new SqlConnection(_connectionString); + + if (Async is AsyncBehavior.Async) + { + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1"; + _ = await cmd.ExecuteScalarAsync(); + } + else + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1"; + _ = cmd.ExecuteScalar(); + } + // Dispose returns the connection to the pool. + } + }); + } + + await Task.WhenAll(tasks); + } + + private void WarmPool(int count) + { + var conns = new SqlConnection[count]; + for (int i = 0; i < count; i++) + { + conns[i] = new SqlConnection(_connectionString); + conns[i].Open(); + } + // Return them all to the pool so subsequent checkouts are served from idle. + for (int i = 0; i < count; i++) + { + conns[i].Close(); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs index 551ce9676f..2bbd1f1c23 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs @@ -68,6 +68,8 @@ public class Benchmarks public RunnerJob JsonVsVarcharReadRunnerConfig; public RunnerJob BeginTransactionRunnerConfig; public RunnerJob ConnectionPoolStressRunnerConfig; + public RunnerJob ConnectionPoolContentionRunnerConfig; + public RunnerJob ConnectionPoolChurnRunnerConfig; } public class RunnerJob diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs index 53886647a6..0a1cb1e6d1 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs @@ -33,6 +33,8 @@ public Program() Run_JsonVsVarcharReadBenchmark(); Run_BeginTransactionBenchmark(); Run_ConnectionPoolStressBenchmark(); + Run_ConnectionPoolContentionBenchmark(); + Run_ConnectionPoolChurnBenchmark(); // TODOs: // Prepared/Regular Parameterized queries @@ -194,6 +196,22 @@ private void Run_ConnectionPoolStressBenchmark() } } + private void Run_ConnectionPoolContentionBenchmark() + { + if (_config.Benchmarks.ConnectionPoolContentionRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.ConnectionPoolContentionRunnerConfig)); + } + } + + private void Run_ConnectionPoolChurnBenchmark() + { + if (_config.Benchmarks.ConnectionPoolChurnRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.ConnectionPoolChurnRunnerConfig)); + } + } + /// /// The main entry point for the performance tests program. /// diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc index ac3ec2a2e4..5777db9652 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc @@ -126,6 +126,22 @@ "InvocationCount": 1, "WarmupCount": 1, "RowCount": 0 + }, + "ConnectionPoolContentionRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 15, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 + }, + "ConnectionPoolChurnRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 15, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 } } }