Repository navigation
Inefficiency when reading large request bodies (e.g. uploads) #32467
Description
Consider an ASP.NET MVC action dealing with file uploads like below.
using var destination = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 81920);
await Request.Body.CopyToAsync(destination, 81920);The code above uses 80KB buffer for the reasons mentioned here https://github.com/dotnet/runtime/blob/release/5.0/src/libraries/System.Private.CoreLib/src/System/IO/Stream.cs#L33.
The code above will use about 3 or 4 times more CPU resources than the same code using .NET Framework System.Web. This is comparing CopyToAsync in .NET Core with CopyTo (synchronous) in .NET Framework. The async version used a lot of CPU in .NET Framework as well, but resorting to synchronous operations solved that with similar or better upload speeds. We can't (by default) use the synchronous version in .NET Core, though, and even when doing so by setting AllowSynchronousIO, it uses about the same amount of CPU resources as the async version.
I'm wondering if this might have to do with the pipe reader in .NET Core reading in 4KB chunks, compared to 16KB chunks in .NET Framework and System.Web.
I profiled the code while uploading a little more than half a GB of data. I can see that calls to FileStream.WriteCore indicate that the buffer size of 80KB is honored. I get around 6 500 writes (512MB / 80KB). The number of request body stream reads is a lot higher, though, and indicate a 4KB buffer limit causing overhead.
When running under IIS, the number of calls to Microsoft.AspNetCore.Server.IIS.NativeMethods.http_read_request_bytes is over 120 000 in my example and that is reflected in the number of thread-pool dispatch related calls.
I can reproduce the same issue with Kestrel, but with slightly different call stacks for reading the request body, of course. I haven't found a way of configuring the default buffer size of the pipe readers used.
Using FileOptions.Asynchronous when opening the file stream doesn't seem to make a lot of difference.
This issue could be related to dotnet/runtime#43480, although they are discussing general performance of pipes and in relation to writing the response.
Activity
davidfowl commentedon May 6, 2021
Having spend a bunch of time with
FileStreamfor writes (in the static files case), I can concur that some of the performance issue comes from the number of calls intoFileStream.WriteAsync(there are some inefficiencies there) caused by the buffer size in Kestrel. Also as you identified, there's thread pool dispatch but I'm not sure how big of a problem that ends up being here (since data get coalesced, we don't dispatch per 4K chunk). The buffer size you pass in toCopyToAsyncis ignored so it doesn't matter when you pass there Kestrel will copy it's internal 4K buffers into the target stream (FileStream in this case).Have you tried writing a custom version of CopyToAsync that copies larger buffers? Basically copying into a bigger buffer (like 16K) and then doing bigger writes to the
FileStream.We should really get a benchmark for this. It's been too long 😄
PS: Async vs Sync here for writes won't matter unless the disk is hitting the queue length (or whatever backpressure mechanism you drive has). The difference shows up immediately on networked drives though so it does matter in lots of scenarios.
avendel commentedon May 7, 2021
Thanks for your reply. Writing my own CopyTo method where I control the buffer size makes no difference, and I think this has to do with the fact that the size of my reads from the body stream doesn't affect the size of reads from the internal structure representing the request body. Profiling the code also indicates that there is a thread pool dispatch for every small read.
Here is a small repro that just reads the request body using an 80KB buffer:
Here is corresponding client code for sending a file:
Below is an excerpt of the profiling results from receiving 1.26 GB. I used 6.0.100-preview.3.21202.5. Note the number of dispatch calls.
Using synchronous reads with
AllowSynchronousIOmakes no difference. Neither does using the overload taking a byte array and a count.davidfowl commentedon May 7, 2021
Show me your implementation
avendel commentedon May 7, 2021
The profiling results in my last comment are from the code I included there.
Here is the
CopyToimplementation I mentioned. The version below is synchronous because I wanted to see if that changed the behavior.davidfowl commentedon May 7, 2021
Synchronous IO from the ASP.NET Core side is MUCH worse, so definitely avoid that and it will make a difference even if you don't see it in your microbenchmark.
How many bytes are being read per call? Can you dump that out in your repro?
I assume this test is all on the local machine so everything is running as fast as possible?
BrennanConroy commentedon May 7, 2021
Triage: We're planning on adding a benchmark to our perf infra for this scenario.
ow-- commentedon May 7, 2021
I'll let @avendel answer about the repro details when he checks back since I haven't been directly involved, but I just want to add for clarity for the triage and benchmarking, that we first saw roughly a doubling of CPU usage on the reading side during performance testing on a proper multiserver setup with uploads and file system writes over the network. We were still fully async and hosting .NET Core 3.1 in-process IIS. After much head scratching small buffer IO with lots of context switching was basically where we was left standing.
So the synchronous stuff is just from further testing after the fact, and it can seemingly be reproduced across different hosting implementations and framework versions. Our expectation is that we'll find that the reads are 4096 bytes since that seems to align well with the IO implementation details of
IISHttpContextand the corresponding Kestrel parts. But we may certainly be overlooking something.davidfowl commentedon May 8, 2021
I assume this is all on windows and it seems like you're measuring IIS not Kestrel?
I just wrote a sample (using Kestrel) and I got 81K reads (localhost), so it should definitely be faster than using CopyToAsync. I think it's important to understand the difference between the cost of the reads VS writes because they differ massively in both cases.
The cost includes CPU time and latency of the overall operation and they are affected by each of these discrete operations. As part of this investigation we'll want to narrow down and zoom in on exactly what we're trying to improve here. My assumption here is overall latency of the operation is what's most important, that is, how fast can we achieve a large file upload.
davidfowl commentedon May 8, 2021
Doing some testing with IIS, I can confirm that the same technique doesn't seem to work as well but it does work with kestrel. I think that confirms the issue above. The slowness likely comes from the number of write operations due to the small size of the read operations. Using a bigger buffer does help a little bit as I get reads that vary between 4K and 64K but the batching isn't as effective as it is in the Kestrel case.
Kestrel's buffer slack on the transport size is 1MB (the amount it writes before pausing), and is configurable globally on the transport side. This setting in IIS is 64K and isn't configurable. Making that setting 1MB in IIS speeds up the overall upload by 50% on my machine.
I can also reproduce the thread pool dispatching and confirm that we're doing 1 per 4K read at the transport layer, but less at the application layer (as those 4K reads end up batched at the application).
I'll post more details later.
4 remaining items
davidfowl commentedon May 11, 2021
Can you share this?
avendel commentedon May 11, 2021
It was basically a variant of the idea from section 3 at https://www.strathweb.com/2012/09/dealing-with-large-files-in-asp-net-web-api/, but with slightly different criteria for choosing when to buffer. But I can assure that it didn't buffer the entire request content.
davidfowl commentedon May 11, 2021
I'm just trying to remember what that code did https://github.com/aspnet/AspNetWebStack/blob/42991b3d2537b702736463f76a10a4fcf2ea44c9/src/System.Web.Http.WebHost/HttpControllerHandler.cs#L296
avendel commentedon May 11, 2021
This is the relevant parts of the subtree of
CopyTofrom a profiling of uploading 1.27GB in three requests on IIS on .NET Framework:So the managed code seems to call into IIS and get 16KB per read (1.27GB / 83,245).
This is the subtree of
ReadAsStreamAsyncBut as you mentioned before, the behavior of
FileStreamis of course also relevant. I noticed e.g. dotnet/runtime#25905 which might affect upload speeds when creating the file stream for async IO.tshcherban commentedon Jul 19, 2022
Any updates on that? Workaround maybe?
arielmoraes commentedon Dec 8, 2022
This issue is still present on .NET 7, when reading data from the HttpRequestStream the read times are very high.
Doing the opposite, by returning a stream via Results.Stream, the read times are normal.
davidfowl commentedon Dec 12, 2022
@arielmoraes do you have a detailed analysis like @avendel did? What are you measuring and can you provide us with a sample?
Safirion commentedon Jan 8, 2023
Very high CPU (100% all time of copy) usage when I use CopyToAsync of a request body to a FileStream (very slow too) but when I use CopyTo, it's very quick and CPU is not impacted.
In .Net 7 Web API.
Don't know if it's related.
davidfowl commentedon Jan 8, 2023
@Safirion do you have a sample and instructions on how to reproduce it?
Temppus commentedon Feb 7, 2025
I am currently profiling issue why asp is spending ~1000ms of time before hitting controller method when larger payload is used (~10MB). This is API endpoint that accept JSON with base64 encoded data. I was able to fix/workaround this issue by switching type declared for API model from byte
byte[]toMemory<byte>.Reproducer:
Using asp net 8.0 and WebApi controller POST method accepting these types.
SLOW (~1000ms)
FAST (~50ms)
I tried to so some profiling and when using
InputRequestByteArray-> 97% of time was spent inSystem.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart.Is this expected behavior ? Because to me it seems like a 🐛
Thanks.