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
cheenamalhotra
left a comment
There was a problem hiding this comment.
Verdict: Approve with minor comments. The change is correct, well-scoped (one file, one private nested type), and the safety argument holds up under verification.
What I verified independently
-
The historical claim is accurate.
git show 2acf6e005(#3534) confirms it removed a_sparePacketslot whosePacketData.Clear()resetBuffer,Read,NextPacket,PrevPacketand the debug fields but notRunningDataSize— exactly the defect behind #3519. BothInitializeandResethere set it to0, so the actual bug is fixed rather than made unreachable. -
Builds clean in Debug (
net9.0), which matters here sinceResetDebugStateImplonly exists under#if DEBUG. -
Free-list invariants hold. I ran a reflection harness against the Release assembly driving
AppendPacketData/ClearPacketsover 5 rounds (20 packets, then 5x5) and asserted:- no duplicate nodes on the free list, and no node simultaneously on the free list and the live chain (the aliasing failure that would cause silent data corruption);
RunningDataSize,Buffer,Read,PrevPacketall cleared on parked nodes;_sparePacketCountmatches the actual list length, and the cap of 16 is respected across repeated cycles;- the rebuilt chain has intact
Prev/Nextlinks and a correct_lastPacket.
All checks passed.
-
No live reference can be recycled. Every
PacketDatareference (_firstPacket,_lastPacket,_current,_continuePacket) is nulled insideClearPacketsbefore any node is parked, andMoveNext/MoveToContinuecopyBufferout viaSetBufferrather than retaining nodes.RentPacketalso correctly readsNextPacketbeforeInitializenulls it.
The causal story is worth stating explicitly
Pre-#3534 reuse only ever recycled one node (_firstPacket), since ClearPackets parked just the head. So the +120% regression comes from the sheer number of snapshots — one per async continuation, mostly single-packet — not from packets-per-snapshot. That is consistent with your measurements and worth a sentence in the description; it pre-empts the obvious "how can one node account for 12 MB?" question during review.
Corollary: the three headline benchmarks would be fixed by a 1-entry list; the 16-entry list is what buys the DataTypeReaderAsync improvement. Please justify 16 in the comment (or note that it is empirical), since it currently reads as a bare magic number.
Minor comments
CaptureAsStartsilently gains_continuePacket = null. Replacing the three assignments withClearPackets()also clears_continuePacket, which the old code left alone. It is a no-op today (every caller path runsClear()first) and defensively correct, but it is an unadvertised semantic change in the diff — worth calling out.- Asymmetric API.
RentPackethas no matchingReturnPacket; the park logic is inlined inClearPackets. ExtractingReturnPacket(PacketData)would put the bound and theReset()ordering in one auditable place. - Constructor duplication.
PacketData(byte[], int)should delegate toInitialize(buffer, read)so the two initialization paths cannot drift. That drift is precisely how theRunningDataSizeomission survived last time. - No automated test. Repo guidance asks for one. I could not run
CanReadAwkwardDataLengthslocally (no SQL Server configured), and the realStateSnapshotis not reachable fromFunctionalTests—TdsParserStateObject.TestHarness.csstubs it out with its own list-based type. Consider either widening the harness to exercise the real snapshot, or adding a manual test that drives more than 16 packets across repeated async reads with continue enabled, which is the one shape existing coverage does not hit. - Retention is roughly 16 x ~56 B = ~900 B per
TdsParserStateObject, held via_cachedSnapshotfor the connection's lifetime. Negligible, and the buffers themselves are nulled — no change needed, just confirming it was considered.
- PacketData(byte[], int) now delegates to Initialize so a new node and a recycled one share a single initialization path. Keeping them separate is how the RunningDataSize reset was missed before. - Extract ReturnPacket(PacketData) to mirror RentPacket, and document that Reset must run before the node is linked because NextPacket doubles as the free list link. - Document MaxSparePacketCount: the value is empirical, bounded at roughly 900 bytes retained per state object. - Note in CaptureAsStart that routing through ClearPackets also clears _continuePacket, which the assignments it replaced did not. - Add StateSnapshotPacketRecyclingTests covering the free list: the dotnet#3519 RunningDataSize guard, full state release on parked nodes, the bound and its tracked count, that no node is shared between the live chain and the free list, and that the rebuilt chain keeps its links intact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 106641f4-0370-4a71-a4ca-41f366556fae
|
Thanks for the review @cheenamalhotra. All six points are addressed in 50f1408.
Full |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/StateSnapshotPacketRecyclingTests.cs:27
- The comment says
MaxSparePacketCountmust exceedStateSnapshot.MaxSparePacketCount, but the value is intentionally set to match it (16). As written, this is misleading; it’sPacketsPerSnapshot(20) that exceeds the bound to exercise the free-list cap.
// Must exceed StateSnapshot.MaxSparePacketCount so the bound itself is exercised.
private const int MaxSparePacketCount = 16;
private const int PacketsPerSnapshot = 20;
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.Worth calling out where the volume comes from, since a single node is only ~40 bytes. A snapshot is taken and released on every async continuation, not once per query —
SqlDataReader.PrepareAsyncInvocationcallsSetSnapshot()each time an async read has to yield. The regression is therefore driven by the number of snapshots, which is large, rather than by packets per snapshot, which is usually one. That is also why the old single-slot_sparePacketwas enough to keep allocation flat despite only ever holding one node: it was reused across the whole stream of continuations.ExecuteReaderAsyncallocating ~12 MB extra is roughly 300k continuations over the benchmark, not 300k nodes alive at once.This restores node reuse:
Buffer/Readare mutable again (viaInitialize/Reset) andStateSnapshotkeeps a bounded 16-entry free list. The bound is a small generalisation of the old single slot; 16 is what stopped the multi-packetDataTypeReaderAsyncbenchmarks from regressing, while the three headline cases would have been fixed by a single entry.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
New unit tests,
StateSnapshotPacketRecyclingTests(5 tests, pass on net462/net8.0/net9.0 in Debug and Release). They drive 20 packets per snapshot across repeated snapshot/clear cycles, which is above the 16-entry bound, and assert:RunningDataSize == 0, and the offsets/lengths derived from it are correct — the direct guard for 6.1.0: Errors while executing the query #3519;NextPacket/PrevPacketlinks in both directions.Confirmed meaningful by reverting the
RunningDataSize = 0lines inInitialize/Reset: two of the five fail, including the 6.1.0: Errors while executing the query #3519 guard.Internal
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.Full
DataReaderTestclass: 17 passed, 0 failed.Functional test suite: 1347 passed. The 5 failures are
AlwaysEncryptedcertificate-store tests that require administrator rights and are unrelated to this change.Local perf suite run three times (6.1.6,
main, this branch) at the defaultUseOptimizedAsyncBehaviour: false, agreeing with the pipeline.Guidelines
Please review the contribution guidelines before submitting a pull request: