Skip to content

Use parameterized trace calls instead of eager interpolated strings - #4528

Merged
mdaigle merged 5 commits into
mainfrom
dev/automation/parameterized-trace-calls
Aug 11, 2026
Merged

Use parameterized trace calls instead of eager interpolated strings#4528
mdaigle merged 5 commits into
mainfrom
dev/automation/parameterized-trace-calls

Conversation

@mdaigle

@mdaigle mdaigle commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description

Connection pool perf benchmarks have shown memory regressions against the 6.1.6 baseline. The cause is that 119 SqlClientEventSource.Log.Try*Event(...) call sites were converted to C# interpolated strings during the netfx/netcore unification.

Those overloads are written to be cheap when tracing is off:

public void TryAdvancedTraceEvent(string message)
{
    if (Log.IsAdvancedTraceOn())
    {
        AdvancedTrace(message);
    }
}

That only helps when the caller passes a constant format string plus arguments. An interpolated string is built at the call site before the call, so the guard is reached after the cost has already been paid. Every traced operation allocated a formatted string even with tracing disabled.

Approach

This restores the format string plus arguments style the driver used in 6.1.6:

SqlClientEventSource.Log.TryAdvancedTraceEvent(
    "SqlInternalConnection.Deactivate | ADV | Object ID {0} deactivating, Client Connection Id {1}",
    ObjectID, Connection?.ClientConnectionId);

All 119 sites were converted mechanically with a C#-aware rewriter that handles verbatim strings, nested interpolation holes, alignment and format suffixes, and the "literal " + $"..." form (roughly a third of the sites, and easy to miss with a naive search). Trace output is unchanged. Every conversion fits an existing overload, so SqlClientEventSource itself is untouched.

Files changed, all under src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/:

  • Connection/SqlConnectionInternal.cs (the bulk, including the per-checkout Deactivate path that drove the pool numbers)
  • SqlCommand.Reader.cs, SqlCommand.cs, SqlCommand.NonQuery.cs, SqlCommand.Xml.cs, SqlCommand.Scalar.cs
  • SqlAuthenticationProviderManager.cs
  • Utilities/AsyncHelper.cs

No public API changes. No behavior changes outside of tracing cost.

Three latent defects fixed in passing

  1. OnFeatureExtAck logged $"Object ID {0}". Inside an interpolated string {0} is the expression 0, so it always reported an object ID of 0 rather than the real one.
  2. Two sites kept a trailing format argument after being interpolated. These bind to the generic overload, which runs string.Format over already-formatted text. The argument is redundant, and a value containing a brace (for example a routed server name) would raise a FormatException from inside tracing.

Trade-off worth noting

The parameterized form still evaluates argument expressions eagerly, which a if (Log.Is*On()) guard would avoid. The arguments on the hot paths are cheap: ObjectID is an int passed through a generic overload so it does not box, ClientConnectionId is a Guid?, CommandText is a plain getter, and ActivityCorrelator.Current is thread-local cached. The only allocating arguments are on startup paths in SqlAuthenticationProviderManager.

A guard-based variant of the same fix measures byte-for-byte identical and is available on dev/automation/guard-eager-trace-strings if reviewers prefer that shape. The parameterized form was chosen because it removes the nesting, matches the convention used everywhere else in the driver, and keeps the call sites diffing cleanly against 6.1.6.

Two things for follow-up, not addressed here

Residual 64 bytes/op on pooled open/close. Churn is still exactly 64 B/op above 6.1.6, identically in sync and async. None of it is logging. Confirmed by experiment: caching the per-open TimeoutTimer drops churn from 476.56 KB to 429.69 KB per 1000 ops, isolating 48 of the 64 bytes. The remaining 16 B is SqlConnection object layout growth. Both look deliberate rather than defects.

UseOptimizedAsyncBehaviour dominates the pool numbers. The perf runner's checked-in runnerconfig.jsonc sets UseOptimizedAsyncBehaviour: true, which turns off Switch.Microsoft.Data.SqlClient.UseCompatibilityAsyncBehaviour and ...UseCompatibilityProcessSni. Neither switch exists in 6.1.6, so the setting is a no-op on the baseline and a significant behavior change on current. With it on, ConnectionPoolContention sync allocates 9.49 MB against 6.1.6's 1.61 MB; with it off, 1.84 MB. That is a larger effect than the trace regression and entirely unrelated to it, and likely part of what the original pool reports were picking up. Worth a separate look by the async path owners.

Issues

No filed issue. Found while investigating reported connection pool memory regressions against the 6.1.6 perf baseline.

Testing

No new tests. This is a mechanical rewrite of trace call sites that produces identical trace output, so existing EventSource coverage is the right check. The three defect fixes are in trace message content only.

Automated:

  • Clean build on net8.0 and net9.0, no new warnings.
  • 294 connection pool and EventSource unit tests pass.
  • 117 functional EventSource, diagnostic, command and auth tests pass (3 skipped).
  • Verified 0 eagerly built Try*Event sites remain, and validated all 130 trace calls in the touched files for contiguous placeholders, correct arity, and overload availability.

Performance, measured back to back on the same machine and SQL Server instance with the repo's own BenchmarkDotNet runners. "6.1.6" is the released package consumed via -p:ReferenceType=Package -p:MdsPackageVersion=6.1.6, so only the driver varies. UseOptimizedAsyncBehaviour was off on both builds so the comparison is like for like.

ConnectionPoolChurn, allocated per 1000 ops:

Benchmark 6.1.6 Before After
RapidOpenCloseSingleThread 414.06 KB 671.88 KB 476.56 KB
RapidOpenCloseSingleThreadAsync 604.47 KB 862.28 KB 666.97 KB

ConnectionPoolContention, 50 workers / pool 50:

Case 6.1.6 Before After
sync 1.61 MB 5.42 MB 1.84 MB
async 3.85 MB 13.87 MB 3.91 MB

ConnectionPoolStress, parallelism 10 / pool 50 shown; all 36 cases behave the same way:

Benchmark 6.1.6 Before After
RapidFireOpenClose 333.41 KB 401.93 KB 354.30 KB
RandomizedHoldAndQuery 933.05 KB 1452.56 KB 939.68 KB
MixedSyncAsyncContention 623.78 KB 1343.93 KB 630.77 KB
MultiCommandReuse 720.47 KB 1675.35 KB 736.75 KB
PoolExhaustionRecovery 2770.66 KB 3976.10 KB 2833.72 KB
BurstyTrafficPattern 1045.71 KB 2383.86 KB 1035.41 KB

Across all 36 stress cases the fixed build lands within a few percent of 6.1.6, in several cases slightly under it.

Command execution is the bigger real-world win, since it affects any application running queries and not only pooled workloads: repeated ExecuteReader goes from 3199.4 B/op to 1027.5 B/op.

Measurement caveat: SQL Server ran in an emulated arm64 container (azure-sql-edge, since the mssql/server:2022 amd64 image does not run under emulation on Apple silicon). Treat the timing columns as unreliable. The allocation figures are host independent and are what the conclusions rest on.

Not covered locally: net462 and Windows-specific paths, which will be exercised by CI.

Guidelines

Please review the contribution guidelines before submitting a pull request:

mdaigle and others added 4 commits August 7, 2026 17:36
Interpolated strings passed to SqlClientEventSource.Try*Event are built
unconditionally at the call site, so the overload's internal
IsTraceEnabled/IsAdvancedTraceOn check no longer avoids the cost. This
allocates on every call even with tracing off.

SqlConnectionInternal.Deactivate runs on every pooled connection return and
accounted for ~200 bytes per open/close cycle, which is most of the +264
bytes/op regression the connection pool benchmarks show against 6.1.6.

Wrap the affected call sites in the matching enablement check so the string
is only built when the event will be written. The guards mirror the checks
already inside each overload, so behavior is unchanged.

Measured on a pooled open/close loop against the in-proc TDS server:
sync 688.5 -> 488.5 bytes/op, async 880.5 -> 680.5 bytes/op.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Three call sites were mis-converted when they moved to interpolated strings:

- OnFeatureExtAck logged "Object ID {0}", which in an interpolated string is
  the expression 0, so it always reported an object ID of 0 instead of the
  actual one.
- Two sites kept a trailing format argument after interpolation. These bind to
  the generic overload, which runs string.Format over already-formatted text.
  The argument is redundant, and a value containing a brace (a routed server
  name, for instance) would raise a FormatException from inside tracing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The earlier sweep only matched calls whose first argument began with an
interpolated string, so it missed the "literal " + $"..." form. That form
allocates the same way.

The remaining 41 sites include the correlation traces in ExecuteReader,
ExecuteNonQuery, ExecuteScalar and ExecuteXmlReader, which interpolate
CommandText and ClientConnectionId on every execution. In 6.1.6 these passed
a constant format string with arguments, so nothing was built unless the
event was enabled.

Measured on a repeated ExecuteReader loop: 3199.4 -> 1027.5 bytes/op.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Try*Event overloads check whether the event is enabled before doing
any formatting work. Passing an interpolated string defeats that, because
the string is built at the call site before the call is made, so every
traced operation allocated even with tracing switched off.

Converts the 119 interpolated call sites introduced since 6.1.6 back to
a composite format string plus arguments, which is what the overloads are
designed for. Trace output is unchanged.

Also corrects two artifacts of the original conversion:
- OnFeatureExtAck logged "Object ID {0}", which inside an interpolated
  string is the expression 0, so it always reported 0.
- Two calls kept a trailing argument that duplicated an interpolated
  expression; the argument is now the format argument.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 10, 2026 20:47
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces allocation overhead when tracing is disabled by replacing eagerly-built interpolated strings at SqlClientEventSource.Log.Try*Event(...) call sites with composite format strings plus arguments, restoring the pre-unification logging pattern and improving connection pool benchmark memory behavior.

Changes:

  • Rewrites TryTraceEvent, TryAdvancedTraceEvent, and TryCorrelationTraceEvent call sites to use parameterized (format + args) logging instead of interpolated strings.
  • Fixes a few latent tracing defects encountered during the mechanical rewrite (incorrect placeholder usage and redundant trailing args that could lead to FormatException).
  • Keeps SqlClientEventSource unchanged and confines changes to driver call sites.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs Converts hot-path connection/pool tracing to parameterized calls to avoid allocations when tracing is off.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs Rewrites reader execution trace/correlation calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.NonQuery.cs Rewrites non-query execution trace/correlation calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Scalar.cs Rewrites scalar execution trace/correlation calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Xml.cs Rewrites XML reader execution trace/correlation calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs Rewrites various command lifecycle/property-setting trace calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs Rewrites auth provider discovery/loading trace calls to parameterized form.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Utilities/AsyncHelper.cs Rewrites unobserved continuation exception trace to parameterized form.
Suppressed comments (2)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:1854

  • This advanced-trace call uses {1:T} / {2:T}, but TryAdvancedTraceEvent stringifies arguments before string.Format (SqlClientEventSource.cs:1186+), so the :T specifiers will not be applied and trace output changes compared to the previous interpolated-string version. Guard on IsAdvancedTraceOn() and pre-format the time values, and drop the :T specifiers in the composite format string.
                            "The expiration time is {1:T}. " +
                            "Current Time is {2:T}.",
                            ObjectID,
                            dbConnectionPoolAuthenticationContext.ExpirationTime,
                            DateTime.UtcNow);

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs:4063

  • This trace message uses {1:T} / {2:T}, but TryTraceEvent converts arguments to strings before formatting (SqlClientEventSource.cs:487+). As a result the :T specifier won’t be applied and trace output differs from the previous interpolated string behavior. If you need the log to remain time-only, guard the call and pass pre-formatted time strings, and remove the :T specifiers.
                        "The expiration time is {1:T}. " +
                        "Current Time is {2:T}.",
                        ObjectID,
                        dbConnectionPoolAuthenticationContext.ExpirationTime,
                        DateTime.UtcNow);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mdaigle
mdaigle marked this pull request as ready for review August 10, 2026 21:18
@mdaigle
mdaigle requested a review from a team as a code owner August 10, 2026 21:18
@mdaigle mdaigle added this to the 7.1.0-preview3 milestone Aug 10, 2026
@mdaigle mdaigle assigned benrr101 and unassigned paulmedynski Aug 10, 2026
saurabh500
saurabh500 previously approved these changes Aug 10, 2026

@saurabh500 saurabh500 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bot comment needs to be addressed. Otherwise good find and thanks for addressing this.

The generic Try*Event overloads call ToString() on each argument before
string.Format, so a {1:T} specifier in the composite format string is
never applied. Three fed auth trace sites relied on :T and would have
logged full date and time instead of time only.

Guard these three sites on the matching enablement check and pass
pre-formatted time strings, which keeps the output identical to the
interpolated version and still allocates nothing when tracing is off.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 10, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs:74

  • This trace call still eagerly allocates the public-key-token string (BitConverter.ToString(...).Replace(...)) even when tracing is disabled, because the argument expression is evaluated before TryTraceEvent checks IsTraceEnabled(). To keep startup cost minimal (and consistent with the rest of this PR’s approach), guard the call and compute the token string only when tracing is on.
                    nameof(SqlAuthenticationProviderManager) +
                    ": Attempting to load Azure extension assembly={0} with " +
                    "expected public key token={1}",
                    azureAssemblyName,
                    BitConverter.ToString(s_azurePublicKeyToken).Replace("-", ""));

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.95448% with 198 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.75%. Comparing base (deabcc2) to head (bbac7ab).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
...Data/SqlClient/Connection/SqlConnectionInternal.cs 36.89% 183 Missing ⚠️
...Data/SqlClient/SqlAuthenticationProviderManager.cs 34.78% 15 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4528      +/-   ##
==========================================
- Coverage   64.71%   62.75%   -1.96%     
==========================================
  Files         288      283       -5     
  Lines       44088    67315   +23227     
==========================================
+ Hits        28532    42246   +13714     
- Misses      15556    25069    +9513     
Flag Coverage Δ
CI-SqlClient ?
PR-SqlClient-Project 62.75% <69.95%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mdaigle

mdaigle commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@cheenamalhotra cheenamalhotra added the Performance 📈 Issues that are targeted to performance improvements. label Aug 11, 2026
@edwardneal

Copy link
Copy Markdown
Contributor

The parameterized form still evaluates argument expressions eagerly, which a if (Log.Is*On()) guard would avoid. The arguments on the hot paths are cheap: ObjectID is an int passed through a generic overload so it does not box, ClientConnectionId is a Guid?, CommandText is a plain getter, and ActivityCorrelator.Current is thread-local cached. The only allocating arguments are on startup paths in SqlAuthenticationProviderManager.

A guard-based variant of the same fix measures byte-for-byte identical and is available on dev/automation/guard-eager-trace-strings if reviewers prefer that shape. The parameterized form was chosen because it removes the nesting, matches the convention used everywhere else in the driver, and keeps the call sites diffing cleanly against 6.1.6.

For future use: we could also use interpolated string handlers and prevent this from recurring. These handlers should compile and run correctly on both .NET and .NET Framework with the appropriate polyfills. SharpLab shows what a simple example could compile to here.

While the current parameters are cheap, this isn't guaranteed - and only these handlers will avoid evaluating them if the log is disabled.

@mdaigle
mdaigle merged commit 1cbb68d into main Aug 11, 2026
360 of 362 checks passed
@mdaigle
mdaigle deleted the dev/automation/parameterized-trace-calls branch August 11, 2026 16:46
@github-project-automation github-project-automation Bot moved this from To triage to Done in SqlClient Board Aug 11, 2026
@ErikEJ

ErikEJ commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@mdaigle did this affect 7.0 ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance 📈 Issues that are targeted to performance improvements.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

9 participants