Skip to content

Avoid per-line MemoryStream allocation in BufferedReadStream.ReadLine - #70194

Closed
davidfowl wants to merge 5 commits into
mainfrom
perf/http-bufferedstream-readline
Closed

davidfowl wants to merge 5 commits into
mainfrom
perf/http-bufferedstream-readline

Conversation

@davidfowl

Copy link
Copy Markdown
Member

BufferedReadStream.ReadLine/ReadLineAsync allocated a MemoryStream(200) (object + 200-byte buffer) for every line, even when the whole line was already in the read buffer. MultipartReader calls it for every boundary remainder and every section header line (including the blank terminator line), so a multipart form pays this several times per section.

This adds a fast path: if the buffered bytes contain \r\n and the line (content + CR) is within lengthLimit, the line is decoded straight from the buffer with the same Encoding.UTF8.GetString. Otherwise (no CRLF buffered yet, or the limit would be exceeded) it falls back to the existing accumulating path, so returned strings, the limit exception, and stream position behaviour are unchanged.

Benchmark

MultipartReader over a 3-section form (28-char boundary, 5 header lines, small bodies, bodies drained), BenchmarkDotNet InProcess, 3 interleaved rounds:

Round main PR main alloc PR alloc
1 2.963 us 2.194 us 12,000 B 8,544 B
2 2.943 us 2.331 us 12,000 B 8,544 B
3 2.973 us 2.258 us 12,000 B 8,544 B

~24% faster, 29% less allocated.

Tests

Added BufferedReadStreamTests covering several buffered lines (empty line, bare \r / \n inside a line, \r\r\n, non-ASCII, trailing unterminated data, BufferedData remainder), and a buffered line exactly at / just over the length limit, for both sync and async. They pass on main and on this branch. WebUtilities.Tests (512) and Http.Tests pass.

When the CRLF-terminated line is already fully buffered and within the
length limit, decode it directly from the buffer instead of copying it
into a new MemoryStream(200). Otherwise fall back to the existing path,
which also produces the limit exception, so results and errors are
unchanged. This is the path MultipartReader uses for every boundary
remainder and section header line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 16:19
@github-actions github-actions Bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label Oct 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new tests leave the read buffer empty, bypassing the changed over-limit fallback and potentially missing an off-by-one regression; prime the buffer before calling ReadLine.

1 open issue
What changed in this PR

This PR reduces per-line allocations in WebUtilities’ BufferedReadStream, improving multipart parsing when complete lines are already buffered.

Changes:

  • Adds a shared fast path for synchronous and asynchronous reads that decodes buffered CRLF-terminated lines directly.
  • Retains the accumulating path for incomplete or over-limit lines.
  • Adds tests for buffered lines, UTF-8 content, remaining data, and length limits.
File Description
src/​Http/​WebUtilities/​test/​BufferedReadStreamTests.cs Adds synchronous and asynchronous line-reading coverage.
src/​Http/​WebUtilities/​src/​BufferedReadStream.cs Avoids MemoryStream allocation for complete buffered lines within the limit.

Review effort: Balanced

Ensures the new tests exercise the buffered fast path, including the
over-limit fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Heads-up for reviewers: this PR overlaps with #70156, which was opened earlier from the same account and applies the same TryReadLineFromBuffer fast path in BufferedReadStream.ReadLine/ReadLineAsync. #70156 also changes the MultipartReader header-value trim. The two are not meant to be merged together. Please pick one; the other can be closed. The only extra here is the test coverage that primes the buffer so the fast path is exercised (BufferedReadStreamTests), and it can be ported to #70156 if preferred. I won't add further changes here unless asked.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Retriggered CI with an empty commit (d22cf7f). The previous run had one failure, Microsoft.AspNetCore.Mvc.FunctionalTests.RealServerUsingMinimalBackedIntegrationTests.CanResolveServices, with System.InvalidOperationException : IServerAddressesFeature.Addresses cannot be modified after the server has started. That's a startup race in the real-server test host. This PR only changes BufferedReadStream.ReadLine in WebUtilities, which that test doesn't exercise. All other 86,592 tests passed.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Retriggered CI with an empty commit (de3cd24). The previous run failed only the Blazor E2E test VirtualizationTest.VariableHeight_HtmlTable_DoesNotProduceNestedTrElements (Selenium Assert.NotEmpty; the same failure appeared on unrelated PR #70163). It is unrelated to this WebUtilities BufferedReadStream change.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Pushed an empty commit (782c1d0) to retrigger CI. Note that its commit message is out of date: the actual failure this run was not a Blazor flake.

Evidence that the aspnetcore-ci failure on de3cd24 is unrelated:

  • The only failing test was Microsoft.AspNetCore.Mvc.FunctionalTests.RealServerUsingMinimalBackedIntegrationTests.CanResolveServices, with System.InvalidOperationException : IServerAddressesFeature.Addresses cannot be modified after the server has started.
  • Build Analysis shows a 0.59% historical failure rate for that test.
  • The failure is in MVC/hosting server startup. This PR only touches src/Http/WebUtilities/src/BufferedReadStream.cs and its tests.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Copy link
Copy Markdown
Member Author

aspnetcore-ci is fully green on this run. The only red leg is aspnetcore-components-e2e, which failed on ServerRoutingTest.NavigationLock_CanBlockExternalNavigation. That is a known Blazor Selenium flake, and this PR doesn't touch Components. aspnetcore-components-e2e is also currently failing on main (failure on b7d01de, 23ab95b, fe077f0 and f00e4eb). I'm not pushing another retrigger until main's e2e pipeline is stable.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Copy link
Copy Markdown
Member Author

Superseded by #70156

The owner is keeping the earlier PR #70156, which covers the same change. Closing this duplicate is left to the maintainers. I will not push or retrigger anything further on this PR.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

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

Labels

area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants