perf: refactor SqlBulkCopy async loops to async/await and eliminate per-row task allocations - #4685
Open
PauloHMattos wants to merge 2 commits into
Open
PauloHMattos wants to merge 2 commits into
PauloHMattos wants to merge 2 commits into
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
Replaces the hand-rolled TaskCompletionSource + AsyncHelper.ContinueTaskWithState state machine in the innermost column-copy loop with async/await. CopyColumnsAsync now runs a straight-line loop over the row's columns and returns null while every WriteBulkCopyValue completes synchronously (the existing Task-or-null contract its caller relies on). The first time a write pends it hands the remainder of the row to a new async helper, CopyColumnsAsyncContinued, which awaits only the writes that actually pend. This removes the per-column TaskCompletionSource, the CopyColumnsAsyncSetupContinuation closure, and the recursive re-entry. The unused 'source' parameter on CopyColumnsAsync is dropped: the only caller (CopyRowsAsync) always called CopyColumnsAsync(0) with no source, so the source-completion branch was dead code. Behavior is unchanged: the synchronous fast path never awaits (no sync-over-async), and exceptions propagate to the caller (sync) or via the returned Task (async) exactly as before.
This is the change that resolves the bulk of issue dotnet#716. The async regression was concentrated in the SqlDataReader (true-async-read) source path, which scheduled roughly two ThreadPool work items per row. Two hotspots are rewritten: 1. ReadFromRowSourceAsync: the async branch previously did ReadAsync(cts).ContinueWith(...).Unwrap(), allocating a continuation and a wrapper Task for every row. It now checks for synchronous completion (return null, the Task-or-null fast path) and otherwise awaits the read via a small async helper, updating _hasMoreRowToCopy inline. 2. CopyRowsAsync: the hand-rolled TaskCompletionSource + AsyncHelper.ContinueTaskWithState re-entry (one continuation per pending row write and per pending read) is replaced by a synchronous fast-path loop that delegates to an async batch loop (CopyRowsLoopAsync) on the first pend. Writes and reads are awaited only when they actually pend, so the synchronous path never awaits (no sync-over-async) and stays allocation-free. CheckForCancellation is removed; cancellation is now a cts.ThrowIfCancellationRequested() inside the async loop and a Task.FromCanceled() on the synchronous entry, preserving the prior behavior of only observing cancellation in async mode. The unused 'source' parameter on CopyRowsAsync is dropped (its only caller passes none). Per-row semantics are preserved exactly: WriteByte(SQLROW), CheckAndRaiseNotification after each row, and the _parserLock release/reacquire around synchronous reads.
PauloHMattos
force-pushed
the
perf/sqlbulkcopy-writetoserverasync
branch
from
September 13, 2026 22:07
770f154 to
f8c0031
Compare
Author
|
@dotnet-policy-service agree company="Inoa" |
Author
|
@dotnet-policy-service agree |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR fixes a long-standing performance and memory allocation bottleneck in
SqlBulkCopywhen streaming rows asynchronously from aDbDataReaderorSqlDataReadersource.While troubleshooting heavy GC pressure during large async bulk copies, we traced the issue back to how
SqlBulkCopyhandled per-row and per-column continuations. For every single row read,ReadFromRowSourceAsyncwas wrappingReadAsyncin aContinueWith(...).Unwrap()delegate chain—even when the underlying socket buffer already had the data ready. On top of that,CopyColumnsAsyncandCopyRowsAsyncrelied on a hand-rolled state machine built aroundTaskCompletionSource<object>andAsyncHelper.ContinueTaskWithState. For a 100,000-row ingestion, this churned through over 200,000 ThreadPool work items and generated tens of megabytes of short-lived heap allocations.What Changed
ReadFromRowSourceAsyncto checkreadTask.Status == TaskStatus.RanToCompletioninline. If data is already buffered, it updates_hasMoreRowToCopydirectly and returnsnullwithout spinning up a task continuation wrapper.async/await: Replaced the legacyTaskCompletionSourcesetup with direct C#async/awaitcontinuation helpers (CopyColumnsAsyncContinuedandCopyRowsLoopAsync).task != null). If all writes for a row complete synchronously, no async state machine is ever allocated.Benchmark Summary (100,000 Rows)
Note on Outliers: The time regressions in Numeric (Batch 10000) and Wide (Batch 0) reflect environmental noise (CPU/GC contention during execution on a local laptop with parallel processes running), as memory allocations consistently dropped by 70–80% across all async scenarios.
Full benchmark code is available here: https://github.com/PauloHMattos/SqlClientBulkCopyBenchmark
Issues
Fixes #716