Repository navigation
Avoid per-line MemoryStream in BufferedReadStream.ReadLine - #70156
Conversation
…ine is buffered BufferedReadStream.ReadLine/ReadLineAsync allocated a MemoryStream (and its 200-byte buffer) for every line and copied the line into it byte by byte. MultipartReader calls it for every boundary remainder, header line and the blank line ending each section's headers. When the terminating CRLF is already in the internal buffer, locate it with a vectorized IndexOf and decode the line straight from the buffer. Otherwise (or if the length limit would be exceeded) fall back to the existing loop so behavior and exceptions are unchanged. Also trim multipart header values on a span to avoid a second string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The split-CRLF test may buffer the entire input because ArrayPool can return more than four bytes, allowing regressions to go undetected.
1 open issue
What changed in this PR
Reduces allocations in WebUtilities multipart parsing by decoding fully buffered lines directly and trimming header values before allocating strings. No public API changes.
Changes:
- Adds a shared fast path for synchronous and asynchronous line reads, retaining the existing fallback.
- Uses span-based trimming for multipart header values.
- Adds tests for buffered lines, remaining data, length limits, and split CRLF handling.
| File | Description |
|---|---|
src/Http/WebUtilities/test/BufferedReadStreamTests.cs |
Adds line-reading and boundary-condition tests. |
src/Http/WebUtilities/src/MultipartReader.cs |
Avoids an intermediate header-value string when trimming. |
src/Http/WebUtilities/src/BufferedReadStream.cs |
Decodes complete buffered lines without a temporary MemoryStream. |
Review effort: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The failures on 00d1aaa look unrelated to this change:
I pushed an empty commit (8086d3f) to retrigger CI. If the Helix failure happens again, I'll dig into the specific work item. 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
|
Azure Pipelines hasn't started on the last two pushes to this PR (8086d3f and the earlier retrigger), even though pipelines are running for other PRs pushed in the same window. The previous run's failures look unrelated to this change:
Could a maintainer comment 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Earlier "Retrigger CI" commits on this branch were created with an empty tree, deleting every file. This restores the tree of the last good commit so the PR diff is again only the intended change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Heads-up: the two most recent "Retrigger CI" commits on this branch (8086d3f and 24b714d) were created with an empty tree, so each one deleted every file in the repo. That's why Azure Pipelines never started on them. Fixed in 742c03a, which restores the tree of the last good commit (00d1aaa). The PR diff is back to the intended change only (+144/−5: 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retriggered CI with an empty commit (d524329). The only failures were Blazor E2E tests unrelated to this WebUtilities change: 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retriggered CI with an empty commit (3a4a889). The previous run failed only the Blazor E2E test 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retriggered CI with an empty commit (bce2ab4). In 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
|
Status after the retrigger (bce2ab4): aspnetcore-ci is green. The only failure is in the separate
🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
|
/review |
|
✅ No documentation PR was created. Confidence that documentation is needed: 3% No docs needed: internal performance refactoring with tests only; no signals detected and no public or observable behavior change. Documentation surfaces considered
|

Avoid per-line MemoryStream in BufferedReadStream.ReadLine
Read buffered lines in place and trim multipart headers on spans
Description
BufferedReadStream.ReadLine/ReadLineAsyncallocated aMemoryStream(plus its 200-byte buffer) for every line and copied the line into it one byte at a time.MultipartReadercalls it for every boundary remainder, every section header and the blank line that ends the headers, so that is roughly fourMemoryStreams per section.This PR adds a fast path. When the terminating CRLF is already in the internal buffer, it is found with a vectorized
IndexOf("\r\n"u8)and the line is decoded directly from the buffer. If there is no CRLF in the buffer, or the line would exceedlengthLimit, the code falls back to the existing loop. That means lines spanning buffers, unterminated lines and theLine length limit N exceeded.exception behave exactly as before.MultipartReader.ReadHeadersAsyncalso usedSubstring(...).Trim(), which allocates a second string when the value has a leading space (the commonName: valueshape). It now usesAsSpan(...).Trim().ToString().No public API changes.
Benchmarks
Existing
MultipartReaderBenchmark(src/Http/WebUtilities/perf/Microbenchmarks), run with the repo runner (--config perflab, InProcess, MemoryDiagnoser) on a 2-vCPU Linux VM. Base and PR binaries were run back to back for two rounds; the second round is shown here (round 1 was within noise of it).LargePayload=False:The
LargePayload=Truecases are dominated by the 10 MB body and are unchanged within noise.Tests
BufferedReadStreamTestscovering multiple lines in one buffer (including a lone\rand a lone\ninside a line), empty lines, data left over after a line, the exact length-limit boundary for single-buffer lines (ASCII and multi-byte UTF-8), and a CRLF split across two buffer fills. I checked that the new tests also pass against the original implementation.Microsoft.AspNetCore.WebUtilities.Tests: 515 passed, 0 failed.There is no existing issue; this came out of a perf survey of the request parsing paths.
Benchmark (3 interleaved rounds)
Existing
MultipartReaderBenchmark.MultipartReaderParsing(src/Http/WebUtilities/perf/Microbenchmarks), all 18 parameter combinations.Environment: AMD EPYC 9V74, 2 vCPU (container), Ubuntu 24.04; .NET SDK 12.0.100-alpha.1.26521.103, .NET 12.0.0 (12.0.26.52911); BenchmarkDotNet v0.13.0
--config perflab(InProcess, MemoryDiagnoser), WarmupCount=3, IterationCount=10. "main" is the merge base4f556f73a6. Both builds use the same benchmark source, and runs were interleaved main, PR, main, PR, main, PR. Each cell shows mean / allocated per operation; allocation figures are exact bytes from the CSV exporter.LargePayload=False(header and boundary line parsing dominates): the PR is faster in all 9 cases (−7% to −24% median) and allocates 1.2–3.8 KB less per parse (−18% to −32%), consistently in every round.LargePayload=True(a 10 MB section body dominates, and the changed line-reading code is a negligible share of the time): time is mixed, between −4% and +10%. Allocations still drop by the same 1.2–3.8 KB. The +10% case (Boundary=28, Sections=3) doesn't follow the boundary/section pattern of the neighboring cases. That suggests code layout or JIT noise rather than this change, but I haven't proven it.🤖 Posted by ASP.NET Core Perf 2 (AI Agent)