Reduce reader/writer contention in Pipelines - #130884
Conversation
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
=== typical counts before the PR: |
|
=== typical counts after the PR: |
There was a problem hiding this comment.
Pull request overview
This PR updates System.IO.Pipelines internals to reduce reader/writer lock contention by changing how BufferSegment instances and backing memory are acquired/returned, and adjusts related scheduling and tests.
Changes:
- Switch
Pipe’s segment pooling from a stack to an SPSC queue and return consumed segments outside the main lock. - Speculatively rent backing memory outside the lock and add a lock-free fast path for
Advancewhen writing is active. - Update the segment-pool reuse test expectation (FIFO) and set ThreadPool scheduling to prefer local queues.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.IO.Pipelines/tests/BufferSegmentPoolTest.cs | Updates pooling test to expect FIFO reuse order. |
| src/libraries/System.IO.Pipelines/src/System/IO/Pipelines/ThreadPoolScheduler.netcoreapp.cs | Uses preferLocal: true for ThreadPool scheduling. |
| src/libraries/System.IO.Pipelines/src/System/IO/Pipelines/PipeOptions.cs | Updates segment pool sizing defaults/comments (but currently misses required property assignment). |
| src/libraries/System.IO.Pipelines/src/System/IO/Pipelines/Pipe.cs | Moves to SPSC pooling + reduces time under the shared lock (memory rent + segment return). |
| src/libraries/System.IO.Pipelines/src/System.IO.Pipelines.csproj | Links in SPSC queue + padding shared sources (and introduces a BOM). |
|
Benchmark used (--scenario json --profile aspnet-gold-lin): The changes to notice:
- Max Lock Contention (#/s) | 483
+ Max Lock Contention (#/s) | 66
- Requests/sec | 2,094,374
+ Requests/sec | 2,148,465
- Read throughput (MB/s) | 291.61
+ Read throughput (MB/s) | 299.14 |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Build failures on NET481 seems unrelated to the change: D:\a_work\1\s\src\libraries\Microsoft.Extensions.Caching.Abstractions\tests\TrimmingTests\HybridCacheEntryOptionsAccessorTest.cs(17,6): error CS0246: The type or namespace name 'UnsafeAccessorAttribute' could not be found (are you missing a using directive or an assembly reference?) [D:\a_work\1\s\src\libraries\Microsoft.Extensions.Caching.Abstractions\tests\Microsoft.Extensions.Caching.Abstractions.Tests.csproj::TargetFramework=net481] |
|
Workflow state for the Holistic Review Orchestrator. {
"version": 5,
"last_dispatched_commit": "640d9a6ed202c8c9d854bab61a3246db82999a81",
"last_dispatched_base_ref": "main",
"last_dispatched_base_sha": "4c9e771cbd5e35313cef06c4d7efb55070fd5493",
"last_reviewed_commit": "640d9a6ed202c8c9d854bab61a3246db82999a81",
"last_reviewed_base_ref": "main",
"last_reviewed_base_sha": "4c9e771cbd5e35313cef06c4d7efb55070fd5493",
"last_recorded_worker_run_id": "29685652058",
"review_attempt_commit": "",
"review_attempt_base_ref": "",
"review_attempt_count": 0,
"max_review_attempts": 5,
"review_history_format": "holistic-review-disclosure-v1",
"review_history": [
{
"commit": "640d9a6ed202c8c9d854bab61a3246db82999a81",
"review_id": 4730708679
}
]
} |
There was a problem hiding this comment.
Holistic Review
Motivation: Under high core-count ASP.NET benchmarks the reader and writer of a Pipe contend heavily on the single lock that guards shared state, plus the FILO segment pool causes cache-line ping-pong because both threads touch the same end of the pool. The PR reports a large drop in max lock contention (483 -> 66 #/s) with modest RPS/throughput gains on a 56-core machine.
Approach: Four coordinated changes. (1) Move non-locking-required work out of the lock: memory rent/return and segment Reset()/return now happen outside SyncObj. AllocateWriteHeadSynchronized speculatively rents backing memory before taking the lock (RentMemoryUnsynchronized) and attaches it under the lock via AttachMemory, and AdvanceReader returns consumed segments in a finally after releasing the lock. (2) Replace the FILO BufferSegmentStack with a SingleProducerSingleConsumerQueue<BufferSegment> so the writer (dequeue/consumer) and reader (enqueue/producer) operate at opposite ends, reducing false sharing. (3) Switch SyncObj from a Monitor object to System.Threading.Lock on NET9+, with an object fallback for older TFMs. (4) Advance(int) takes a lock-free fast path while writing is active, and continuations are scheduled preferLocal: true to keep pipelining on the same physical thread.
Summary: The change is subtle but the concurrency reasoning is thorough and, as far as I can determine by inspection, correct. The SPSC role split is respected: GetOrCreateSegment (dequeue) is only reached from writer allocation paths, and ReturnSegment (enqueue) is only reached from the reader's AdvanceReader, consistent with the Pipe's single-reader/single-writer contract that SPSC requires. The off-lock _writingHeadMemory.Length reads are single-int reads (atomic, at worst stale), the authoritative decision is remade under the lock, and the Debug.Assert(prerented is null) documents/guards the "speculative rent always consumed" invariant. The lock-free Advance fast path is gated on IsWritingActive, where the writer thread exclusively owns the writing-head fields. InitialSegmentPoolSize is removed cleanly (internal-only, no ref-surface impact; StreamPipeReader/StreamPipeWriter keep their own constant and BufferSegmentStack, which is intentional). The MaxSegmentPoolSize documentation is a helpful addition. The one test change correctly flips the pool reuse assertion from FILO to FIFO ordering. I did not identify functional bugs or missing test coverage introduced by this PR; the existing pool/threshold tests continue to exercise the new pooling path. Verdict: LGTM. I recommend confirming the full CI matrix (including the netstandard2.0 / .NET Framework Monitor fallback path and stress/concurrency tests) is green before merge, since correctness here rests on threading invariants that are hard to cover exhaustively with unit tests.
Note
This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.
Generated by Holistic Review · 154.4 AIC · ⌖ 10.7 AIC · ⊞ 10K
eduardo-vp
left a comment
There was a problem hiding this comment.
LGTM! I was only surprised at the values of Max Time in GC (%) and Max Gen 2 Size (B) in the benchmark but I think the use of a more complex queue rather than a simpler stack is the reason we're seeing the increase in the Gen 2 - the numbers overall look great.
|
/ba-g netfx failure is #130892 |
These benchmarks do not allocate much (1-3 gc per second in a multithreaded app running on 56 cores is fairly low), so this may be just sampling noise that depends on timing when those relatively few GCs happen. The end result does not seem to be impacted by that metric. |
The change deals with excessive contentions coming from Pipelines in some benchmarks when reader and writer contend for the lock used to protect shared state of the Pipe.
Changes:
For example, writer thread, if it looks for more work after writing, would be preferred to take care of asynchronous reading and processing of written data, thus further reducing contention and improving locality of access.
=== effect on JSON asp.net benchmark running on a 56-core machine: