Skip to content

Performance | Remove manual state machine from setup phase of SqlBulkCopy - #4687

Open
edwardneal wants to merge 7 commits into
dotnet:mainfrom
edwardneal:async/sqlbulkcopy-native-async-pr1
Open

edwardneal wants to merge 7 commits into
dotnet:mainfrom
edwardneal:async/sqlbulkcopy-native-async-pr1

Conversation

@edwardneal

Copy link
Copy Markdown
Contributor

Description

This is the same idea as #4685; strangely enough, it stops at the same boundary. The primary difference is that where #4685 handles the transport of rows, this PR handles the setup phase - metadata retrieval, connection resiliency and so on.

This PR removes the manual state machine orchestration from the outer layers of SqlBulkCopy, replacing it with native async/await. This simplifies those layers, although it leaves behind some modest scar tissue: slightly odd method names.

I've moved commit-by-commit, with a single cleanup layer at the end.

Issues

Contributes to #3459.

Testing

Existing SqlBulkCopy tests continue to pass. Performance benchmarking highlights no inexplicable performance regressions (although there's a little CPU noise - I'm testing against a local SQL Server), plus a few very minor reductions in memory usage.

Benchmark results

All benchmarks are run against .NET 10. A few points of note:

  • When comparing the PR to main, average duration is within the margin of error in both directions.
  • One very large deviation in BulkCopyDeep with UseCompatibilityAsync set to false. This is because I was benchmarking against a local SQL Server - the first test was slow, no matter which test it was.
  • UseCompatibilityAsync covers cases where the UseCompatibilityAsyncBehaviour and the UseCompatibilityProcessSni AppContext switches are set to true or false. I don't expect it to have a bearing here, since we're not making sufficiently heavy use of these code paths during startup.
  • Async bulk copy operations queue two fewer thread pool work items per operation.
Method Job UseCompatibilityAsync Mean Error StdDev Median Ratio RatioSD Gen0 Completed Work Items Lock Contentions Allocated Alloc Ratio
BulkCopyDeep PR False 217.546 ms 4.2840 ms 7.9407 ms 215.790 ms 1.19 0.05 - - - 4408.41 KB 1.00
BulkCopyDeep main False 182.223 ms 3.1808 ms 2.8197 ms 182.403 ms 1.00 0.02 - - - 4408.73 KB 1.00
BulkCopyDeepAsync PR False 185.152 ms 3.5514 ms 4.4913 ms 184.937 ms 1.00 0.03 - - - 4408.6 KB 1.00
BulkCopyDeepAsync main False 184.475 ms 3.4417 ms 3.2194 ms 184.992 ms 1.00 0.02 - 2.0000 - 4409.73 KB 1.00
BulkCopyWide PR False 495.525 ms 7.2674 ms 6.4424 ms 498.063 ms 1.01 0.02 2000.0000 - - 20549.99 KB 1.00
BulkCopyWide main False 491.257 ms 5.6813 ms 5.3143 ms 490.108 ms 1.00 0.01 2000.0000 - - 20550.23 KB 1.00
BulkCopyWideAsync PR False 507.932 ms 9.8453 ms 10.5343 ms 509.432 ms 1.03 0.02 2000.0000 - - 20550.18 KB 1.00
BulkCopyWideAsync main False 492.362 ms 7.1601 ms 5.9790 ms 493.301 ms 1.00 0.02 2000.0000 2.0000 - 20551.26 KB 1.00
BulkCopySmall PR False 3.822 ms 0.2215 ms 0.6425 ms 3.766 ms 1.11 0.31 - - - 39.16 KB 0.99
BulkCopySmall main False 3.638 ms 0.3141 ms 0.9061 ms 3.359 ms 1.06 0.35 - - - 39.4 KB 1.00
BulkCopySmallAsync PR False 3.910 ms 0.2610 ms 0.7530 ms 3.841 ms 1.05 0.29 - - - 39.35 KB 0.97
BulkCopySmallAsync main False 3.857 ms 0.2758 ms 0.7957 ms 3.679 ms 1.04 0.30 - 2.0000 - 40.4 KB 1.00
BulkCopyDeep PR True 181.589 ms 3.5085 ms 3.7541 ms 182.773 ms 0.99 0.04 - - - 482.69 KB 1.00
BulkCopyDeep main True 183.789 ms 3.5867 ms 5.7918 ms 183.339 ms 1.00 0.04 - - - 482.84 KB 1.00
BulkCopyDeepAsync PR True 183.821 ms 3.6550 ms 4.8793 ms 183.677 ms 0.99 0.03 - - - 482.8 KB 1.00
BulkCopyDeepAsync main True 185.159 ms 3.6905 ms 4.3933 ms 185.945 ms 1.00 0.03 - 2.0000 - 483.92 KB 1.00
BulkCopyWide PR True 427.176 ms 7.8012 ms 12.5975 ms 423.454 ms 1.03 0.03 2000.0000 - - 16624.14 KB 1.00
BulkCopyWide main True 415.991 ms 7.2896 ms 6.8187 ms 417.564 ms 1.00 0.02 2000.0000 - - 16624.45 KB 1.00
BulkCopyWideAsync PR True 416.644 ms 8.2596 ms 6.8972 ms 415.651 ms 0.99 0.02 2000.0000 - - 16624.38 KB 1.00
BulkCopyWideAsync main True 421.799 ms 6.4374 ms 8.3704 ms 420.364 ms 1.00 0.03 2000.0000 2.0000 - 16625.38 KB 1.00
BulkCopySmall PR True 3.773 ms 0.2924 ms 0.8576 ms 3.738 ms 1.08 0.33 - - - 15.66 KB 0.99
BulkCopySmall main True 3.659 ms 0.2876 ms 0.8391 ms 3.436 ms 1.05 0.33 - - - 15.89 KB 1.00
BulkCopySmallAsync PR True 3.681 ms 0.2549 ms 0.7477 ms 3.462 ms 1.00 0.29 - - - 15.84 KB 0.94
BulkCopySmallAsync main True 3.858 ms 0.2842 ms 0.8291 ms 3.826 ms 1.04 0.31 - 2.0000 - 16.89 KB 1.00

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

// @TODO: CER Exception Handling was removed here (see GH#3581)
finally
{
_columnMappings.ReadOnly = false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behavioural change 1 of 3: previously, ColumnMappings was marked as read-write immediately after the async task was started. It's now read-only for the entire duration. I think this was originally a bug.

Comment on lines -2553 to -2563
private Task<T> RegisterForConnectionCloseNotification<T>(Task<T> outerTask)
{
SqlConnection connection = _connection;
if (connection == null)
{
// No connection
throw ADP.ClosedConnectionError();
}

return connection.RegisterForConnectionCloseNotification(outerTask, this, SqlReferenceCollection.BulkCopyTag);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Flagging this explicitly: the only thing this does is add the instance to the connection's SqlReferenceCollection while the bulk copy is ongoing. It's replaced with a direct call to the appropriate method, since this style of method doesn't align with the more modern async/await style.

try
{
CleanUpStateObject();
CleanUpStateObject(isCancelRequested: !completedSuccessfully);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behavioural change 2 of 3: this is now called whenever the bulk copy throws an exception, and it sends a cancellation signal to the server. Previously, a fault in an async bulk copy didn't actually do this.

return;
try
{
await reconnectTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behavioural change 3 of 3: the original async connection resiliency logic would continue the reconnection task with the current method - so if a server successfully and then immediately disconnected, it'd loop through this reconnection logic repeatedly. Each reconnection attempt would start a new timer against BulkCopyTimeout.

I've assumed this is a bug, since it'd allow repeated disconnections/reconnections to the server to exceed the user-specified BulkCopyTimeout. We now only attempt to reconnect once - I can add a loop if that's an issue.

else
{
var internalResult = new BulkCopySimpleResultSet();
RunParserReliably(internalResult);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Small quirk: RunParserReliably and RunParser are identical. I've switched them over to use RunParser, and RunParserReliably can vanish in a follow-up.

@edwardneal
edwardneal marked this pull request as ready for review September 14, 2026 19:55
@edwardneal
edwardneal requested a review from a team as a code owner September 14, 2026 19:55
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

_rowSourceType = ValueSourceType.IDataReader;

WriteRowSourceToServerAsync(reader.FieldCount, CancellationToken.None); //It returns null since _isAsyncBulkCopy = false;
WriteRowSourceToServerAsync(reader.FieldCount, CancellationToken.None).GetAwaiter().GetResult();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I would really advise against calling GetAwaiter().GetResult() on a ValueTask unless you do know that it's going to complete synchronously. Otherwise, GetAwaiter().GetResult() on ValueTask doesn't guarantee that it's going to wait for it to actually complete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree with your general point about ValueTask - and we definitely want to avoid that. In this particular case though, the sync APIs unconditionally call ResetWriteToServerGlobalVariables‎ before calling WriteRowSourceToServerAsync. Resetting the variables sets _isAsyncBulkCopy to false, and that variable is threaded through the existing code to ensure that the existing logic uses synchronous paths.

I'd personally prefer to pass something similar to an isAsync parameter through the call chain to make this clearer, but the variable is necessary to support the rest of the copy logic. If SqlBulkCopy is fully migrated, we can hopefully do this.

I could add an extension method which asserts that ValueTask has completed, then calls GetAwaiter().GetResult(), but I'm not sure that'd add much beyond documenting a fairly standard pattern of sharing sync and async implementations (albeit with unusual method names). Would that be helpful, or do you have another suggestion?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resetting the variables sets _isAsyncBulkCopy to false, and that variable is threaded through the existing code to ensure that the existing logic uses synchronous paths.

That's certainly a design choice of all times. But then again, there is/was a path with IO from a class ctor, so at this point I'm not really surprised.
I wonder if just making WriteRowSourceToServerAsync to return Task again wouldn't be a much simpler solution. You'll be able to remove AsTask you added, there's no issue with GetAwaiter().GetResult() and it's not like ValueTask brings much in this specific case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The design is slightly different to the normal pattern we'd use, I agree - and I'm not strongly attached to the use of Task vs. ValueTask, I've got no problem if that's what the team want me to use.

This design is somewhat similar to SslStream.Read though. You'll note that the top-level synchronous Read method calls ReadAsyncInternal, which returns a ValueTask<int>. It then asserts that the ValueTask is completed and calls .GetAwaiter().GetResult(). This doesn't cause an issue because ReadAsyncInternal is genuinely taking a synchronous path - just as WriteRowSourceToServerAsync is. In both situations, we're not using GetResult() to try to force the ValueTask to complete, we're using it to synchronously observe the ValueTask's result and force it to throw any exceptions.

The primary difference between SslStream.Read and these methods is actually just that debug assertion on vt.IsCompleted. I'm open to adding that alongside a brief comment explaining that that the ValueTask returned is always expected to be completed when called with _isAsyncBulkCopy = false, if you think that would make the behaviour clearer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This design is somewhat similar to SslStream.Read though. You'll note that the top-level synchronous Read method calls ReadAsyncInternal, which returns a ValueTask. It then asserts that the ValueTask is completed and calls .GetAwaiter().GetResult(). This doesn't cause an issue because ReadAsyncInternal is genuinely taking a synchronous path - just as WriteRowSourceToServerAsync is. In both situations, we're not using GetResult() to try to force the ValueTask to complete, we're using it to synchronously observe the ValueTask's result and force it to throw any exceptions.

SslStream passes SyncReadWriteAdapter so it is a somewhat explicit contract that it is supposed to be completely sync. Not amazing in my books, but still miles better compared to how SqlClient sets a field and calls it a day. When async is passed as a variable, at least the compiler is going to complain if you forget to pass it to a method. When async is a field? Good luck not forgetting to specify it for every single public method.
That's also ignoring perf implications, where SqlBulkcCopy object is a bit heavier because it also has to account for another field.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The design is slightly different to the normal pattern we'd use, I agree - and I'm not strongly attached to the use of Task vs. ValueTask, I've got no problem if that's what the team want me to use.

I just don't see a reason for ValueTask, especially since you already have to convert it to Task anyway in multiple methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think your point around using a variable makes perfect sense. It's still needed today for the hand-written state machine, but it'd definitely be a good idea to pass that as a parameter where possible. I'll make sure that happens if it's possible in a follow-up (once everything is done via async/await.) Thanks.

The original reason I used ValueTask was to reflect that the method could has both synchronous and asynchronous completion paths (because this method can be called by both WriteToServer and WriteToServerAsync). When calling the method from WriteToServer we'll need to observe it via GetAwaiter().GetResult() no matter what - so I didn't see Task as providing anything useful here for the (admittedly minor) allocation overhead.

If the return type causes confusion within the team, I'm happy to either add a comment, assert ValueTask.IsCompleted, or return a Task instance

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

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

5 participants