From 89d7ccd9674a5699cfc598dd5e4cd33279f7d9af Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Mon, 23 Mar 2026 19:03:27 -0700 Subject: [PATCH 1/8] Introduce performance tests --- .../AsyncLargeDataReadRunner.cs | 100 +++++++ .../BeginTransactionRunner.cs | 75 +++++ .../CancellationTokenReadAsyncRunner.cs | 95 ++++++ .../ConnectionPoolStressRunner.cs | 280 ++++++++++++++++++ .../JsonVsVarcharReadRunner.cs | 120 ++++++++ .../BenchmarkRunners/MarsOverheadRunner.cs | 74 +++++ .../ParallelAsyncConnectionRunner.cs | 54 ++++ .../SequentialXmlReadRunner.cs | 98 ++++++ .../Config/BenchmarkConfig.cs | 5 +- .../tests/PerformanceTests/Config/Config.cs | 10 + .../tests/PerformanceTests/Program.cs | 131 +++++++- .../tests/PerformanceTests/runnerconfig.json | 76 ++++- 12 files changed, 1099 insertions(+), 19 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/MarsOverheadRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ParallelAsyncConnectionRunner.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs new file mode 100644 index 0000000000..75593f66a7 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs @@ -0,0 +1,100 @@ +// 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.Data; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Benchmarks for sync vs async reading of large VARBINARY(MAX) values. + /// Reproduces issues #593 and #1562. + /// + public class AsyncLargeDataReadRunner : BaseRunner + { + private string _tableName; + private string _connectionString; + + /// + /// Size of the data to read in bytes. + /// + [Params(1_048_576, 5_242_880, 10_485_760, 20_971_520)] + public int DataSizeBytes { get; set; } + + [GlobalSetup] + public void Setup() + { + _connectionString = s_config.ConnectionString; + _tableName = $"[perf_AsyncLargeData_{Environment.MachineName}_{Guid.NewGuid():N}]"; + + using var conn = new SqlConnection(_connectionString); + conn.Open(); + + using var createCmd = new SqlCommand( + $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Data VARBINARY(MAX))", conn); + createCmd.ExecuteNonQuery(); + + // Insert a single row with random bytes of the specified size + byte[] data = new byte[DataSizeBytes]; + Random.Shared.NextBytes(data); + + using var insertCmd = new SqlCommand( + $"INSERT INTO {_tableName} (Data) VALUES (@data)", conn); + insertCmd.Parameters.Add("@data", SqlDbType.VarBinary, -1).Value = data; + insertCmd.ExecuteNonQuery(); + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_tableName}", conn); + cmd.ExecuteNonQuery(); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public void ReadLargeDataSync() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand($"SELECT Data FROM {_tableName}", conn); + using var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess); + while (reader.Read()) + { + byte[] buffer = new byte[8192]; + long offset = 0; + long bytesRead; + do + { + bytesRead = reader.GetBytes(0, offset, buffer, 0, buffer.Length); + offset += bytesRead; + } while (bytesRead > 0); + } + } + + [Benchmark] + public async Task ReadLargeDataAsync() + { + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + using var cmd = new SqlCommand($"SELECT Data FROM {_tableName}", conn); + using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess); + while (await reader.ReadAsync()) + { + byte[] buffer = new byte[8192]; + long offset = 0; + long bytesRead; + do + { + bytesRead = reader.GetBytes(0, offset, buffer, 0, buffer.Length); + offset += bytesRead; + } while (bytesRead > 0); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs new file mode 100644 index 0000000000..4509f68aed --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs @@ -0,0 +1,75 @@ +// 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 +{ + /// + /// Benchmarks measuring BeginTransaction round-trip latency. + /// Reproduces issue #1554. + /// + public class BeginTransactionRunner : BaseRunner + { + private SqlConnection _connection; + private string _tableName; + + [GlobalSetup] + public void Setup() + { + _connection = new SqlConnection(s_config.ConnectionString); + _connection.Open(); + + _tableName = $"[perf_TxnBench_{Environment.MachineName}_{Guid.NewGuid():N}]"; + + using var cmd = new SqlCommand( + $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Value INT)", _connection); + cmd.ExecuteNonQuery(); + } + + [GlobalCleanup] + public void Cleanup() + { + using var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_tableName}", _connection); + cmd.ExecuteNonQuery(); + _connection.Close(); + _connection.Dispose(); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public void WithTransaction() + { + using var txn = _connection.BeginTransaction(); + using var cmd = new SqlCommand($"INSERT INTO {_tableName} (Value) VALUES (1)", _connection, txn); + cmd.ExecuteNonQuery(); + txn.Commit(); + } + + [Benchmark] + public void WithoutTransaction() + { + using var cmd = new SqlCommand($"INSERT INTO {_tableName} (Value) VALUES (1)", _connection); + cmd.ExecuteNonQuery(); + } + + [Benchmark] + public async Task WithTransactionAsync() + { + using var txn = (SqlTransaction)await _connection.BeginTransactionAsync(); + using var cmd = new SqlCommand($"INSERT INTO {_tableName} (Value) VALUES (1)", _connection, txn); + await cmd.ExecuteNonQueryAsync(); + await txn.CommitAsync(); + } + + [Benchmark] + public async Task WithoutTransactionAsync() + { + using var cmd = new SqlCommand($"INSERT INTO {_tableName} (Value) VALUES (1)", _connection); + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs new file mode 100644 index 0000000000..8ae2da1f8a --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs @@ -0,0 +1,95 @@ +// 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; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Benchmarks measuring CancellationToken overhead during ReadAsync iteration. + /// Reproduces issue #2408. + /// + public class CancellationTokenReadAsyncRunner : BaseRunner + { + private static long s_rowCount; + private string _tableName; + private string _connectionString; + private string _query; + + /// + /// Whether to pass a CancellationToken to ReadAsync. + /// + [Params(true, false)] + public bool UseCancellationToken { get; set; } + + [GlobalSetup] + public void Setup() + { + s_rowCount = s_config.Benchmarks.CancellationTokenReadAsyncRunnerConfig.RowCount; + _connectionString = s_config.ConnectionString; + _tableName = $"[perf_CancelToken_{Environment.MachineName}_{Guid.NewGuid():N}]"; + + using var conn = new SqlConnection(_connectionString); + conn.Open(); + + using var createCmd = new SqlCommand( + $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Value INT)", conn); + createCmd.ExecuteNonQuery(); + + // Bulk insert rows + using var bulkCopy = new SqlBulkCopy(conn) + { + DestinationTableName = _tableName, + BatchSize = 10000 + }; + + var dt = new System.Data.DataTable(); + dt.Columns.Add("Id", typeof(int)); + dt.Columns.Add("Value", typeof(int)); + for (int i = 0; i < s_rowCount; i++) + { + dt.Rows.Add(i, i * 2); + } + bulkCopy.WriteToServer(dt); + + _query = $"SELECT Id, Value FROM {_tableName}"; + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_tableName}", conn); + cmd.ExecuteNonQuery(); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public async Task ReadAsyncWithOrWithoutToken() + { + using var cts = UseCancellationToken ? new CancellationTokenSource() : null; + var token = cts?.Token ?? CancellationToken.None; + + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(token); + using var cmd = new SqlCommand(_query, conn); + using var reader = await cmd.ExecuteReaderAsync(token); + + if (UseCancellationToken) + { + while (await reader.ReadAsync(token)) + { } + } + else + { + while (await reader.ReadAsync()) + { } + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs new file mode 100644 index 0000000000..c123e99f1d --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -0,0 +1,280 @@ +// 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.Collections.Generic; +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 + /// + /// 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() + { + _connectionString = s_config.ConnectionString + + $";Pooling=True;Max Pool Size={MaxPoolSize};Min Pool Size=5;Connect Timeout=60"; + + // Create a small table for query workloads + _tableName = $"[perf_PoolStress_{Environment.MachineName}_{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(); + } + + [IterationCleanup] + public void IterationCleanup() + { + SqlConnection.ClearAllPools(); + } + + [GlobalCleanup] + public void Cleanup() + { + 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(); + } + + /// + /// Pure open/close churn — every task opens a pooled connection, immediately closes it, + /// and repeats. Measures raw pool checkout/return throughput under contention. + /// + [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); + } + + /// + /// 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. + /// + [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(); + + // ~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(); + } + + // Random hold time: 0-50ms + int holdMs = rng.Next(51); + if (holdMs > 0) + { + await Task.Delay(holdMs); + } + } + }); + } + await Task.WhenAll(tasks); + } + + /// + /// 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. + /// + [Benchmark] + public async Task MixedSyncAsyncContention() + { + 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); + } + + /// + /// 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() + { + 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); + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + // 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()) { } + } + }); + } + 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() + { + // Ensure we exceed pool capacity + int taskCount = Math.Max(Parallelism, MaxPoolSize * 2); + var tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + int seed = i; + tasks[i] = Task.Run(async () => + { + var rng = new Random(seed); + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + // 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(); + + await Task.Delay(rng.Next(10, 101)); + }); + } + 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() + { + int burstCount = 5; + int tasksPerBurst = Parallelism / burstCount; + if (tasksPerBurst < 1) + { + tasksPerBurst = 1; + } + + for (int burst = 0; burst < burstCount; burst++) + { + var tasks = new Task[tasksPerBurst]; + for (int i = 0; i < tasksPerBurst; i++) + { + int seed = burst * tasksPerBurst + i; + tasks[i] = Task.Run(async () => + { + var rng = new Random(seed); + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + + // Each connection in the burst does 1-5 queries + int queryCount = rng.Next(1, 6); + for (int q = 0; q < queryCount; q++) + { + using var cmd = new SqlCommand($"SELECT Val FROM {_tableName}", conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) { } + } + }); + } + await Task.WhenAll(tasks); + + // Brief pause between bursts (simulates request clustering) + await Task.Delay(5); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs new file mode 100644 index 0000000000..470d354b19 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs @@ -0,0 +1,120 @@ +// 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.Data; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Benchmarks comparing read performance of JSON vs VARCHAR(MAX) columns. + /// Reproduces issue #3499. + /// Note: JSON column type requires SQL Server 2025+ or Azure SQL. + /// This runner is disabled by default in runnerconfig.json. + /// + public class JsonVsVarcharReadRunner : BaseRunner + { + private static long s_rowCount; + private string _jsonTableName; + private string _varcharTableName; + private string _connectionString; + + /// + /// Which column type to read from: "JSON" or "VARCHAR". + /// + [Params("JSON", "VARCHAR")] + public string ColumnType { get; set; } + + private string ActiveTableName => ColumnType == "JSON" ? _jsonTableName : _varcharTableName; + + [GlobalSetup] + public void Setup() + { + s_rowCount = s_config.Benchmarks.JsonVsVarcharReadRunnerConfig.RowCount; + _connectionString = s_config.ConnectionString; + + string suffix = $"{Environment.MachineName}_{Guid.NewGuid():N}"; + _jsonTableName = $"[perf_Json_{suffix}]"; + _varcharTableName = $"[perf_Varchar_{suffix}]"; + + string sampleJson = "{\"id\":1,\"name\":\"test\",\"values\":[1,2,3],\"nested\":{\"key\":\"value\"}}"; + + using var conn = new SqlConnection(_connectionString); + conn.Open(); + + // Create JSON table + using (var cmd = new SqlCommand( + $"CREATE TABLE {_jsonTableName} (Id INT IDENTITY PRIMARY KEY, Data JSON)", conn)) + { + cmd.ExecuteNonQuery(); + } + + // Create VARCHAR(MAX) table + using (var cmd = new SqlCommand( + $"CREATE TABLE {_varcharTableName} (Id INT IDENTITY PRIMARY KEY, Data VARCHAR(MAX))", conn)) + { + cmd.ExecuteNonQuery(); + } + + // Bulk insert identical data into both tables + var dt = new System.Data.DataTable(); + dt.Columns.Add("Id", typeof(int)); + dt.Columns.Add("Data", typeof(string)); + for (int i = 0; i < s_rowCount; i++) + { + dt.Rows.Add(i, sampleJson); + } + + using (var bulkCopy = new SqlBulkCopy(conn) { DestinationTableName = _jsonTableName, BatchSize = 10000 }) + { + bulkCopy.WriteToServer(dt); + } + + using (var bulkCopy = new SqlBulkCopy(conn) { DestinationTableName = _varcharTableName, BatchSize = 10000 }) + { + bulkCopy.WriteToServer(dt); + } + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using (var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_jsonTableName}", conn)) + cmd.ExecuteNonQuery(); + using (var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_varcharTableName}", conn)) + cmd.ExecuteNonQuery(); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public void ReadDataSync() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand($"SELECT Data FROM {ActiveTableName}", conn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + _ = reader.GetString(0); + } + } + + [Benchmark] + public async Task ReadDataAsync() + { + using var conn = new SqlConnection(_connectionString); + await conn.OpenAsync(); + using var cmd = new SqlCommand($"SELECT Data FROM {ActiveTableName}", conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + _ = await reader.GetFieldValueAsync(0); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/MarsOverheadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/MarsOverheadRunner.cs new file mode 100644 index 0000000000..db0ea313a3 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/MarsOverheadRunner.cs @@ -0,0 +1,74 @@ +// 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.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Benchmarks comparing query execution with MARS enabled vs disabled. + /// Reproduces issues #422 and #1283. + /// + public class MarsOverheadRunner : BaseRunner + { + private static long s_rowCount; + private Table _table; + private string _query; + + /// + /// Whether MARS is enabled on the connection string. + /// + [Params(true, false)] + public bool MARS { get; set; } + + private string ConnectionString => s_config.ConnectionString + $";MultipleActiveResultSets={MARS}"; + + [GlobalSetup] + public void Setup() + { + s_rowCount = s_config.Benchmarks.MarsOverheadRunnerConfig.RowCount; + + using var conn = new SqlConnection(s_config.ConnectionString); + conn.Open(); + + _table = TablePatterns.TableAll25Columns(s_datatypes, nameof(MarsOverheadRunner)) + .CreateTable(conn) + .InsertBulkRows(s_rowCount, conn); + + _query = $"SELECT * FROM {_table.Name}"; + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(s_config.ConnectionString); + conn.Open(); + _table.DropTable(conn); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public void ExecuteReaderWithMars() + { + using var conn = new SqlConnection(ConnectionString); + conn.Open(); + using var cmd = new SqlCommand(_query, conn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { } + } + + [Benchmark] + public async Task ExecuteReaderAsyncWithMars() + { + using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + using var cmd = new SqlCommand(_query, conn); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ParallelAsyncConnectionRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ParallelAsyncConnectionRunner.cs new file mode 100644 index 0000000000..32516bddad --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ParallelAsyncConnectionRunner.cs @@ -0,0 +1,54 @@ +// 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 +{ + /// + /// Benchmarks for concurrent async connection opens to surface pool contention. + /// Reproduces issues #601 and #979. + /// + public class ParallelAsyncConnectionRunner : BaseRunner + { + /// + /// Number of concurrent connections to open. + /// + [Params(10, 50, 100)] + public int Concurrency { get; set; } + + /// + /// Whether connection pooling is enabled. + /// + [Params(true, false)] + public bool Pooling { get; set; } + + private string ConnectionString => s_config.ConnectionString + $";Pooling={Pooling};Max Pool Size=200"; + + [IterationCleanup] + public void IterationCleanup() + { + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public async Task OpenConnectionsConcurrently() + { + var tasks = new Task[Concurrency]; + for (int i = 0; i < Concurrency; i++) + { + tasks[i] = OpenAndCloseConnectionAsync(); + } + await Task.WhenAll(tasks); + } + + private async Task OpenAndCloseConnectionAsync() + { + using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs new file mode 100644 index 0000000000..db9430b653 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs @@ -0,0 +1,98 @@ +// 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.Data; +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.Data.SqlClient.PerformanceTests +{ + /// + /// Benchmarks for XML column reads via SequentialAccess at increasing data sizes + /// to detect O(N²) scaling behavior. + /// Reproduces issue #1877. + /// + public class SequentialXmlReadRunner : BaseRunner + { + private string _tableName; + private string _connectionString; + private string _query; + + /// + /// Size of the XML data in kilobytes. + /// + [Params(10, 100, 500, 1000)] + public int XmlSizeKB { get; set; } + + /// + /// Whether to use CommandBehavior.SequentialAccess. + /// + [Params(true, false)] + public bool UseSequentialAccess { get; set; } + + [GlobalSetup] + public void Setup() + { + _connectionString = s_config.ConnectionString; + _tableName = $"[perf_SeqXml_{Environment.MachineName}_{Guid.NewGuid():N}]"; + + using var conn = new SqlConnection(_connectionString); + conn.Open(); + + using var createCmd = new SqlCommand( + $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Data XML)", conn); + createCmd.ExecuteNonQuery(); + + // Generate XML of approximately XmlSizeKB kilobytes + string xmlData = GenerateXml(XmlSizeKB * 1024); + + using var insertCmd = new SqlCommand( + $"INSERT INTO {_tableName} (Data) VALUES (@data)", conn); + insertCmd.Parameters.Add("@data", SqlDbType.Xml).Value = xmlData; + insertCmd.ExecuteNonQuery(); + + _query = $"SELECT Data FROM {_tableName}"; + } + + [GlobalCleanup] + public void Cleanup() + { + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_tableName}", conn); + cmd.ExecuteNonQuery(); + SqlConnection.ClearAllPools(); + } + + [Benchmark] + public void ReadXml() + { + var behavior = UseSequentialAccess ? CommandBehavior.SequentialAccess : CommandBehavior.Default; + + using var conn = new SqlConnection(_connectionString); + conn.Open(); + using var cmd = new SqlCommand(_query, conn); + using var reader = cmd.ExecuteReader(behavior); + while (reader.Read()) + { + _ = reader.GetString(0); + } + } + + private static string GenerateXml(int targetBytes) + { + var sb = new StringBuilder(targetBytes + 256); + sb.Append(""); + int index = 0; + while (sb.Length < targetBytes) + { + sb.Append($"This is element number {index} with some padding data to increase size."); + index++; + } + sb.Append(""); + return sb.ToString(); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs index 6c0bceb6ba..9f44c5889e 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs @@ -15,6 +15,7 @@ public static class BenchmarkConfig public static ManualConfig s_instance(RunnerJob runnerJob) => DefaultConfig.Instance .WithOption(ConfigOptions.DisableOptimizationsValidator, true) + .WithOption(ConfigOptions.DontOverwriteResults, true) .AddDiagnoser(MemoryDiagnoser.Default) .AddDiagnoser(ThreadingDiagnoser.Default) .AddExporter(MarkdownExporter.GitHub) @@ -26,6 +27,8 @@ public static ManualConfig s_instance(RunnerJob runnerJob) => .WithWarmupCount(runnerJob.WarmupCount) .WithUnrollFactor(1) .WithStrategy(BenchmarkDotNet.Engines.RunStrategy.Throughput) - ); + .WithEnvironmentVariable("COMPlus_gcServer", "1") + ) + .WithOptions(ConfigOptions.JoinSummary); } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs index f52fb1b554..1aaaa65b67 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs @@ -11,6 +11,8 @@ public class Config { public string ConnectionString; public bool UseManagedSniOnWindows; + public bool UseOptimizedAsyncBehaviour; + public bool WaitForProfiler; public Benchmarks Benchmarks; /// @@ -43,6 +45,14 @@ public class Benchmarks public RunnerJob SqlBulkCopyRunnerConfig; public RunnerJob DataTypeReaderRunnerConfig; public RunnerJob DataTypeReaderAsyncRunnerConfig; + public RunnerJob AsyncLargeDataReadRunnerConfig; + public RunnerJob MarsOverheadRunnerConfig; + public RunnerJob ParallelAsyncConnectionRunnerConfig; + public RunnerJob CancellationTokenReadAsyncRunnerConfig; + public RunnerJob SequentialXmlReadRunnerConfig; + public RunnerJob JsonVsVarcharReadRunnerConfig; + public RunnerJob BeginTransactionRunnerConfig; + public RunnerJob ConnectionPoolStressRunnerConfig; } public class RunnerJob diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs index 8df9d36a3e..8ba4f89432 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs @@ -11,30 +11,72 @@ public class Program { private readonly Config _config; + /// + /// Initializes a new instance of the class, + /// which loads the benchmark configuration and runs the benchmarks. + /// public Program() { - // Load config file _config = Config.Load(); - if (_config.UseManagedSniOnWindows) - { - AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); - } + SetupConfigurations(); + Run_SqlConnectionBenchmark(); Run_SqlCommandBenchmark(); Run_SqlBulkCopyBenchmark(); Run_DataTypeReaderBenchmark(); Run_DataTypeReaderAsyncBenchmark(); + Run_AsyncLargeDataReadBenchmark(); + Run_MarsOverheadBenchmark(); + Run_ParallelAsyncConnectionBenchmark(); + Run_CancellationTokenReadAsyncBenchmark(); + Run_SequentialXmlReadBenchmark(); + Run_JsonVsVarcharReadBenchmark(); + Run_BeginTransactionBenchmark(); + Run_ConnectionPoolStressBenchmark(); // TODOs: - // Transactions - // Insert/Update queries (+CRUD) // Prepared/Regular Parameterized queries - // DataType Reader Max (large size / max columns / million row tables) - // DataType conversions (Implicit) - // MARS enabled // Always Encrypted } + private void SetupConfigurations() + { + // If the config file specifies to use managed SNI on Windows, + // enable the appropriate AppContext switch to use the managed SNI implementation. + if (_config.UseManagedSniOnWindows) + { + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows", true); + } + + // 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. + if(_config.UseOptimizedAsyncBehaviour) + { + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseCompatibilityAsyncBehaviour", false); + AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseCompatibilityProcessSni", false); + } + + // If the config file specifies to wait for a profiler, + // display the process ID and wait for user input before starting the benchmarks. + if (_config.WaitForProfiler) + { + int pid = System.Diagnostics.Process.GetCurrentProcess().Id; + Console.WriteLine(); + Console.WriteLine("==========================================="); + Console.WriteLine($" Process ID: {pid}"); + Console.WriteLine("==========================================="); + Console.WriteLine(); + Console.WriteLine("Attach your profiler now. Examples:"); + Console.WriteLine($" dotnet-counters monitor -p {pid}"); + Console.WriteLine($" dotnet-trace collect -p {pid}"); + Console.WriteLine(); + Console.WriteLine("Press any key to start benchmarks..."); + Console.ReadKey(intercept: true); + Console.WriteLine(); + } + } + private void Run_SqlConnectionBenchmark() { if (_config.Benchmarks.SqlConnectionRunnerConfig?.Enabled == true) @@ -75,10 +117,73 @@ private void Run_SqlBulkCopyBenchmark() } } - public static void Main() + private void Run_AsyncLargeDataReadBenchmark() + { + if (_config.Benchmarks.AsyncLargeDataReadRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.AsyncLargeDataReadRunnerConfig)); + } + } + + private void Run_MarsOverheadBenchmark() { - // Run the benchmarks. - new Program(); + if (_config.Benchmarks.MarsOverheadRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.MarsOverheadRunnerConfig)); + } } + + private void Run_ParallelAsyncConnectionBenchmark() + { + if (_config.Benchmarks.ParallelAsyncConnectionRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.ParallelAsyncConnectionRunnerConfig)); + } + } + + private void Run_CancellationTokenReadAsyncBenchmark() + { + if (_config.Benchmarks.CancellationTokenReadAsyncRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.CancellationTokenReadAsyncRunnerConfig)); + } + } + + private void Run_SequentialXmlReadBenchmark() + { + if (_config.Benchmarks.SequentialXmlReadRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.SequentialXmlReadRunnerConfig)); + } + } + + private void Run_JsonVsVarcharReadBenchmark() + { + if (_config.Benchmarks.JsonVsVarcharReadRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.JsonVsVarcharReadRunnerConfig)); + } + } + + private void Run_BeginTransactionBenchmark() + { + if (_config.Benchmarks.BeginTransactionRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.BeginTransactionRunnerConfig)); + } + } + + private void Run_ConnectionPoolStressBenchmark() + { + if (_config.Benchmarks.ConnectionPoolStressRunnerConfig?.Enabled == true) + { + BenchmarkRunner.Run(BenchmarkConfig.s_instance(_config.Benchmarks.ConnectionPoolStressRunnerConfig)); + } + } + + /// + /// The main entry point for the performance tests program. + /// + public static void Main() => _ = new Program(); } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json index 47d1736127..6239f17fab 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json @@ -1,9 +1,11 @@ { "ConnectionString": "Server=tcp:localhost; Integrated Security=true; Initial Catalog=sqlclient-perf-db;", "UseManagedSniOnWindows": false, + "UseOptimizedAsyncBehaviour": true, + "WaitForProfiler": false, "Benchmarks": { "SqlConnectionRunnerConfig": { - "Enabled": true, + "Enabled": false, "LaunchCount": 1, "IterationCount": 50, "InvocationCount":30, @@ -11,7 +13,7 @@ "RowCount": 0 }, "SqlCommandRunnerConfig": { - "Enabled": true, + "Enabled": false, "LaunchCount": 1, "IterationCount": 10, "InvocationCount": 2, @@ -19,7 +21,7 @@ "RowCount": 10000 }, "SqlBulkCopyRunnerConfig": { - "Enabled": true, + "Enabled": false, "LaunchCount": 1, "IterationCount": 10, "InvocationCount": 1, @@ -27,7 +29,7 @@ "RowCount": 10000 }, "DataTypeReaderRunnerConfig": { - "Enabled": true, + "Enabled": false, "LaunchCount": 1, "IterationCount": 20, "InvocationCount": 2, @@ -35,12 +37,76 @@ "RowCount": 1000 }, "DataTypeReaderAsyncRunnerConfig": { - "Enabled": true, + "Enabled": false, "LaunchCount": 1, "IterationCount": 20, "InvocationCount": 2, "WarmupCount": 1, "RowCount": 1000 + }, + "AsyncLargeDataReadRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 + }, + "MarsOverheadRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 1000 + }, + "ParallelAsyncConnectionRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 + }, + "CancellationTokenReadAsyncRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 10000 + }, + "SequentialXmlReadRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 + }, + "JsonVsVarcharReadRunnerConfig": { + "Enabled": false, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 10000 + }, + "BeginTransactionRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 10, + "InvocationCount": 2, + "WarmupCount": 1, + "RowCount": 0 + }, + "ConnectionPoolStressRunnerConfig": { + "Enabled": true, + "LaunchCount": 1, + "IterationCount": 5, + "InvocationCount": 1, + "WarmupCount": 1, + "RowCount": 0 } } } From efeed684bf070f9a26e9c0e2f1b0f33509bcd5e2 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 15 Jul 2026 00:33:22 -0700 Subject: [PATCH 2/8] Touch-ups and updates --- BUILDGUIDE.md | 24 +++++------ .../AsyncLargeDataReadRunner.cs | 7 ++-- .../CancellationTokenReadAsyncRunner.cs | 4 +- .../ConnectionPoolStressRunner.cs | 7 +++- .../JsonVsVarcharReadRunner.cs | 7 ++-- .../tests/PerformanceTests/Config/Config.cs | 4 +- ...oft.Data.SqlClient.PerformanceTests.csproj | 2 +- .../{runnerconfig.json => runnerconfig.jsonc} | 40 +++++++++++-------- 8 files changed, 53 insertions(+), 42 deletions(-) rename src/Microsoft.Data.SqlClient/tests/PerformanceTests/{runnerconfig.json => runnerconfig.jsonc} (71%) diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index 5acf76cbd5..44ff2fd350 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -451,13 +451,13 @@ $ sqlcmd -S localhost -U sa -P password 1> quit ``` -The default `runnerconfig.json` expects a database named `sqlclient-perf-db`, +The default `runnerconfig.jsonc` expects a database named `sqlclient-perf-db`, but you may change the config to use any existing database. All tables in the database will be dropped when running the benchmarks. ### Configure Runner -Configure the benchmarks by editing the `runnerconfig.json` file directly in the +Configure the benchmarks by editing the `runnerconfig.jsonc` file directly in the `PerformanceTests` directory with an appropriate connection string and benchmark settings: @@ -484,36 +484,36 @@ settings: Individual benchmarks may be enabled or disabled, and each has several benchmarking options for fine tuning. -After making edits to `runnerconfig.json` you must perform a build which will +After making edits to `runnerconfig.jsonc` you must perform a build which will copy the file into the `artifacts` directory alongside the benchmark DLL. By -default, the benchmarks look for `runnerconfig.json` in the same directory as +default, the benchmarks look for `runnerconfig.jsonc` in the same directory as the DLL. Optionally, to avoid polluting your git workspace and requiring a build after -each config change, copy `runnerconfig.json` to a new file, make your edits +each config change, copy `runnerconfig.jsonc` to a new file, make your edits there, and then specify the new file with the RUNNER_CONFIG environment variable. PowerShell: ```pwsh -> copy runnerconfig.json $HOME\.configs\runnerconfig.json +> copy runnerconfig.jsonc $HOME\.configs\runnerconfig.jsonc -# Make edits to $HOME\.configs\runnerconfig.json +# Make edits to $HOME\.configs\runnerconfig.jsonc # You must set the RUNNER_CONFIG environment variable for the current shell. -> $env:RUNNER_CONFIG="${HOME}\.configs\runnerconfig.json" +> $env:RUNNER_CONFIG="${HOME}\.configs\runnerconfig.jsonc" ``` Bash: ```bash -$ cp runnerconfig.json ~/.configs/runnerconfig.json +$ cp runnerconfig.jsonc ~/.configs/runnerconfig.jsonc -# Make edits to ~/.configs/runnerconfig.json +# Make edits to ~/.configs/runnerconfig.jsonc # Optionally export RUNNER_CONFIG. -$ export RUNNER_CONFIG=~/.configs/runnerconfig.json +$ export RUNNER_CONFIG=~/.configs/runnerconfig.jsonc ``` ### Run Benchmarks @@ -533,5 +533,5 @@ Bash: # copy prepared by the build. $ dotnet run -c Release -f net9.0 -$ RUNNER_CONFIG=~/.configs/runnerconfig.json dotnet run -c Release -f net9.0 +$ RUNNER_CONFIG=~/.configs/runnerconfig.jsonc dotnet run -c Release -f net9.0 ``` diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs index 75593f66a7..9073f21264 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs @@ -86,13 +86,12 @@ public async Task ReadLargeDataAsync() using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess); while (await reader.ReadAsync()) { + using var stream = reader.GetStream(0); byte[] buffer = new byte[8192]; - long offset = 0; - long bytesRead; + int bytesRead; do { - bytesRead = reader.GetBytes(0, offset, buffer, 0, buffer.Length); - offset += bytesRead; + bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); } while (bytesRead > 0); } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs index 8ae2da1f8a..b6bf12b0c2 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs @@ -48,12 +48,12 @@ public void Setup() }; var dt = new System.Data.DataTable(); - dt.Columns.Add("Id", typeof(int)); dt.Columns.Add("Value", typeof(int)); for (int i = 0; i < s_rowCount; i++) { - dt.Rows.Add(i, i * 2); + dt.Rows.Add(i * 2); } + bulkCopy.ColumnMappings.Add("Value", "Value"); bulkCopy.WriteToServer(dt); _query = $"SELECT Id, Value FROM {_tableName}"; diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index c123e99f1d..292da604ba 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -44,8 +44,11 @@ public void Setup() _connectionString = s_config.ConnectionString + $";Pooling=True;Max Pool Size={MaxPoolSize};Min Pool Size=5;Connect Timeout=60"; - // Create a small table for query workloads - _tableName = $"[perf_PoolStress_{Environment.MachineName}_{Guid.NewGuid():N}]"; + // 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. + string machineHash = Math.Abs(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( diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs index 470d354b19..ccf41ff03b 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs @@ -13,7 +13,7 @@ namespace Microsoft.Data.SqlClient.PerformanceTests /// Benchmarks comparing read performance of JSON vs VARCHAR(MAX) columns. /// Reproduces issue #3499. /// Note: JSON column type requires SQL Server 2025+ or Azure SQL. - /// This runner is disabled by default in runnerconfig.json. + /// This runner is disabled by default in runnerconfig.jsonc. /// public class JsonVsVarcharReadRunner : BaseRunner { @@ -61,20 +61,21 @@ public void Setup() // Bulk insert identical data into both tables var dt = new System.Data.DataTable(); - dt.Columns.Add("Id", typeof(int)); dt.Columns.Add("Data", typeof(string)); for (int i = 0; i < s_rowCount; i++) { - dt.Rows.Add(i, sampleJson); + dt.Rows.Add(sampleJson); } using (var bulkCopy = new SqlBulkCopy(conn) { DestinationTableName = _jsonTableName, BatchSize = 10000 }) { + bulkCopy.ColumnMappings.Add("Data", "Data"); bulkCopy.WriteToServer(dt); } using (var bulkCopy = new SqlBulkCopy(conn) { DestinationTableName = _varcharTableName, BatchSize = 10000 }) { + bulkCopy.ColumnMappings.Add("Data", "Data"); bulkCopy.WriteToServer(dt); } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs index 1aaaa65b67..3b9cd28cc3 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs @@ -20,7 +20,7 @@ public class Config /// /// If the environment variable "RUNNER_CONFIG" is set, it will be used /// as the path to the config file. Otherwise, the file - /// "runnerconfig.json" in the current working directory will be used. + /// "runnerconfig.jsonc" in the current working directory will be used. /// /// /// @@ -34,7 +34,7 @@ public class Config public static Config Load() { return Loader.FromJsonFile( - "runnerconfig.json", "RUNNER_CONFIG"); + "runnerconfig.jsonc", "RUNNER_CONFIG"); } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj index 2da560b6b7..0c2a4958d4 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc similarity index 71% rename from src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json rename to src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc index 6239f17fab..783f16ab28 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.json +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc @@ -1,11 +1,19 @@ { "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 enable optimized async behavior in SqlClient. + // This is useful for benchmarking packet multiplexing and async performance improvements. "UseOptimizedAsyncBehaviour": true, + // Enable this flag to get prompted to attach a profiler when running the performance tests. + // This is useful for capturing event source traces when running a performance test. "WaitForProfiler": false, + // This section contains the configuration for each benchmark. + // You can enable/disable benchmarks and configure the number of iterations, invocations, + // warmup runs, and row count for each benchmark. "Benchmarks": { "SqlConnectionRunnerConfig": { - "Enabled": false, + "Enabled": true, "LaunchCount": 1, "IterationCount": 50, "InvocationCount":30, @@ -13,23 +21,23 @@ "RowCount": 0 }, "SqlCommandRunnerConfig": { - "Enabled": false, + "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 2, "WarmupCount": 1, "RowCount": 10000 }, "SqlBulkCopyRunnerConfig": { - "Enabled": false, + "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 10000 }, "DataTypeReaderRunnerConfig": { - "Enabled": false, + "Enabled": true, "LaunchCount": 1, "IterationCount": 20, "InvocationCount": 2, @@ -37,7 +45,7 @@ "RowCount": 1000 }, "DataTypeReaderAsyncRunnerConfig": { - "Enabled": false, + "Enabled": true, "LaunchCount": 1, "IterationCount": 20, "InvocationCount": 2, @@ -47,7 +55,7 @@ "AsyncLargeDataReadRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 0 @@ -55,7 +63,7 @@ "MarsOverheadRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 1000 @@ -63,7 +71,7 @@ "ParallelAsyncConnectionRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 0 @@ -71,7 +79,7 @@ "CancellationTokenReadAsyncRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 10000 @@ -79,7 +87,7 @@ "SequentialXmlReadRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 0 @@ -87,15 +95,15 @@ "JsonVsVarcharReadRunnerConfig": { "Enabled": false, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 100, "InvocationCount": 1, - "WarmupCount": 1, + "WarmupCount": 5, "RowCount": 10000 }, "BeginTransactionRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 10, + "IterationCount": 20, "InvocationCount": 2, "WarmupCount": 1, "RowCount": 0 @@ -103,7 +111,7 @@ "ConnectionPoolStressRunnerConfig": { "Enabled": true, "LaunchCount": 1, - "IterationCount": 5, + "IterationCount": 20, "InvocationCount": 1, "WarmupCount": 1, "RowCount": 0 From 53823214fdafff32c4c0c157dc592a5fc2ccc250 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 15 Jul 2026 01:01:14 -0700 Subject: [PATCH 3/8] Enable Native profiling on Windows --- BUILDGUIDE.md | 5 +- .../tests/Directory.Packages.props | 1 + .../BeginTransactionRunner.cs | 11 ++++ .../Config/BenchmarkConfig.cs | 58 +++++++++++++------ .../tests/PerformanceTests/Config/Config.cs | 1 + ...oft.Data.SqlClient.PerformanceTests.csproj | 8 +++ .../tests/PerformanceTests/Program.cs | 5 ++ .../tests/PerformanceTests/runnerconfig.jsonc | 8 ++- 8 files changed, 76 insertions(+), 21 deletions(-) diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index 44ff2fd350..b388bb382e 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -452,8 +452,9 @@ $ sqlcmd -S localhost -U sa -P password ``` The default `runnerconfig.jsonc` expects a database named `sqlclient-perf-db`, -but you may change the config to use any existing database. All tables in -the database will be dropped when running the benchmarks. +but you may change the config to use any existing database. The benchmarks +create and drop their own tables (typically prefixed with `perf_`) in this +database; other existing tables are left untouched. ### Configure Runner diff --git a/src/Microsoft.Data.SqlClient/tests/Directory.Packages.props b/src/Microsoft.Data.SqlClient/tests/Directory.Packages.props index 4f3dbacb21..54596a729f 100644 --- a/src/Microsoft.Data.SqlClient/tests/Directory.Packages.props +++ b/src/Microsoft.Data.SqlClient/tests/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs index 4509f68aed..f72d0449dd 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs @@ -40,6 +40,17 @@ public void Cleanup() SqlConnection.ClearAllPools(); } + // Truncate the table between iterations so every iteration inserts into an empty + // table. Without this, the table grows across iterations (and across benchmarks, + // since they share the same connection/table), which would skew the measured + // BeginTransaction overhead as row count increases. + [IterationCleanup] + public void IterationCleanup() + { + using var cmd = new SqlCommand($"TRUNCATE TABLE {_tableName}", _connection); + cmd.ExecuteNonQuery(); + } + [Benchmark] public void WithTransaction() { diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs index 9f44c5889e..7db0130977 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs @@ -12,23 +12,45 @@ namespace Microsoft.Data.SqlClient.PerformanceTests { public static class BenchmarkConfig { - public static ManualConfig s_instance(RunnerJob runnerJob) => - DefaultConfig.Instance - .WithOption(ConfigOptions.DisableOptimizationsValidator, true) - .WithOption(ConfigOptions.DontOverwriteResults, true) - .AddDiagnoser(MemoryDiagnoser.Default) - .AddDiagnoser(ThreadingDiagnoser.Default) - .AddExporter(MarkdownExporter.GitHub) - .AddJob( - Job.MediumRun.WithToolchain(InProcessEmitToolchain.Instance) - .WithLaunchCount(runnerJob.LaunchCount) - .WithInvocationCount(runnerJob.InvocationCount) - .WithIterationCount(runnerJob.IterationCount) - .WithWarmupCount(runnerJob.WarmupCount) - .WithUnrollFactor(1) - .WithStrategy(BenchmarkDotNet.Engines.RunStrategy.Throughput) - .WithEnvironmentVariable("COMPlus_gcServer", "1") - ) - .WithOptions(ConfigOptions.JoinSummary); + /// + /// When set to true, attaches the NativeMemoryProfiler and EtwProfiler diagnosers + /// so native memory allocations and ETW traces are captured for each benchmark run. + /// This is only supported on Windows; the value is ignored on other OSes since the + /// underlying diagnosers are compiled out (see the "WINDOWS" compile constant, which + /// is only set when building on Windows in the PerformanceTests.csproj file). + /// + public static bool UseNativeMemoryAndEtwProfiler { get; set; } + + public static ManualConfig s_instance(RunnerJob runnerJob) + { + ManualConfig config = DefaultConfig.Instance + .WithOption(ConfigOptions.DisableOptimizationsValidator, true) + .WithOption(ConfigOptions.DontOverwriteResults, true) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddDiagnoser(ThreadingDiagnoser.Default) + .AddExporter(MarkdownExporter.GitHub) + .AddJob( + Job.MediumRun.WithToolchain(InProcessEmitToolchain.Instance) + .WithLaunchCount(runnerJob.LaunchCount) + .WithInvocationCount(runnerJob.InvocationCount) + .WithIterationCount(runnerJob.IterationCount) + .WithWarmupCount(runnerJob.WarmupCount) + .WithUnrollFactor(1) + .WithStrategy(BenchmarkDotNet.Engines.RunStrategy.Throughput) + .WithEnvironmentVariable("COMPlus_gcServer", "1") + ) + .WithOptions(ConfigOptions.JoinSummary); + +#if WINDOWS + if (UseNativeMemoryAndEtwProfiler) + { + config = config + .AddDiagnoser(NativeMemoryProfiler.Default) + .AddDiagnoser(new EtwProfiler()); + } +#endif + + return config; + } } } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs index 3b9cd28cc3..7a93f65090 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/Config.cs @@ -13,6 +13,7 @@ public class Config public bool UseManagedSniOnWindows; public bool UseOptimizedAsyncBehaviour; public bool WaitForProfiler; + public bool UseNativeMemoryAndETWProfiler; public Benchmarks Benchmarks; /// diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj index 0c2a4958d4..1986ce68e1 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj @@ -38,4 +38,12 @@ + + + + + + + $(DefineConstants);WINDOWS + diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs index 8ba4f89432..8f64c7448a 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Program.cs @@ -57,6 +57,11 @@ private void SetupConfigurations() AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseCompatibilityProcessSni", false); } + // If the config file specifies to use the NativeMemoryProfiler and ETW profiler, + // propagate the setting to BenchmarkConfig so it can attach these Windows-only + // diagnosers when building each benchmark's ManualConfig. + BenchmarkConfig.UseNativeMemoryAndEtwProfiler = _config.UseNativeMemoryAndETWProfiler; + // If the config file specifies to wait for a profiler, // display the process ID and wait for user input before starting the benchmarks. if (_config.WaitForProfiler) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc index 783f16ab28..6eb459fbaf 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc @@ -6,8 +6,14 @@ // This is useful for benchmarking packet multiplexing and async performance improvements. "UseOptimizedAsyncBehaviour": true, // Enable this flag to get prompted to attach a profiler when running the performance tests. - // This is useful for capturing event source traces when running a performance test. + // This is useful for capturing event source traces when running a performance test on all OSes. + // Note: This flag is not required on Windows as the ETW profiler is automatically attached + // when running the performance tests on Windows. "WaitForProfiler": false, + // Enable this flag to enable the NativeMemoryProfiler and ETW profiler on Windows (only). + // This is useful for capturing native memory allocation traces when running a performance test on Windows. + // Note: This flag cannot be enabled on non-Windows OSes as the NativeMemoryProfiler is not supported on non-Windows OSes. + "UseNativeMemoryAndETWProfiler": false, // This section contains the configuration for each benchmark. // You can enable/disable benchmarks and configure the number of iterations, invocations, // warmup runs, and row count for each benchmark. From eb980d75028851237d117212dd82a4ae4372628b Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 15 Jul 2026 01:12:55 -0700 Subject: [PATCH 4/8] Update doc, address comments --- BUILDGUIDE.md | 20 ++++++++++++++++++- .../CancellationTokenReadAsyncRunner.cs | 7 +++++-- .../ConnectionPoolStressRunner.cs | 6 +++--- .../JsonVsVarcharReadRunner.cs | 8 +++++++- .../Config/BenchmarkConfig.cs | 3 +++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index b388bb382e..ee3f0b7d0d 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -466,6 +466,9 @@ settings: { "ConnectionString": "Server=tcp:localhost; Integrated Security=true; Initial Catalog=sqlclient-perf-db;", "UseManagedSniOnWindows": false, + "UseOptimizedAsyncBehaviour": true, + "WaitForProfiler": false, + "UseNativeMemoryAndETWProfiler": false, "Benchmarks": { "SqlConnectionRunnerConfig": @@ -485,6 +488,20 @@ settings: Individual benchmarks may be enabled or disabled, and each has several benchmarking options for fine tuning. +The top-level flags control global runner behavior: + +| Flag | Description | +| --- | --- | +| `UseManagedSniOnWindows` | Enables the managed SNI implementation on Windows instead of native SNI. | +| `UseOptimizedAsyncBehaviour` | Enables packet multiplexing and other async optimizations in SqlClient. | +| `WaitForProfiler` | Pauses at startup and prints the process ID so you can attach an external profiler (e.g. `dotnet-trace`) before benchmarks run. Not needed on Windows, since ETW is attached automatically there. | +| `UseNativeMemoryAndETWProfiler` | Attaches the `NativeMemoryProfiler` and `EtwProfiler` BenchmarkDotNet diagnosers. Windows only; has no effect on other OSes. | + +Some benchmarks (e.g. `DataTypeReaderRunner`, `DataTypeReaderAsyncRunner`) also +read per-type test values from `datatypes.json` in the `PerformanceTests` +directory. Like `runnerconfig.jsonc`, this file's location can be overridden +with the `DATATYPES_CONFIG` environment variable. + After making edits to `runnerconfig.jsonc` you must perform a build which will copy the file into the `artifacts` directory alongside the benchmark DLL. By default, the benchmarks look for `runnerconfig.jsonc` in the same directory as @@ -493,7 +510,8 @@ the DLL. Optionally, to avoid polluting your git workspace and requiring a build after each config change, copy `runnerconfig.jsonc` to a new file, make your edits there, and then specify the new file with the RUNNER_CONFIG environment -variable. +variable. The same approach works for `datatypes.json` via the +`DATATYPES_CONFIG` environment variable. PowerShell: diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs index b6bf12b0c2..79fa6322c5 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs @@ -75,10 +75,13 @@ public async Task ReadAsyncWithOrWithoutToken() using var cts = UseCancellationToken ? new CancellationTokenSource() : null; var token = cts?.Token ?? CancellationToken.None; + // Only the ReadAsync loop below should observe the CancellationToken overhead + // this benchmark is measuring, so Open/ExecuteReaderAsync intentionally run + // without a token here. using var conn = new SqlConnection(_connectionString); - await conn.OpenAsync(token); + await conn.OpenAsync(); using var cmd = new SqlCommand(_query, conn); - using var reader = await cmd.ExecuteReaderAsync(token); + using var reader = await cmd.ExecuteReaderAsync(); if (UseCancellationToken) { diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index 292da604ba..c40b3f5b71 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; @@ -46,8 +45,9 @@ public void Setup() // 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. - string machineHash = Math.Abs(Environment.MachineName.GetHashCode()).ToString("x8"); + // 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(); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs index ccf41ff03b..7b5741996e 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Data; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; @@ -85,10 +84,17 @@ public void Cleanup() { using var conn = new SqlConnection(_connectionString); conn.Open(); + using (var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_jsonTableName}", conn)) + { cmd.ExecuteNonQuery(); + } + using (var cmd = new SqlCommand($"DROP TABLE IF EXISTS {_varcharTableName}", conn)) + { cmd.ExecuteNonQuery(); + } + SqlConnection.ClearAllPools(); } diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs index 7db0130977..8cd26c8641 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs @@ -7,6 +7,9 @@ using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.Emit; +#if WINDOWS +using BenchmarkDotNet.Diagnostics.Windows.Configs; +#endif namespace Microsoft.Data.SqlClient.PerformanceTests { From d67bf7a0a7b32c8ec8c9aa22602ef46d7accdded Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:07:28 -0700 Subject: [PATCH 5/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- BUILDGUIDE.md | 2 +- .../BenchmarkRunners/AsyncLargeDataReadRunner.cs | 3 ++- .../BenchmarkRunners/BeginTransactionRunner.cs | 4 ++-- .../CancellationTokenReadAsyncRunner.cs | 10 +++++----- .../BenchmarkRunners/JsonVsVarcharReadRunner.cs | 3 ++- .../BenchmarkRunners/SequentialXmlReadRunner.cs | 4 ++-- .../tests/PerformanceTests/runnerconfig.jsonc | 5 ++--- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index ee3f0b7d0d..375e9cb318 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -494,7 +494,7 @@ The top-level flags control global runner behavior: | --- | --- | | `UseManagedSniOnWindows` | Enables the managed SNI implementation on Windows instead of native SNI. | | `UseOptimizedAsyncBehaviour` | Enables packet multiplexing and other async optimizations in SqlClient. | -| `WaitForProfiler` | Pauses at startup and prints the process ID so you can attach an external profiler (e.g. `dotnet-trace`) before benchmarks run. Not needed on Windows, since ETW is attached automatically there. | +| `WaitForProfiler` | Pauses at startup and prints the process ID so you can attach an external profiler (e.g. `dotnet-trace`) before benchmarks run. | | `UseNativeMemoryAndETWProfiler` | Attaches the `NativeMemoryProfiler` and `EtwProfiler` BenchmarkDotNet diagnosers. Windows only; has no effect on other OSes. | Some benchmarks (e.g. `DataTypeReaderRunner`, `DataTypeReaderAsyncRunner`) also diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs index 9073f21264..259bb9d828 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs @@ -28,7 +28,8 @@ public class AsyncLargeDataReadRunner : BaseRunner public void Setup() { _connectionString = s_config.ConnectionString; - _tableName = $"[perf_AsyncLargeData_{Environment.MachineName}_{Guid.NewGuid():N}]"; + string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); + _tableName = $"[perf_AsyncLargeData_{machineHash}_{Guid.NewGuid():N}]"; using var conn = new SqlConnection(_connectionString); conn.Open(); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs index f72d0449dd..3a3bded24e 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/BeginTransactionRunner.cs @@ -23,8 +23,8 @@ public void Setup() _connection = new SqlConnection(s_config.ConnectionString); _connection.Open(); - _tableName = $"[perf_TxnBench_{Environment.MachineName}_{Guid.NewGuid():N}]"; - + string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); + _tableName = $"[perf_TxnBench_{machineHash}_{Guid.NewGuid():N}]"; using var cmd = new SqlCommand( $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Value INT)", _connection); cmd.ExecuteNonQuery(); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs index 79fa6322c5..80a4d85bf0 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs @@ -31,8 +31,8 @@ public void Setup() { s_rowCount = s_config.Benchmarks.CancellationTokenReadAsyncRunnerConfig.RowCount; _connectionString = s_config.ConnectionString; - _tableName = $"[perf_CancelToken_{Environment.MachineName}_{Guid.NewGuid():N}]"; - + string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); + _tableName = $"[perf_CancelToken_{machineHash}_{Guid.NewGuid():N}]"; using var conn = new SqlConnection(_connectionString); conn.Open(); @@ -75,9 +75,9 @@ public async Task ReadAsyncWithOrWithoutToken() using var cts = UseCancellationToken ? new CancellationTokenSource() : null; var token = cts?.Token ?? CancellationToken.None; - // Only the ReadAsync loop below should observe the CancellationToken overhead - // this benchmark is measuring, so Open/ExecuteReaderAsync intentionally run - // without a token here. + // Note: When UseCancellationToken=true, this benchmark includes the cost of + // allocating/disposing a CancellationTokenSource in addition to the per-row + // ReadAsync(CancellationToken) overhead being measured. using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); using var cmd = new SqlCommand(_query, conn); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs index 7b5741996e..2a78c61f30 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs @@ -35,7 +35,8 @@ public void Setup() s_rowCount = s_config.Benchmarks.JsonVsVarcharReadRunnerConfig.RowCount; _connectionString = s_config.ConnectionString; - string suffix = $"{Environment.MachineName}_{Guid.NewGuid():N}"; + string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); + string suffix = $"{machineHash}_{Guid.NewGuid():N}"; _jsonTableName = $"[perf_Json_{suffix}]"; _varcharTableName = $"[perf_Varchar_{suffix}]"; diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs index db9430b653..4d7d15843e 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/SequentialXmlReadRunner.cs @@ -36,8 +36,8 @@ public class SequentialXmlReadRunner : BaseRunner public void Setup() { _connectionString = s_config.ConnectionString; - _tableName = $"[perf_SeqXml_{Environment.MachineName}_{Guid.NewGuid():N}]"; - + string machineHash = ((uint)Environment.MachineName.GetHashCode()).ToString("x8"); + _tableName = $"[perf_SeqXml_{machineHash}_{Guid.NewGuid():N}]"; using var conn = new SqlConnection(_connectionString); conn.Open(); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc index 6eb459fbaf..34d7a511aa 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/runnerconfig.jsonc @@ -6,9 +6,8 @@ // This is useful for benchmarking packet multiplexing and async performance improvements. "UseOptimizedAsyncBehaviour": true, // Enable this flag to get prompted to attach a profiler when running the performance tests. - // This is useful for capturing event source traces when running a performance test on all OSes. - // Note: This flag is not required on Windows as the ETW profiler is automatically attached - // when running the performance tests on Windows. + // This is useful for capturing event source traces when running a performance test on any OS. + // On Windows, you can also set UseNativeMemoryAndETWProfiler=true to attach ETW diagnosers automatically. "WaitForProfiler": false, // Enable this flag to enable the NativeMemoryProfiler and ETW profiler on Windows (only). // This is useful for capturing native memory allocation traces when running a performance test on Windows. From ad3ba33b878c31d62f9db3926944cd5ce9b3a032 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:03:52 -0700 Subject: [PATCH 6/8] Address PR review feedback - AsyncLargeDataReadRunner: seed VARBINARY(MAX) row via CRYPT_GEN_RANDOM T-SQL instead of allocating a client-side byte[] and shipping it over the wire, and add [Params] for ReadBufferBytes so we can observe how the client read buffer size interacts with payload size. - CancellationTokenReadAsyncRunner: move CancellationTokenSource allocation to [IterationSetup] and disposal to [IterationCleanup] so only the per-row ReadAsync(CancellationToken) overhead is measured. - ConnectionPoolStressRunner: scale the RapidFireOpenClose per-task iteration count with MaxPoolSize/Parallelism so total checkouts stay proportional to pool capacity, and drop the unused System.Collections.Generic import. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62d8a5ff-8bd4-4780-9715-748ea0d950aa --- .../AsyncLargeDataReadRunner.cs | 35 ++++++++++++++----- .../CancellationTokenReadAsyncRunner.cs | 35 +++++++++++++++---- .../ConnectionPoolStressRunner.cs | 6 ++-- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs index 259bb9d828..851b5b8d66 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/AsyncLargeDataReadRunner.cs @@ -24,6 +24,14 @@ public class AsyncLargeDataReadRunner : BaseRunner [Params(1_048_576, 5_242_880, 10_485_760, 20_971_520)] public int DataSizeBytes { get; set; } + /// + /// Size of the client-side read buffer used to drain the VARBINARY(MAX) column. + /// Kept small (8 KB) and large (1 MB) to observe whether buffer size relative to + /// the payload materially changes throughput. + /// + [Params(8_192, 1_048_576)] + public int ReadBufferBytes { get; set; } + [GlobalSetup] public void Setup() { @@ -38,13 +46,24 @@ public void Setup() $"CREATE TABLE {_tableName} (Id INT IDENTITY PRIMARY KEY, Data VARBINARY(MAX))", conn); createCmd.ExecuteNonQuery(); - // Insert a single row with random bytes of the specified size - byte[] data = new byte[DataSizeBytes]; - Random.Shared.NextBytes(data); - + // Generate the payload entirely server-side via CRYPT_GEN_RANDOM so we don't + // allocate a multi-megabyte byte[] on the client and don't ship the payload + // over the wire just to seed the benchmark. using var insertCmd = new SqlCommand( - $"INSERT INTO {_tableName} (Data) VALUES (@data)", conn); - insertCmd.Parameters.Add("@data", SqlDbType.VarBinary, -1).Value = data; + $@"INSERT INTO {_tableName} (Data) + SELECT SUBSTRING( + CONVERT( + varbinary(max), + REPLICATE( + CONVERT(varchar(max), CRYPT_GEN_RANDOM(8000), 2), + (@dataSizeBytes + 7999) / 8000 + ), + 2 + ), + 1, + @dataSizeBytes + );", conn); + insertCmd.Parameters.Add("@dataSizeBytes", SqlDbType.Int).Value = DataSizeBytes; insertCmd.ExecuteNonQuery(); } @@ -67,7 +86,7 @@ public void ReadLargeDataSync() using var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess); while (reader.Read()) { - byte[] buffer = new byte[8192]; + byte[] buffer = new byte[ReadBufferBytes]; long offset = 0; long bytesRead; do @@ -88,7 +107,7 @@ public async Task ReadLargeDataAsync() while (await reader.ReadAsync()) { using var stream = reader.GetStream(0); - byte[] buffer = new byte[8192]; + byte[] buffer = new byte[ReadBufferBytes]; int bytesRead; do { diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs index 80a4d85bf0..aa632503fc 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/CancellationTokenReadAsyncRunner.cs @@ -19,6 +19,8 @@ public class CancellationTokenReadAsyncRunner : BaseRunner private string _tableName; private string _connectionString; private string _query; + private CancellationTokenSource _cts; + private CancellationToken _token; /// /// Whether to pass a CancellationToken to ReadAsync. @@ -69,15 +71,34 @@ public void Cleanup() SqlConnection.ClearAllPools(); } + // Allocate the CancellationTokenSource in [IterationSetup] / dispose it in + // [IterationCleanup] so the CTS alloc/dispose cost is excluded from the measurement + // and only the per-row ReadAsync(CancellationToken) overhead is captured. + [IterationSetup] + public void IterationSetup() + { + if (UseCancellationToken) + { + _cts = new CancellationTokenSource(); + _token = _cts.Token; + } + else + { + _cts = null; + _token = CancellationToken.None; + } + } + + [IterationCleanup] + public void IterationCleanup() + { + _cts?.Dispose(); + _cts = null; + } + [Benchmark] public async Task ReadAsyncWithOrWithoutToken() { - using var cts = UseCancellationToken ? new CancellationTokenSource() : null; - var token = cts?.Token ?? CancellationToken.None; - - // Note: When UseCancellationToken=true, this benchmark includes the cost of - // allocating/disposing a CancellationTokenSource in addition to the per-row - // ReadAsync(CancellationToken) overhead being measured. using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); using var cmd = new SqlCommand(_query, conn); @@ -85,7 +106,7 @@ public async Task ReadAsyncWithOrWithoutToken() if (UseCancellationToken) { - while (await reader.ReadAsync(token)) + while (await reader.ReadAsync(_token)) { } } else diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index c40b3f5b71..e2fb7748e5 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; @@ -81,16 +80,19 @@ public void Cleanup() /// /// Pure open/close churn — every task opens a pooled connection, immediately closes it, /// and repeats. Measures raw pool checkout/return throughput under contention. + /// The per-task loop count scales with MaxPoolSize so total checkouts stay proportional + /// to pool capacity regardless of how the [Params] values change. /// [Benchmark] public async Task RapidFireOpenClose() { + int iterationsPerTask = Math.Max(20, MaxPoolSize / Math.Max(1, Parallelism) * 4); var tasks = new Task[Parallelism]; for (int i = 0; i < Parallelism; i++) { tasks[i] = Task.Run(async () => { - for (int j = 0; j < 20; j++) + for (int j = 0; j < iterationsPerTask; j++) { using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); From 8787fce8a1ee663a3bccec3486adf3f264308dcf Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:26:55 -0700 Subject: [PATCH 7/8] Address second round of review feedback - JsonVsVarcharReadRunner: use SqlDataReader.GetSqlJson(0).Value for the JSON case (sync and async) so the benchmark exercises the SqlJson accessor path implicated in #3499 instead of the shared GetString / GetFieldValueAsync path used for VARCHAR. - ConnectionPoolStressRunner.BurstyTrafficPattern: spin up Parallelism concurrent tasks per burst instead of Parallelism/burstCount, so the configured Parallelism parameter actually drives observed concurrency. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62d8a5ff-8bd4-4780-9715-748ea0d950aa --- .../ConnectionPoolStressRunner.cs | 17 +++++------ .../JsonVsVarcharReadRunner.cs | 30 ++++++++++++++++--- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index e2fb7748e5..9dad6f41d0 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -241,24 +241,21 @@ public async Task PoolExhaustionRecovery() /// /// Bursty traffic pattern — sends waves of connections with pauses between bursts, - /// simulating real web server traffic patterns where requests cluster. + /// simulating real web server traffic patterns where requests cluster. Each burst + /// spins up concurrent tasks so the configured parallelism + /// level actually drives the observed concurrency. /// [Benchmark] public async Task BurstyTrafficPattern() { - int burstCount = 5; - int tasksPerBurst = Parallelism / burstCount; - if (tasksPerBurst < 1) - { - tasksPerBurst = 1; - } + const int burstCount = 5; for (int burst = 0; burst < burstCount; burst++) { - var tasks = new Task[tasksPerBurst]; - for (int i = 0; i < tasksPerBurst; i++) + var tasks = new Task[Parallelism]; + for (int i = 0; i < Parallelism; i++) { - int seed = burst * tasksPerBurst + i; + int seed = burst * Parallelism + i; tasks[i] = Task.Run(async () => { var rng = new Random(seed); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs index 2a78c61f30..7b9533386b 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/JsonVsVarcharReadRunner.cs @@ -106,9 +106,21 @@ public void ReadDataSync() conn.Open(); using var cmd = new SqlCommand($"SELECT Data FROM {ActiveTableName}", conn); using var reader = cmd.ExecuteReader(); - while (reader.Read()) + if (ColumnType == "JSON") { - _ = reader.GetString(0); + // Exercise the SqlJson accessor path so the JSON case captures + // JSON-type handling overhead rather than the shared string path. + while (reader.Read()) + { + _ = reader.GetSqlJson(0).Value; + } + } + else + { + while (reader.Read()) + { + _ = reader.GetString(0); + } } } @@ -119,9 +131,19 @@ public async Task ReadDataAsync() await conn.OpenAsync(); using var cmd = new SqlCommand($"SELECT Data FROM {ActiveTableName}", conn); using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) + if (ColumnType == "JSON") + { + while (await reader.ReadAsync()) + { + _ = reader.GetSqlJson(0).Value; + } + } + else { - _ = await reader.GetFieldValueAsync(0); + while (await reader.ReadAsync()) + { + _ = await reader.GetFieldValueAsync(0); + } } } } From b2940384a8fdb27b35e6f7cf9ddbcb2cbe521d63 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:26:07 -0700 Subject: [PATCH 8/8] Perf benchmark follow-ups deferred from #4447 - ConnectionPoolStressRunner: expand MaxPoolSize [Params] to include 500 and 1000 so the pool bookkeeping is exercised under high capacity (per @paulmedynski's suggestion to include larger sizes). - ConnectionPoolStressRunner: scale MixedSyncAsyncContention iterations and MultiCommandReuse burst size with MaxPoolSize/Parallelism so the workload per checkout tracks pool capacity regardless of [Params] values (mirrors the earlier RapidFireOpenClose scaling change). - Move server GC configuration to the perf test csproj via true so it actually applies to the InProcessEmitToolchain host, and drop the equivalent BDN .WithEnvironmentVariable("COMPlus_gcServer", "1") line, which had no effect since COMPlus_gcServer is a startup-only runtime knob. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62d8a5ff-8bd4-4780-9715-748ea0d950aa --- .../ConnectionPoolStressRunner.cs | 19 ++++++++++++++----- .../Config/BenchmarkConfig.cs | 4 +++- ...oft.Data.SqlClient.PerformanceTests.csproj | 5 +++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs index 9dad6f41d0..030578cd7f 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/BenchmarkRunners/ConnectionPoolStressRunner.cs @@ -32,8 +32,11 @@ public class ConnectionPoolStressRunner : BaseRunner /// /// Max pool size — controls how many physical connections the pool can hold. /// When Parallelism exceeds this, tasks must wait for a free connection. + /// The larger values exercise pool bookkeeping under high capacity; SQL Server + /// itself must be able to accept that many concurrent connections for the + /// higher values to be meaningful. /// - [Params(50, 100)] + [Params(50, 100, 500, 1000)] public int MaxPoolSize { get; set; } [GlobalSetup] @@ -145,18 +148,21 @@ public async Task RandomizedHoldAndQuery() /// /// 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. + /// between sync and async checkout. Per-task iteration count scales with + /// MaxPoolSize/Parallelism so total checkouts stay proportional to pool + /// capacity regardless of how the [Params] values change. /// [Benchmark] public async Task MixedSyncAsyncContention() { + int iterationsPerTask = Math.Max(10, MaxPoolSize / Math.Max(1, Parallelism) * 2); 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++) + for (int j = 0; j < iterationsPerTask; j++) { using var conn = new SqlConnection(_connectionString); if (useAsync) @@ -181,10 +187,14 @@ public async Task MixedSyncAsyncContention() /// 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). + /// Command burst size scales with MaxPoolSize so the workload per checkout grows + /// with pool capacity. /// [Benchmark] public async Task MultiCommandReuse() { + int minCommands = Math.Max(5, MaxPoolSize / 20); + int maxCommandsExclusive = Math.Max(minCommands + 1, MaxPoolSize / 5); var tasks = new Task[Parallelism]; for (int i = 0; i < Parallelism; i++) { @@ -195,8 +205,7 @@ public async Task MultiCommandReuse() using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); - // Execute a burst of 5-15 commands on the same connection - int commandCount = rng.Next(5, 16); + int commandCount = rng.Next(minCommands, maxCommandsExclusive); for (int c = 0; c < commandCount; c++) { using var cmd = new SqlCommand($"SELECT Val FROM {_tableName} WHERE Id = @id", conn); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs index 8cd26c8641..053145d39b 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Config/BenchmarkConfig.cs @@ -40,7 +40,9 @@ public static ManualConfig s_instance(RunnerJob runnerJob) .WithWarmupCount(runnerJob.WarmupCount) .WithUnrollFactor(1) .WithStrategy(BenchmarkDotNet.Engines.RunStrategy.Throughput) - .WithEnvironmentVariable("COMPlus_gcServer", "1") + // Server GC is configured at the host-process level via the + // csproj property (COMPlus_gcServer is + // startup-only and would be ignored here under InProcessEmitToolchain). ) .WithOptions(ConfigOptions.JoinSummary); diff --git a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj index 1986ce68e1..9dd68fe01b 100644 --- a/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/PerformanceTests/Microsoft.Data.SqlClient.PerformanceTests.csproj @@ -5,6 +5,11 @@ net8.0;net9.0;net10.0 Debug;Release; Microsoft.Data.SqlClient.PerformanceTests.Program + + true + true