Perf | Reduce async read memory allocations by recycling StateSnapshot packet nodes - #4536
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a memory regression in the packet multiplexer by avoiding per-read 8 KB buffer reallocation unless the active read buffer is actually retained by a StateSnapshot, restoring the allocation profile of the legacy compat path while keeping snapshot replay correctness.
Changes:
- Introduces
_inBuffRetainedtracking inTdsParserStateObjectand sets it at concrete snapshot retention points. - Updates
ProcessSniPacketto reallocate the read buffer only when_inBuffRetainedis true (and_inBytesRead != 0), preventing unnecessary allocations. - Adds functional tests (plus test harness plumbing) to validate buffer reuse vs. snapshot-retained replacement behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserStateObject.cs | Adds _inBuffRetained state and sets it in snapshot capture/replay paths to reflect real buffer ownership. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserStateObject.Multiplexer.cs | Uses _inBuffRetained to gate buffer reallocation in ProcessSniPacket; marks retention when snapshot appends reference data. |
| src/Microsoft.Data.SqlClient/tests/FunctionalTests/TdsParserStateObject.TestHarness.cs | Mirrors new members in the functional test harness stub so multiplexer code can compile/run in the test project. |
| src/Microsoft.Data.SqlClient/tests/FunctionalTests/MultiplexerTests.cs | Adds tests verifying buffer reuse when unretained and replacement when snapshot retains the buffer. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <summary> | ||
| /// True when <see cref="_inBuff"/> is still referenced by something other than this state | ||
| /// object - currently only the state snapshot. While this is set the buffer must not be | ||
| /// reused as the target of the next network read because doing so would overwrite data | ||
| /// that the other owner still needs. When it is clear, the buffer is exclusively owned | ||
| /// here and can be read into again without allocating a replacement. | ||
| /// </summary> | ||
| private bool _inBuffRetained; |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4536 +/- ##
==========================================
- Coverage 64.78% 62.97% -1.81%
==========================================
Files 288 283 -5
Lines 44418 67450 +23032
==========================================
+ Hits 28774 42477 +13703
- Misses 15644 24973 +9329
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d8d75f2 to
4aec745
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserStateObject.cs:4880
- Recycled PacketData nodes have Buffer reset to null, but the DEBUG-only header helpers a few lines above (SPID/IsEOM/DataLength/GetHeaderSpan) unconditionally slice Buffer. This can throw during debugger inspection of the free-list nodes (especially now that ResetDebugStateImpl makes it more likely these nodes show up as 'empty'). Guard those helpers so free-list nodes are safe to inspect.
partial void SetDebugPacketIdImpl(int value) => DebugPacketId = value;
partial void ResetDebugStateImpl()
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserStateObject.cs:1
- The file now starts with a UTF-8 BOM (\uFEFF). Other C# files in this repo appear not to use BOMs (e.g., TdsParser.cs, SqlConnection.cs), so this introduces unnecessary encoding churn in diffs/blame.
// Licensed to the .NET Foundation under one or more agreements.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParserStateObject.cs:4703
- PacketData.Reset() can set Buffer to null when a node is returned to the free list, but PacketID unconditionally dereferences Buffer. This can cause NullReferenceException during debugger evaluation (DebuggerDisplay/ToString) or if a freed node is accidentally inspected/logged. Making PacketID null/length-safe keeps recycled nodes inert.
This issue also appears on line 4878 of the same file.
public int PacketID => Packet.GetIDFromHeader(Buffer.AsSpan(0, TdsEnums.HEADER_LEN));
…ssion StateSnapshot allocates a PacketData node for every packet appended during an async read. A prior refactor made PacketData immutable, so ResetSnapshot and ClearPackets dropped every node and each subsequent async read re-allocated the whole chain. This showed up as a large allocation regression against 6.1.6. Restore node reuse by making the buffer/read fields mutable again and keeping a bounded (16 entry) free list on StateSnapshot. Nodes never own the packet buffers, so Reset only drops this node's references to them. Measured with the perf suite at UseOptimizedAsyncBehaviour: false, against the 6.1.6 baseline: SqlCommand/ExecuteReaderAsync +121.7% -> -0.1% MarsOverhead/ExecuteReaderAsyncWithMars +120.6% -> -0.1% DataTypeReaderAsync/NVarCharAsync +20.9% -> +2.3% DataTypeReaderAsync/VarCharAsync +19.2% -> +0.5% DataTypeReaderAsync/XmlAsync +16.5% -> +1.4% SqlCommand/ExecuteScalarAsync +11.5% -> +1.2% Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 106641f4-0370-4a71-a4ca-41f366556fae
4aec745 to
7a9b083
Compare
Description
This is a memory allocation performance improvement. It restores allocation behaviour in the async read path, which regressed against the 6.1.6 baseline.
Every packet appended to a snapshot during an async read needs a
PacketDatalinked-list node. Before #3534 these nodes were reused via a_sparePacketslot. #3534 removed that reuse, soClearPacketsnow discards the whole chain on everyResetSnapshotand each subsequent async read re-allocates it from scratch. The cost scales with packets per read, which is why async reader benchmarks are hit hardest while sync ones barely move.This restores node reuse:
Buffer/Readare mutable again (viaInitialize/Reset) andStateSnapshotkeeps a bounded 16-entry free list.Measurements
Validated on the internal
sqlclient-perfpipeline, build 166806, which runs the full suite of 14 runners / 162 benchmarks at the defaultUseOptimizedAsyncBehaviour: false. Both columns below are the allocation delta against the same 6.1.6 baseline, comparingmainagainst this branch.10 of the 162 benchmarks are excluded because the 6.1.6 baseline itself was not reproducible between the two runs (up to 383% drift on the same baseline build, all of them low-absolute-allocation connection-pool or
ReadLargeDataSynccases). Any delta computed against a moving baseline is meaningless in either direction. The 152 benchmarks below have a baseline stable to within 5%, and the ones that matter here are stable to within 0.2%.mainPer-benchmark, for everything that was at or above +80% on
main:mainSqlCommand/ExecuteReaderAsyncMarsOverhead/ExecuteReaderAsyncWithMars[MARS=False]MarsOverhead/ExecuteReaderAsyncWithMars[MARS=True]These are the only benchmarks in the suite above +80%, and all three are async reads. Below that threshold the
DataTypeReaderAsyncfamily also comes down from +18-23% to +1-2%, andSqlConnection/OpenConnection[MARS=False; Pooling=True]from +11.2% to +1.9%.The six entries still at or above +10% after this change are all connection-pool runners (
ConnectionPoolContention,ConnectionPoolChurn,ConnectionPoolStress) sitting at +10-16%, effectively unchanged by this PR. They are a separate regression on the pooling path and are not addressed here.Ensuring this does not reintroduce the bugs #3534 fixed
#3534 removed packet reuse while fixing several distinct problems. Reuse itself was not the defect. Quoting that PR:
The pre-#3534
Clear()resetBuffer,Read,NextPacket,PrevPacketand the debug fields, but never resetRunningDataSize. A recycled node could therefore be reinstalled as the head of a new chain still carrying a stale running length, andGetPacketDataOffset/GetPacketDataSizewould compute wrong values once a read reached the continue stage at 3 or more packets. Removing reuse made the stale field unreachable, which fixed the symptom.This change addresses the actual defect instead. Both
InitializeandResetexplicitly setRunningDataSize = 0, so a node cannot carry a stale length across uses regardless of which path it takes. That is the one field whose omission caused #3519.The rest of #3534 is untouched. Its plp terminator fix, the char array sizing fix, and the
TryReadColumnInternalfallthrough fix for #3572 all live elsewhere in the file; the diff here is confined to thePacketDataclass andStateSnapshot.Verified by running #3534's own regression test,
CanReadAwkwardDataLengths, which sweeps packet sizes from 512 to 2048 in steps of 3 and is the direct repro for #3519. It passes, as does the fullDataReaderTestclass.The recycling implementation is also stricter than the pre-#3534 one it replaces: the free list is bounded at 16 entries rather than an untyped single slot, and
ResetDebugState()clearsDebugPacketId/Stack/Hashin DEBUG builds so a recycled node cannot carry stale debug state into the duplicate and overlap assertions.Compatibility
No public API changes.
PacketDatais aprivate sealedclass nested insideTdsParserStateObject; every reference to it lives insideStateSnapshot(_firstPacket,_lastPacket,_current,_continuePacket,_sparePackets), all of which are cleared byClearPacketsbefore any node is recycled. No caller outside the snapshot can observe a recycled node. Nodes never own the packet buffers they point at, soReset()only drops this node's references and buffer lifetime is unchanged.Issues
N/A
Testing
sqlclient-perfpipeline run of this branch against the 6.1.6 baseline, full suite: build 166806. Results above.CanReadAwkwardDataLengthspasses. This is the regression test added by Async multi packet fixes #3534 for 6.1.0: Errors while executing the query #3519 and is the most direct check that this change does not reintroduce that bug.DataReaderTestclass: 17 passed, 0 failed.AlwaysEncryptedcertificate-store tests that require administrator rights and are unrelated to this change.main, this branch) at the defaultUseOptimizedAsyncBehaviour: false, agreeing with the pipeline.Guidelines
Please review the contribution guidelines before submitting a pull request: