* [PATCH 0/2] fs/pipe: reduce pipe->mutex contention by bulk-allocating outside the lock
@ 2026-05-15 10:28 Breno Leitao
2026-05-15 10:28 ` [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write Breno Leitao
2026-05-15 10:28 ` [PATCH 2/2] selftests/pipe: add pipe_bench microbenchmark Breno Leitao
0 siblings, 2 replies; 9+ messages in thread
From: Breno Leitao @ 2026-05-15 10:28 UTC (permalink / raw)
To: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan
Cc: linux-fsdevel, linux-kernel, linux-kselftest, Breno Leitao,
usama.arif, kernel-team
While profiling kernel code on Meta's caching code[1], I found some lock
contention on pipe hot path, and I decide to investigate, and found that
anon_pipe_write() currently calls alloc_page() once per page while
holding pipe->mutex. The allocation can sleep doing direct reclaim and
runs memcg charging, which extends the critical section and stalls any
concurrent reader on the same mutex.
I would like to propose that we bulk pre-allocate pages outside
pipe->mutex in anon_pipe_write -- pre-allocates up to PIPE_PREALLOC_MAX
(8) pages with alloc_pages_bulk() before taking the lock
* the get-page helper then consumes from tmp_page[] / the prealloc
array first and only falls back to in-lock alloc_page() for writes
larger than PIPE_PREALLOC_MAX (8) pages, which is rare.
I also vibe coded a microbenchmark to test the pipe benchmark. Basically
it wweeps writers x readers over {1,2,5} x {1,5,10} with 64KB writes
against a 1 MB pipe and prints throughput + latency percentiles per
config.
Measured on arm64 in virtme-ng (16 vCPUs, 64KB writes, 1 MB pipe).
== No memory pressure (10s per config) ==
Throughput in MB/s (baseline -> patched, delta):
writers readers=1 readers=5 readers=10
1 1119 -> 1354 (+21%) 1132 -> 1195 (+6%) 1060 -> 1240 (+17%)
2 1162 -> 1487 (+28%) 1034 -> 1285 (+24%) 1069 -> 1213 (+14%)
5 1152 -> 1357 (+18%) 1021 -> 1164 (+14%) 997 -> 1239 (+24%)
Avg write latency in ns (baseline -> patched, delta):
writers readers=1 readers=5 readers=10
1 55786 -> 46103 (-17%) 55164 -> 52260 (-5%) 58906 -> 50370 (-14%)
2 107546 -> 84011 (-22%) 120837 -> 97206 (-20%) 116860 -> 103036 (-12%)
5 271293 -> 230170 (-15%) 306089 -> 268429 (-12%) 313300 -> 252232 (-19%)
Throughput improves +6% to +28% and average write latency drops 5%
to 22% across every configuration.
== Under memory pressure (--memory-pressure, 6s per config) ==
stress-ng --vm 2 --vm-bytes 50% --vm-keep is forked alongside the
sweep so the alloc_page() calls inside anon_pipe_write() routinely
hit direct reclaim -- exactly the regime the patch targets.
Throughput in MB/s (baseline -> patched, delta):
writers readers=1 readers=5 readers=10
1 1088 -> 1438 (+32%) 996 -> 1477 (+48%) 989 -> 1194 (+21%)
2 1076 -> 1378 (+28%) 1007 -> 1269 (+26%) 1018 -> 1234 (+21%)
5 1052 -> 1311 (+25%) 986 -> 1225 (+24%) 972 -> 1249 (+29%)
Avg write latency in ns (baseline -> patched, delta):
writers readers=1 readers=5 readers=10
1 57397 -> 43406 (-24%) 62690 -> 42272 (-33%) 63136 -> 52272 (-17%)
2 116121 -> 90700 (-22%) 124098 -> 98481 (-21%) 122754 -> 101217 (-18%)
5 297122 -> 238322 (-20%) 316836 -> 255095 (-19%) 321496 -> 250189 (-22%)
Throughput improves +21% to +48% and average write latency drops
17% to 33% -- a noticeably bigger win than the no-pressure run.
That tracks: when alloc_page() has to dip into reclaim, the cost
of holding pipe->mutex across it is highest, and pulling the
allocation out of the critical section pays the most.
Link: https://www.usenix.org/system/files/conference/atc13/atc13-bronson.pdf [1]
Signed-off-by: Breno Leitao <leitao@debian.org>
---
Breno Leitao (2):
fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write
selftests/pipe: add pipe_bench microbenchmark
fs/pipe.c | 40 +++-
tools/testing/selftests/Makefile | 1 +
tools/testing/selftests/pipe/.gitignore | 1 +
tools/testing/selftests/pipe/Makefile | 9 +
tools/testing/selftests/pipe/pipe_bench.c | 351 ++++++++++++++++++++++++++++++
5 files changed, 400 insertions(+), 2 deletions(-)
---
base-commit: e98d21c170b01ddef366f023bbfcf6b31509fa83
change-id: 20260515-fix_pipe-c91677c187e7
Best regards,
--
Breno Leitao <leitao@debian.org>
^ permalink raw reply [flat|nested] 9+ messages in thread* [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-15 10:28 [PATCH 0/2] fs/pipe: reduce pipe->mutex contention by bulk-allocating outside the lock Breno Leitao @ 2026-05-15 10:28 ` Breno Leitao 2026-05-15 20:18 ` Mateusz Guzik 2026-05-15 10:28 ` [PATCH 2/2] selftests/pipe: add pipe_bench microbenchmark Breno Leitao 1 sibling, 1 reply; 9+ messages in thread From: Breno Leitao @ 2026-05-15 10:28 UTC (permalink / raw) To: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan Cc: linux-fsdevel, linux-kernel, linux-kselftest, Breno Leitao, usama.arif, kernel-team anon_pipe_write() takes pipe->mutex and then, from the per-iteration anon_pipe_get_page() helper, used to call alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT) once per page while still holding it. That allocation can sleep doing direct reclaim and/or runs memcg charging, which extends the critical section and stalls a concurrent reader on the very same mutex. Bulk pre-allocate DIV_ROUND_UP(total_len, PAGE_SIZE) pages (up to PIPE_PREALLOC_MAX (8)) pages outside the mutex when total_len >= PAGE_SIZE, using alloc_pages_bulk(). (Under memcg, alloc_pages_bulk() with __GFP_ACCOUNT might return less pages than requested, but, this is still a win, given some pages allocation is moved outside of the lock). Pass the array into anon_pipe_get_page(), which now consumes from tmp_page[] first, then from the prealloc array, and only as a last resort falls back to alloc_page() under the mutex (reached only for writes larger than 8 pages, where the prealloc cap is exhausted). Doing this in one bulk call before the lock keeps the fast path's mutex held for a single, write-bounded critical section -- no extra mutex_unlock/_lock cycles -- so it avoids the per-page lock-handoff overhead that a per-page drop-and-retake design would introduce, while still moving the typical multi-page allocation off the critical section. Unused prealloc pages are pushed to the pipe's tmp_page[] cache (or freed) before unlock, so a subsequent write to the same pipe gets a hot cached page rather than paying for an alloc again. Sub-PAGE_SIZE writes are unchanged: the merge path handles them without ever needing a fresh page, so it is not worth speculatively allocating for them. This can improve the pipe throughput up to 48% and reduce the latency in 33%. Signed-off-by: Breno Leitao <leitao@debian.org> --- fs/pipe.c | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/fs/pipe.c b/fs/pipe.c index 9841648c9cf3e..7a1517d15107a 100644 --- a/fs/pipe.c +++ b/fs/pipe.c @@ -111,7 +111,11 @@ void pipe_double_lock(struct pipe_inode_info *pipe1, pipe_lock(pipe2); } -static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) +#define PIPE_PREALLOC_MAX 8 + +static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe, + struct page **prealloc, + unsigned int *prealloc_n) { for (int i = 0; i < ARRAY_SIZE(pipe->tmp_page); i++) { if (pipe->tmp_page[i]) { @@ -121,6 +125,14 @@ static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) } } + if (*prealloc_n) { + unsigned int idx = --(*prealloc_n); + struct page *page = prealloc[idx]; + + prealloc[idx] = NULL; + return page; + } + return alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); } @@ -438,6 +450,8 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) ssize_t chars; bool was_empty = false; bool wake_next_writer = false; + struct page *prealloc[PIPE_PREALLOC_MAX] = { NULL }; + unsigned int prealloc_n = 0; /* * Reject writing to watch queue pipes before the point where we lock @@ -455,6 +469,26 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) if (unlikely(total_len == 0)) return 0; + /* + * Bulk pre-allocate up to PIPE_PREALLOC_MAX pages outside pipe->mutex + * for writes that span at least one full page. alloc_page() with + * GFP_HIGHUSER may sleep doing reclaim and runs memcg charging, so + * doing it under the mutex extends the critical section and stalls + * the reader. The merge path handles sub-PAGE_SIZE writes without + * needing a fresh page; for writes larger than PIPE_PREALLOC_MAX + * pages, anon_pipe_get_page() falls back to a single alloc_page() + * under the mutex for the remainder. Unused prealloc pages are + * returned to the pipe's tmp_page[] cache (or freed) before unlock. + */ + if (total_len >= PAGE_SIZE) { + unsigned int want = min_t(unsigned int, + DIV_ROUND_UP(total_len, PAGE_SIZE), + PIPE_PREALLOC_MAX); + + prealloc_n = alloc_pages_bulk(GFP_HIGHUSER | __GFP_ACCOUNT, + want, prealloc); + } + mutex_lock(&pipe->mutex); if (!pipe->readers) { @@ -512,7 +546,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) struct page *page; int copied; - page = anon_pipe_get_page(pipe); + page = anon_pipe_get_page(pipe, prealloc, &prealloc_n); if (unlikely(!page)) { if (!ret) ret = -ENOMEM; @@ -576,6 +610,8 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) wake_next_writer = true; } out: + while (prealloc_n) + anon_pipe_put_page(pipe, prealloc[--prealloc_n]); if (pipe_is_full(pipe)) wake_next_writer = false; mutex_unlock(&pipe->mutex); -- 2.53.0-Meta ^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-15 10:28 ` [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write Breno Leitao @ 2026-05-15 20:18 ` Mateusz Guzik 2026-05-20 17:09 ` Breno Leitao 0 siblings, 1 reply; 9+ messages in thread From: Mateusz Guzik @ 2026-05-15 20:18 UTC (permalink / raw) To: Breno Leitao Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team On Fri, May 15, 2026 at 03:28:44AM -0700, Breno Leitao wrote: > anon_pipe_write() takes pipe->mutex and then, from the per-iteration > anon_pipe_get_page() helper, used to call alloc_page(GFP_HIGHUSER | > __GFP_ACCOUNT) once per page while still holding it. That allocation > can sleep doing direct reclaim and/or runs memcg charging, which extends > the critical section and stalls a concurrent reader on the very same > mutex. > > Bulk pre-allocate DIV_ROUND_UP(total_len, PAGE_SIZE) pages (up to > PIPE_PREALLOC_MAX (8)) pages outside the mutex when total_len >= > PAGE_SIZE, using alloc_pages_bulk(). (Under memcg, alloc_pages_bulk() > with __GFP_ACCOUNT might return less pages than requested, but, this is > still a win, given some pages allocation is moved outside of the > lock). > > Pass the array into anon_pipe_get_page(), which now consumes from > tmp_page[] first, then from the prealloc array, and only as a last > resort falls back to alloc_page() under the mutex (reached only for > writes larger than 8 pages, where the prealloc cap is exhausted). > > Doing this in one bulk call before the lock keeps the fast path's > mutex held for a single, write-bounded critical section -- no extra > mutex_unlock/_lock cycles -- so it avoids the per-page lock-handoff > overhead that a per-page drop-and-retake design would introduce, while > still moving the typical multi-page allocation off the critical > section. Unused prealloc pages are pushed to the pipe's tmp_page[] > cache (or freed) before unlock, so a subsequent write to the same > pipe gets a hot cached page rather than paying for an alloc again. > > Sub-PAGE_SIZE writes are unchanged: the merge path handles them > without ever needing a fresh page, so it is not worth speculatively > allocating for them. > > This can improve the pipe throughput up to 48% and reduce the latency in > 33%. This change is quite a hammer. Do you have a real workload where there are multiple processes issuing large ops to the same pipe on both reader and writer side? The only workload I'm aware of sharing the pipe between more than just one reader and one writer is anything with make, but that is mostly operating on 1-byte ops. I'm asking because this would be better implemented taking into account how many pages are hanging out in the tmp_page array. With only one writer and one reader there is no concern someone will steal the pages. Preferably the tmp_page array would grow to accomodate more memory, but admittedly this runs into a problem where a pipe can linger unused indefinitely while hoarding several pages. As in, some shrinking mechanism would be needed so that's probably a no-go and preallocation is the way to go for now. Even then, this can definitely be integrated much better instead of being open-coded. > > Signed-off-by: Breno Leitao <leitao@debian.org> > --- > fs/pipe.c | 40 ++++++++++++++++++++++++++++++++++++++-- > 1 file changed, 38 insertions(+), 2 deletions(-) > > diff --git a/fs/pipe.c b/fs/pipe.c > index 9841648c9cf3e..7a1517d15107a 100644 > --- a/fs/pipe.c > +++ b/fs/pipe.c > @@ -111,7 +111,11 @@ void pipe_double_lock(struct pipe_inode_info *pipe1, > pipe_lock(pipe2); > } > > -static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) > +#define PIPE_PREALLOC_MAX 8 > + > +static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe, > + struct page **prealloc, > + unsigned int *prealloc_n) > { > for (int i = 0; i < ARRAY_SIZE(pipe->tmp_page); i++) { > if (pipe->tmp_page[i]) { > @@ -121,6 +125,14 @@ static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) > } > } > > + if (*prealloc_n) { > + unsigned int idx = --(*prealloc_n); This is some nasty golfing. Instead, you could have something like: struct tmp_page_prealloc { struct page *pages; unsigned int count; }; then... > + struct page *page = prealloc[idx]; > + > + prealloc[idx] = NULL; > + return page; > + } > + > return alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); > } > > @@ -438,6 +450,8 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > ssize_t chars; > bool was_empty = false; > bool wake_next_writer = false; > + struct page *prealloc[PIPE_PREALLOC_MAX] = { NULL }; > + unsigned int prealloc_n = 0; > struct tmp_page_prealloc tpp = {}; > /* > * Reject writing to watch queue pipes before the point where we lock > @@ -455,6 +469,26 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > if (unlikely(total_len == 0)) > return 0; > > + /* > + * Bulk pre-allocate up to PIPE_PREALLOC_MAX pages outside pipe->mutex > + * for writes that span at least one full page. alloc_page() with > + * GFP_HIGHUSER may sleep doing reclaim and runs memcg charging, so > + * doing it under the mutex extends the critical section and stalls > + * the reader. The merge path handles sub-PAGE_SIZE writes without > + * needing a fresh page; for writes larger than PIPE_PREALLOC_MAX > + * pages, anon_pipe_get_page() falls back to a single alloc_page() > + * under the mutex for the remainder. Unused prealloc pages are > + * returned to the pipe's tmp_page[] cache (or freed) before unlock. > + */ > + if (total_len >= PAGE_SIZE) { > + unsigned int want = min_t(unsigned int, > + DIV_ROUND_UP(total_len, PAGE_SIZE), > + PIPE_PREALLOC_MAX); > + > + prealloc_n = alloc_pages_bulk(GFP_HIGHUSER | __GFP_ACCOUNT, > + want, prealloc); > + } > + This should move to a dedicated routine, perhaps: anon_get_page_prealloc(pipe, &tmp_page_prealloc); then it can also easily decide how much to prealloc based on existing state > mutex_lock(&pipe->mutex); > > if (!pipe->readers) { > @@ -512,7 +546,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > struct page *page; > int copied; > > - page = anon_pipe_get_page(pipe); > + page = anon_pipe_get_page(pipe, prealloc, &prealloc_n); > if (unlikely(!page)) { > if (!ret) > ret = -ENOMEM; > @@ -576,6 +610,8 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > wake_next_writer = true; > } > out: > + while (prealloc_n) > + anon_pipe_put_page(pipe, prealloc[--prealloc_n]); this results in put_page() calls under the mutex, still extending the hold time you could split this into 2 ops, for example: + anon_pipe_cache_pages(pipe, &tmp_page_prealloc); if (pipe_is_full(pipe)) wake_next_writer = false; mutex_unlock(&pipe->mutex); + anon_pipe_free_pages(pipe, &tmp_page_prealloc); All that aside, I have no idea about performance impact on arm64, but there is *definitely* some throughput lost based on the fact that memory copy is restarted per page. I presume arm64 has an equivalent of amd64's SMAP which again will not be free and is paid for every time as well. ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-15 20:18 ` Mateusz Guzik @ 2026-05-20 17:09 ` Breno Leitao 2026-05-21 12:58 ` Mateusz Guzik 0 siblings, 1 reply; 9+ messages in thread From: Breno Leitao @ 2026-05-20 17:09 UTC (permalink / raw) To: Mateusz Guzik Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team Hello Mateusz, Thanks for the careful review. Let me address the points inline, with data I collected on three Meta production hosts: two aarch64 NVIDIA Grace and one x86_64 Bergamo / EPYC 9D64, using bpftrace and perf lock contention. On Fri, May 15, 2026 at 10:18:19PM +0200, Mateusz Guzik wrote: > This change is quite a hammer. Are you seeing any downside/trade-off with this current approach? > > Do you have a real workload where there are multiple processes issuing > large ops to the same pipe on both reader and writer side? > > The only workload I'm aware of sharing the pipe between more than just > one reader and one writer is anything with make, but that is mostly > operating on 1-byte ops. You're right -- most pipe writes really are tiny. On Bergamo, 120s sample of anon_pipe_write, 299629 calls bucketed by iov_iter_count(from): size writes % <128 B 293602 98.0 (merge path, no alloc) 128 B - 4 KB 1108 0.4 4 - 8 KB 2149 0.72 <-- patch start helps from here 8 - 16 KB 3 16 - 32 KB 0 32 - 64 KB 5 64 - 128 KB 2 <-- up to 72KB writes So only ~0.72% of writes are >= PAGE_SIZE. The dominant comm by contention count is a Meta fleet workload spraying sub-128-byte metric strings -- it does *zero* writes >= PAGE_SIZE itself; it just hits the mutex very often. But the writers that *do* take the alloc-under-mutex path are real. Lock-wait data confirms it shows up: perf lock contention -ab (30s, anon_pipe_* only): contentions total wait max wait arm64 (NVIDIA Grace) anon_pipe_write 7660 4.39 ms 50 us anon_pipe_read 7 55.78 us 17 us x86_64 (Bergamo) anon_pipe_write 3154 4.53 ms 79 us anon_pipe_read 817 1.72 ms 82 us The reader side stands out on x86 (817 vs ~7 on arm64) -- that's the symptom the patch targets: a multi-page writer holding pipe->mutex across alloc_page() stalls the concurrent reader on the same mutex. bpftrace, mutex_lock duration inside anon_pipe_write, 300s: Grace-A Grace-B Bergamo [4K, 8K) 1090 2799 980 [8K, 16K) 1785 1353 953 [16K,32K) 755 623 530 [32K,64K) 153 172 184 [64K,128K) 13 8 59 [128K,256K) - - 4 [256K,512K) - - 1 <-- ~500us blocked So the patch's value is shortening the long critical sections that the 99% of small writers are blocked on -- the contention everyone sees drops because the few multi-page writers do their alloc work outside the lock. >= 64KB, which are not common but existent. > I'm asking because this would be better implemented taking into account > how many pages are hanging out in the tmp_page array. With only one > writer and one reader there is no concern someone will steal the pages. > > Preferably the tmp_page array would grow to accomodate more memory, but > admittedly this runs into a problem where a pipe can linger unused > indefinitely while hoarding several pages. As in, some shrinking > mechanism would be needed so that's probably a no-go and preallocation > is the way to go for now. > > Even then, this can definitely be integrated much better instead of > being open-coded. > This is some nasty golfing. > > Instead, you could have something like: > struct tmp_page_prealloc { > struct page *pages; > unsigned int count; > }; Agreed -- v2 will wrap the array+counter in struct tmp_page_prealloc and drop the open-coded indexing. > struct tmp_page_prealloc tpp = {}; > [...] > This should move to a dedicated routine, perhaps: > anon_get_page_prealloc(pipe, &tmp_page_prealloc); > > then it can also easily decide how much to prealloc based on existing > state Yes. v2 will have anon_get_page_prealloc(pipe, &tpp) doing both: top up to PIPE_PREALLOC_MAX based on tmp_page[] occupancy, and keep the policy in one place. > > + while (prealloc_n) > > + anon_pipe_put_page(pipe, prealloc[--prealloc_n]); > > this results in put_page() calls under the mutex, still extending the hold time > > you could split this into 2 ops, for example: > + anon_pipe_cache_pages(pipe, &tmp_page_prealloc); > if (pipe_is_full(pipe)) > wake_next_writer = false; > mutex_unlock(&pipe->mutex); > + anon_pipe_free_pages(pipe, &tmp_page_prealloc); Well spotted! > All that aside, I have no idea about performance impact on arm64, but > there is *definitely* some throughput lost based on the fact that memory > copy is restarted per page. I presume arm64 has an equivalent of amd64's > SMAP which again will not be free and is paid for every time as well. Can you say a bit more about what you'd want to see here? I want to make sure I'm reading the suggestion the same way you are. My understanding: anon_pipe_write loops once per page calling copy_page_from_iter(), and each call re-enters the iov_iter machinery and pays a STAC/CLAC pair on x86 (PAN toggle on arm64). So an N-page write pays the user-access bracket N times instead of once, on top of resetting the per-page memcpy and losing the hardware prefetch streak. The way I see it, is opening a single user_read_access_begin() region around the page loop and use unsafe_copy_from_user() inside, with a labeled fault-fallback path that closes the region and finishes via the slow path. Is this what you meant? Really thanks for your review, --breno ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-20 17:09 ` Breno Leitao @ 2026-05-21 12:58 ` Mateusz Guzik 2026-05-21 15:38 ` Breno Leitao 0 siblings, 1 reply; 9+ messages in thread From: Mateusz Guzik @ 2026-05-21 12:58 UTC (permalink / raw) To: Breno Leitao Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team On Wed, May 20, 2026 at 7:09 PM Breno Leitao <leitao@debian.org> wrote: > On Fri, May 15, 2026 at 10:18:19PM +0200, Mateusz Guzik wrote: > > This change is quite a hammer. > > Are you seeing any downside/trade-off with this current approach? > Well there is a lot to say about the current code to begin with, I'm going to refrain from writing long rants here as that's of no use in this thread. The idea behind the patch has a lot of merit and I'm definitely not protesting it or denying there is a problem. > > > > Do you have a real workload where there are multiple processes issuing > > large ops to the same pipe on both reader and writer side? > > > > The only workload I'm aware of sharing the pipe between more than just > > one reader and one writer is anything with make, but that is mostly > > operating on 1-byte ops. > > You're right -- most pipe writes really are tiny. On Bergamo, 120s sample of > anon_pipe_write, 299629 calls bucketed by iov_iter_count(from): > > size writes % > <128 B 293602 98.0 (merge path, no alloc) > 128 B - 4 KB 1108 0.4 > 4 - 8 KB 2149 0.72 <-- patch start helps from here > 8 - 16 KB 3 > 16 - 32 KB 0 > 32 - 64 KB 5 > 64 - 128 KB 2 <-- up to 72KB writes > > So only ~0.72% of writes are >= PAGE_SIZE. The dominant comm by > contention count is a Meta fleet workload spraying sub-128-byte > metric strings -- it does *zero* writes >= PAGE_SIZE itself; it > just hits the mutex very often. > > But the writers that *do* take the alloc-under-mutex path are > real. Lock-wait data confirms it shows up: > > perf lock contention -ab (30s, anon_pipe_* only): > > contentions total wait max wait > arm64 (NVIDIA Grace) > anon_pipe_write 7660 4.39 ms 50 us > anon_pipe_read 7 55.78 us 17 us > x86_64 (Bergamo) > anon_pipe_write 3154 4.53 ms 79 us > anon_pipe_read 817 1.72 ms 82 us > > The reader side stands out on x86 (817 vs ~7 on arm64) -- that's > the symptom the patch targets: a multi-page writer holding > pipe->mutex across alloc_page() stalls the concurrent reader on > the same mutex. > > bpftrace, mutex_lock duration inside anon_pipe_write, 300s: > > Grace-A Grace-B Bergamo > [4K, 8K) 1090 2799 980 > [8K, 16K) 1785 1353 953 > [16K,32K) 755 623 530 > [32K,64K) 153 172 184 > [64K,128K) 13 8 59 > [128K,256K) - - 4 > [256K,512K) - - 1 <-- ~500us blocked > > So the patch's value is shortening the long critical sections that > the 99% of small writers are blocked on -- the contention everyone > sees drops because the few multi-page writers do their alloc work > outside the lock. > > >= 64KB, which are not common but existent. > Ye this makes sense to me. > > This should move to a dedicated routine, perhaps: > > anon_get_page_prealloc(pipe, &tmp_page_prealloc); > > > > then it can also easily decide how much to prealloc based on existing > > state > > Yes. v2 will have anon_get_page_prealloc(pipe, &tpp) doing both: > top up to PIPE_PREALLOC_MAX based on tmp_page[] occupancy, and > keep the policy in one place. > Note I lost the 'pipe' word. This definitely needs to be anon_pipe_-prefixed. > > All that aside, I have no idea about performance impact on arm64, but > > there is *definitely* some throughput lost based on the fact that memory > > copy is restarted per page. I presume arm64 has an equivalent of amd64's > > SMAP which again will not be free and is paid for every time as well. > > Can you say a bit more about what you'd want to see here? I want to make sure > I'm reading the suggestion the same way you are. > > My understanding: anon_pipe_write loops once per page calling > copy_page_from_iter(), and each call re-enters the iov_iter > machinery and pays a STAC/CLAC pair on x86 (PAN toggle on arm64). I was told arm64 has an explicit instruction set to deal with user memory, so it might be this is implemented differently there and fixing the problem might happen to not alter perf on that arch. It will definitely help on amd64. > So an N-page write pays the user-access bracket N times instead of > once, on top of resetting the per-page memcpy and losing the > hardware prefetch streak. > This bit is unavoidable with the current implementation as backing pages are not guaranteed to be even virtually contiguous. The extra SMAP trips however can be avoided. > The way I see it, is opening a single user_read_access_begin() region around > the page loop and use unsafe_copy_from_user() inside, with a labeled > fault-fallback path that closes the region and finishes via the slow path. > > Is this what you meant? > Yes indeed. This is however optional and even if implemented it should be a separate patch. As a nit you could sprinkle some predicts in a separate commit, for example "if (!pipe->readers) {" definitely warrants an unlikely(). The real deal is the following: the wakeup policy is to *always* wakeup *all* readers if any data is available and *all* writers if any space is available. In case of heavily shared usage this leads to the thundering herd problem (and it is causing performance issues with the BSD and GNU makes). While this cannot be changed by default, I was thinking about an opt-in fcntl which changes to policy to only wake up one waiter. In case of a microbenchmark like the one your provided here such a policy might end up reducing throughput as there will be more time spent off cpu by the benchmark. However, in case of real workloads which presumably have stuff to do, it should be a win. That is to say, if you have 1 reader and 1 writer, this is irrelevant. But if you have multiple and they block on the pipe, there might be a win hiding here for your actual workload by utilizing the above. Something to consider. ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-21 12:58 ` Mateusz Guzik @ 2026-05-21 15:38 ` Breno Leitao 2026-05-21 17:26 ` Mateusz Guzik 0 siblings, 1 reply; 9+ messages in thread From: Breno Leitao @ 2026-05-21 15:38 UTC (permalink / raw) To: Mateusz Guzik Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team On Thu, May 21, 2026 at 02:58:48PM +0200, Mateusz Guzik wrote: > On Wed, May 20, 2026 at 7:09 PM Breno Leitao <leitao@debian.org> wrote: > > On Fri, May 15, 2026 at 10:18:19PM +0200, Mateusz Guzik wrote: > > > This change is quite a hammer. > > > > Are you seeing any downside/trade-off with this current approach? > > > > Well there is a lot to say about the current code to begin with, I'm > going to refrain from writing long rants here as that's of no use in > this thread. > > The idea behind the patch has a lot of merit and I'm definitely not > protesting it or denying there is a problem. Ack, thanks for the support. > > Yes. v2 will have anon_get_page_prealloc(pipe, &tpp) doing both: > > top up to PIPE_PREALLOC_MAX based on tmp_page[] occupancy, and > > keep the policy in one place. > > > > Note I lost the 'pipe' word. This definitely needs to be anon_pipe_-prefixed. Ack! > As a nit you could sprinkle some predicts in a separate commit, for > example "if (!pipe->readers) {" definitely warrants an unlikely(). > > The real deal is the following: the wakeup policy is to *always* > wakeup *all* readers if any data is available and *all* writers if any > space is available. In case of heavily shared usage this leads to the > thundering herd problem (and it is causing performance issues with the > BSD and GNU makes). While this cannot be changed by default, I was > thinking about an opt-in fcntl which changes to policy to only wake up > one waiter. Happy to look at it as a follow-up series once this one lands. Also, I am not reusing the leftover pages back to pipe->tmp_page, given I really don't expect to have much leftovers, but, I am happy to do so, in case you think this would be useful. Anyway, my reading of the discussion above changed my approach to the following patch. Is it any better? Thanks for you suggestions so far, --breno commit 2394068de8ee0ab9e582ce9776990ed695397553 Author: Breno Leitao <leitao@debian.org> Date: Fri May 15 01:35:13 2026 -0700 fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write anon_pipe_write() takes pipe->mutex and then, from the per-iteration anon_pipe_get_page() helper, used to call alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT) once per page while still holding it. That allocation can sleep doing direct reclaim and/or runs memcg charging, which extends the critical section and stalls a concurrent reader on the very same mutex. For writes that span more than one full page, bulk pre-allocate up to PIPE_PREALLOC_MAX (8) pages with alloc_pages_bulk() before taking pipe->mutex. anon_pipe_get_page() consumes from pipe->tmp_page[] first, then from the prealloc array, and only falls back to alloc_page() under the mutex for writes larger than PIPE_PREALLOC_MAX pages (rare) or for single-page writes. Sub-PAGE_SIZE writes are unchanged: the merge path handles them without needing a fresh page. The prealloc array, its count, and the helpers operating on it live in struct anon_pipe_prealloc: - anon_pipe_get_page_prealloc() issues a single alloc_pages_bulk() call before the mutex is taken. - anon_pipe_free_pages() runs after mutex_unlock() and put_page()s any leftover prealloc pages, keeping put_page() off the critical section. Leftovers happen when the per-pipe tmp_page[] cache or the merge path covered some of what we preallocated, or when the write exits early. This can improve the pipe throughput up to 48% and reduce the latency in 33%. Suggested-by: Mateusz Guzik <mjguzik@gmail.com> Signed-off-by: Breno Leitao <leitao@debian.org> diff --git a/fs/pipe.c b/fs/pipe.c index 9841648c9cf3e..a6441c44f1e3f 100644 --- a/fs/pipe.c +++ b/fs/pipe.c @@ -111,7 +111,32 @@ void pipe_double_lock(struct pipe_inode_info *pipe1, pipe_lock(pipe2); } -static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) +#define PIPE_PREALLOC_MAX 8 + +struct anon_pipe_prealloc { + struct page *pages[PIPE_PREALLOC_MAX]; + unsigned int count; +}; + +/* + * Bulk pre-allocate pages outside pipe->mutex. Callers must gate on + * total_len > PAGE_SIZE; single-page writes are handled by the in-lock + * alloc_page() fallback. Any pages not consumed by the write loop are + * freed by anon_pipe_free_pages() after the mutex is dropped. + */ +static void anon_pipe_get_page_prealloc(struct anon_pipe_prealloc *prealloc, + size_t total_len) +{ + unsigned int want = min_t(unsigned int, + DIV_ROUND_UP(total_len, PAGE_SIZE), + PIPE_PREALLOC_MAX); + + prealloc->count = alloc_pages_bulk(GFP_HIGHUSER | __GFP_ACCOUNT, + want, prealloc->pages); +} + +static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe, + struct anon_pipe_prealloc *prealloc) { for (int i = 0; i < ARRAY_SIZE(pipe->tmp_page); i++) { if (pipe->tmp_page[i]) { @@ -121,6 +146,15 @@ static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) } } + if (prealloc->count) { + unsigned int idx = prealloc->count - 1; + struct page *page = prealloc->pages[idx]; + + prealloc->pages[idx] = NULL; + prealloc->count = idx; + return page; + } + return alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); } @@ -139,6 +173,17 @@ static void anon_pipe_put_page(struct pipe_inode_info *pipe, put_page(page); } +/* Called after pipe->mutex is dropped, to keep put_page() off the critical section. */ +static void anon_pipe_free_pages(struct anon_pipe_prealloc *prealloc) +{ + while (prealloc->count) { + unsigned int idx = prealloc->count - 1; + + put_page(prealloc->pages[idx]); + prealloc->count = idx; + } +} + static void anon_pipe_buf_release(struct pipe_inode_info *pipe, struct pipe_buffer *buf) { @@ -438,6 +483,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) ssize_t chars; bool was_empty = false; bool wake_next_writer = false; + struct anon_pipe_prealloc prealloc = {}; /* * Reject writing to watch queue pipes before the point where we lock @@ -455,6 +501,18 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) if (unlikely(total_len == 0)) return 0; + /* + * Bulk pre-allocate pages outside pipe->mutex for writes that span more + * than one full page. alloc_page() with GFP_HIGHUSER may sleep doing + * reclaim and runs memcg charging, so doing it under the mutex + * extends the critical section and stalls the reader. The merge path + * handles sub-PAGE_SIZE writes without a fresh page; single-page and + * >PIPE_PREALLOC_MAX-page writes fall back to alloc_page() under the + * mutex for the remainder. + */ + if (total_len > PAGE_SIZE) + anon_pipe_get_page_prealloc(&prealloc, total_len); + mutex_lock(&pipe->mutex); if (!pipe->readers) { @@ -512,7 +570,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) struct page *page; int copied; - page = anon_pipe_get_page(pipe); + page = anon_pipe_get_page(pipe, &prealloc); if (unlikely(!page)) { if (!ret) ret = -ENOMEM; @@ -579,6 +637,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) if (pipe_is_full(pipe)) wake_next_writer = false; mutex_unlock(&pipe->mutex); + anon_pipe_free_pages(&prealloc); /* * If we do do a wakeup event, we do a 'sync' wakeup, because we ^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-21 15:38 ` Breno Leitao @ 2026-05-21 17:26 ` Mateusz Guzik 2026-05-22 12:10 ` Breno Leitao 0 siblings, 1 reply; 9+ messages in thread From: Mateusz Guzik @ 2026-05-21 17:26 UTC (permalink / raw) To: Breno Leitao Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team On Thu, May 21, 2026 at 5:38 PM Breno Leitao <leitao@debian.org> wrote: > > Also, I am not reusing the leftover pages back to pipe->tmp_page, given > I really don't expect to have much leftovers, but, I am happy to do so, > in case you think this would be useful. > Since on the allocation side you do take from tmp_page, a big write ends up depleting it, afterwards you free up whatever preallocated pages. If this is followed by a small write, that small write now has to suffer allocation *with* the lock held, bringing the original problem. Easiest way out is to take from preallocated pages before looking at tmp_page. I do suspect the best way forward in the long run is repopulate the tmp_page array if you have pages to do so. > commit 2394068de8ee0ab9e582ce9776990ed695397553 > Author: Breno Leitao <leitao@debian.org> > Date: Fri May 15 01:35:13 2026 -0700 > > fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write > > anon_pipe_write() takes pipe->mutex and then, from the per-iteration > anon_pipe_get_page() helper, used to call alloc_page(GFP_HIGHUSER | > __GFP_ACCOUNT) once per page while still holding it. That allocation > can sleep doing direct reclaim and/or runs memcg charging, which extends > the critical section and stalls a concurrent reader on the very same > mutex. > > For writes that span more than one full page, bulk pre-allocate up to > PIPE_PREALLOC_MAX (8) pages with alloc_pages_bulk() before taking > pipe->mutex. anon_pipe_get_page() consumes from pipe->tmp_page[] first, > then from the prealloc array, and only falls back to alloc_page() under > the mutex for writes larger than PIPE_PREALLOC_MAX pages (rare) or for > single-page writes. Sub-PAGE_SIZE writes are unchanged: the merge path > handles them without needing a fresh page. > > The prealloc array, its count, and the helpers operating on it live in > struct anon_pipe_prealloc: > > - anon_pipe_get_page_prealloc() issues a single alloc_pages_bulk() > call before the mutex is taken. > - anon_pipe_free_pages() runs after mutex_unlock() and put_page()s > any leftover prealloc pages, keeping put_page() off the critical > section. Leftovers happen when the per-pipe tmp_page[] cache or the > merge path covered some of what we preallocated, or when the write > exits early. > > This can improve the pipe throughput up to 48% and reduce the latency in > 33%. > > Suggested-by: Mateusz Guzik <mjguzik@gmail.com> I have not suggested preallocation, so this credit is not warranted. > Signed-off-by: Breno Leitao <leitao@debian.org> > > diff --git a/fs/pipe.c b/fs/pipe.c > index 9841648c9cf3e..a6441c44f1e3f 100644 > --- a/fs/pipe.c > +++ b/fs/pipe.c > @@ -111,7 +111,32 @@ void pipe_double_lock(struct pipe_inode_info *pipe1, > pipe_lock(pipe2); > } > > -static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) > +#define PIPE_PREALLOC_MAX 8 > + > +struct anon_pipe_prealloc { > + struct page *pages[PIPE_PREALLOC_MAX]; > + unsigned int count; > +}; > + > +/* > + * Bulk pre-allocate pages outside pipe->mutex. Callers must gate on > + * total_len > PAGE_SIZE; single-page writes are handled by the in-lock > + * alloc_page() fallback. Any pages not consumed by the write loop are > + * freed by anon_pipe_free_pages() after the mutex is dropped. > + */ > +static void anon_pipe_get_page_prealloc(struct anon_pipe_prealloc *prealloc, > + size_t total_len) > +{ > + unsigned int want = min_t(unsigned int, > + DIV_ROUND_UP(total_len, PAGE_SIZE), > + PIPE_PREALLOC_MAX); > + > + prealloc->count = alloc_pages_bulk(GFP_HIGHUSER | __GFP_ACCOUNT, > + want, prealloc->pages); > +} > + > +static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe, > + struct anon_pipe_prealloc *prealloc) > { > for (int i = 0; i < ARRAY_SIZE(pipe->tmp_page); i++) { > if (pipe->tmp_page[i]) { > @@ -121,6 +146,15 @@ static struct page *anon_pipe_get_page(struct pipe_inode_info *pipe) > } > } > > + if (prealloc->count) { > + unsigned int idx = prealloc->count - 1; > + struct page *page = prealloc->pages[idx]; > + > + prealloc->pages[idx] = NULL; > + prealloc->count = idx; > + return page; > + } I would drop the idx var. *maybe* swap macro can help here > + > return alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); > } > > @@ -139,6 +173,17 @@ static void anon_pipe_put_page(struct pipe_inode_info *pipe, > put_page(page); > } > > +/* Called after pipe->mutex is dropped, to keep put_page() off the critical section. */ > +static void anon_pipe_free_pages(struct anon_pipe_prealloc *prealloc) > +{ > + while (prealloc->count) { > + unsigned int idx = prealloc->count - 1; > + > + put_page(prealloc->pages[idx]); > + prealloc->count = idx; > + } > +} > + > static void anon_pipe_buf_release(struct pipe_inode_info *pipe, > struct pipe_buffer *buf) > { > @@ -438,6 +483,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > ssize_t chars; > bool was_empty = false; > bool wake_next_writer = false; > + struct anon_pipe_prealloc prealloc = {}; > I forgot this bit is going to be a problem with gcc. I verified it emits rep stosq in place, which is going to completely unnecessarily slow things down especially on older uarchs. This is a known bug with gcc doing terrible job optimizing this. The problem will be avoided by merely initializing the count to 0. which looks kind of ugly if done here, but see below. > /* > * Reject writing to watch queue pipes before the point where we lock > @@ -455,6 +501,18 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > if (unlikely(total_len == 0)) > return 0; > > + /* > + * Bulk pre-allocate pages outside pipe->mutex for writes that span more > + * than one full page. alloc_page() with GFP_HIGHUSER may sleep doing > + * reclaim and runs memcg charging, so doing it under the mutex > + * extends the critical section and stalls the reader. The merge path > + * handles sub-PAGE_SIZE writes without a fresh page; single-page and > + * >PIPE_PREALLOC_MAX-page writes fall back to alloc_page() under the > + * mutex for the remainder. > + */ > + if (total_len > PAGE_SIZE) > + anon_pipe_get_page_prealloc(&prealloc, total_len); > + I don't think this comment belongs here, it should move above the prealloc routine. How about this: anon_pipe_get_page_prealloc gets called unconditionally and expects an uninitialized prealloc struct. For total_len > PAGE_SIZE you roll with the current code. Otherwise you just set ->count to 0, which prevents the ->pages array from being looked at. You can even pre-set to 0 on entry, just don't memset the entire obj. > mutex_lock(&pipe->mutex); > > if (!pipe->readers) { > @@ -512,7 +570,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > struct page *page; > int copied; > > - page = anon_pipe_get_page(pipe); > + page = anon_pipe_get_page(pipe, &prealloc); > if (unlikely(!page)) { > if (!ret) > ret = -ENOMEM; > @@ -579,6 +637,7 @@ anon_pipe_write(struct kiocb *iocb, struct iov_iter *from) > if (pipe_is_full(pipe)) > wake_next_writer = false; > mutex_unlock(&pipe->mutex); > + anon_pipe_free_pages(&prealloc); > > /* > * If we do do a wakeup event, we do a 'sync' wakeup, because we ^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write 2026-05-21 17:26 ` Mateusz Guzik @ 2026-05-22 12:10 ` Breno Leitao 0 siblings, 0 replies; 9+ messages in thread From: Breno Leitao @ 2026-05-22 12:10 UTC (permalink / raw) To: Mateusz Guzik Cc: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan, linux-fsdevel, linux-kernel, linux-kselftest, usama.arif, kernel-team On Thu, May 21, 2026 at 07:26:48PM +0200, Mateusz Guzik wrote: > On Thu, May 21, 2026 at 5:38 PM Breno Leitao <leitao@debian.org> wrote: > > I do suspect the best way forward in the long run is repopulate the > tmp_page array if you have pages to do so. Ack! > > + struct anon_pipe_prealloc prealloc = {}; > > > > I forgot this bit is going to be a problem with gcc. I verified it > emits rep stosq in place, which is going to completely unnecessarily > slow things down especially on older uarchs. This is a known bug with > gcc doing terrible job optimizing this. > > The problem will be avoided by merely initializing the count to 0. > which looks kind of ugly if done here, but see below. Ack, I can do it inside anon_pipe_get_page_prealloc() static void anon_pipe_get_page_prealloc(struct anon_pipe_prealloc *prealloc, size_t total_len) { prealloc->count = 0; if (total_len <= PAGE_SIZE) return; ... } > > + /* > > + * Bulk pre-allocate pages outside pipe->mutex for writes that span more > > + * than one full page. alloc_page() with GFP_HIGHUSER may sleep doing > > + * reclaim and runs memcg charging, so doing it under the mutex > > + * extends the critical section and stalls the reader. The merge path > > + * handles sub-PAGE_SIZE writes without a fresh page; single-page and > > + * >PIPE_PREALLOC_MAX-page writes fall back to alloc_page() under the > > + * mutex for the remainder. > > + */ > > + if (total_len > PAGE_SIZE) > > + anon_pipe_get_page_prealloc(&prealloc, total_len); > > + > > I don't think this comment belongs here, it should move above the > prealloc routine. > > How about this: anon_pipe_get_page_prealloc gets called > unconditionally and expects an uninitialized prealloc struct. For > total_len > PAGE_SIZE you roll with the current code. Otherwise you > just set ->count to 0, which prevents the ->pages array from being > looked at. You can even pre-set to 0 on entry, just don't memset the > entire obj. Thanks, let me respin a v2. --breno ^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH 2/2] selftests/pipe: add pipe_bench microbenchmark 2026-05-15 10:28 [PATCH 0/2] fs/pipe: reduce pipe->mutex contention by bulk-allocating outside the lock Breno Leitao 2026-05-15 10:28 ` [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write Breno Leitao @ 2026-05-15 10:28 ` Breno Leitao 1 sibling, 0 replies; 9+ messages in thread From: Breno Leitao @ 2026-05-15 10:28 UTC (permalink / raw) To: Alexander Viro, Christian Brauner, Jan Kara, Shuah Khan Cc: linux-fsdevel, linux-kernel, linux-kselftest, Breno Leitao, usama.arif, kernel-team Add a small selftest that stresses pipe->mutex contention by spawning N writer threads that hammer a single pipe with multi-page writes, plus M reader threads that drain. Each writer records its own write() latency samples into a log2-bucketed histogram; main aggregates and prints total writes, throughput, average and percentile (p50/p99/p99.9) latencies, and the maximum observed latency. This was used to validate "fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write". By default it sweeps over writers={1,2,5} x readers={1,5,10} using 64KB writes for 3s on a 1 MB pipe (~27s total); -w/-r switch to a single configuration and -s/-d/-p tune msgsize/duration/pipe size. Output is one-line-per-metric with a "---" separator between configurations so two runs (e.g. baseline vs patched) can be diffed directly. Pass --memory-pressure to fork stress-ng (--vm 4 --vm-bytes 80% --vm-method all) for the duration of the run, so alloc_page() in anon_pipe_write() routinely hits direct reclaim. The flag fails fast if stress-ng is not on $PATH. The program exits 0 on a clean run and reports its results to stdout, so it integrates with the kselftest framework via TEST_GEN_PROGS. Signed-off-by: Breno Leitao <leitao@debian.org> --- tools/testing/selftests/Makefile | 1 + tools/testing/selftests/pipe/.gitignore | 1 + tools/testing/selftests/pipe/Makefile | 9 + tools/testing/selftests/pipe/pipe_bench.c | 351 ++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+) diff --git a/tools/testing/selftests/Makefile b/tools/testing/selftests/Makefile index 6e59b8f63e416..bcd9db9d292ca 100644 --- a/tools/testing/selftests/Makefile +++ b/tools/testing/selftests/Makefile @@ -91,6 +91,7 @@ TARGETS += pcie_bwctrl TARGETS += perf_events TARGETS += pidfd TARGETS += pid_namespace +TARGETS += pipe TARGETS += power_supply TARGETS += powerpc TARGETS += prctl diff --git a/tools/testing/selftests/pipe/.gitignore b/tools/testing/selftests/pipe/.gitignore new file mode 100644 index 0000000000000..20b549361a152 --- /dev/null +++ b/tools/testing/selftests/pipe/.gitignore @@ -0,0 +1 @@ +pipe_bench diff --git a/tools/testing/selftests/pipe/Makefile b/tools/testing/selftests/pipe/Makefile new file mode 100644 index 0000000000000..1810c680117b3 --- /dev/null +++ b/tools/testing/selftests/pipe/Makefile @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (c) 2026 Meta Platforms, Inc. and affiliates +# Copyright (c) 2026 Breno Leitao <leitao@debian.org> + +CFLAGS += -O2 -Wall -Wextra -pthread + +TEST_GEN_PROGS := pipe_bench + +include ../lib.mk diff --git a/tools/testing/selftests/pipe/pipe_bench.c b/tools/testing/selftests/pipe/pipe_bench.c new file mode 100644 index 0000000000000..4b4ee6c8c0ced --- /dev/null +++ b/tools/testing/selftests/pipe/pipe_bench.c @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * pipe_bench - exercise pipe->mutex contention under concurrent writers. + * + * N writer threads hammer a single pipe with multi-page writes; M reader + * threads drain it. Each writer records its own write() latency histogram. + * Multi-page writes (msgsize >= PAGE_SIZE) force the loop in + * anon_pipe_write() to call alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT) under + * pipe->mutex, which is the critical section the patch shrinks. + * + * By default the benchmark sweeps writers in {1, 2, 5} x readers in + * {1, 5, 10} and prints one block per configuration so two runs (e.g. + * baseline vs patched) can be diffed directly. Pass -w and -r to run a + * single configuration instead. Pass --memory-pressure to spawn stress-ng + * alongside the sweep so the per-page alloc_page() path under pipe->mutex + * has to dip into reclaim. + * + * Copyright (c) 2026 Meta Platforms, Inc. and affiliates + * Copyright (c) 2026 Breno Leitao <leitao@debian.org> + */ + +#define _GNU_SOURCE +#include <errno.h> +#include <fcntl.h> +#include <getopt.h> +#include <pthread.h> +#include <signal.h> +#include <stdatomic.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) +#define HIST_BUCKETS 32 + +static size_t g_msgsize = 16 * 4096; +static int g_duration = 3; +static int g_pipe_size = 1024 * 1024; +static int g_memory_pressure; + +static atomic_int g_stop; +static int g_pipe[2]; + +struct wstats { + uint64_t writes; + uint64_t bytes; + uint64_t lat_sum_ns; + uint64_t lat_max_ns; + uint64_t lat_hist[HIST_BUCKETS]; +}; + +static inline uint64_t now_ns(void) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + ts.tv_nsec; +} + +static inline int log2_bucket(uint64_t v) +{ + int b = 0; + + if (!v) + return 0; + while (v >>= 1) + b++; + return b < HIST_BUCKETS ? b : HIST_BUCKETS - 1; +} + +static void *writer(void *arg) +{ + struct wstats *s = arg; + char *buf = aligned_alloc(4096, g_msgsize); + + if (!buf) + return NULL; + memset(buf, 0xAA, g_msgsize); + + while (!atomic_load_explicit(&g_stop, memory_order_relaxed)) { + uint64_t t0 = now_ns(); + ssize_t n = write(g_pipe[1], buf, g_msgsize); + uint64_t dt = now_ns() - t0; + + if (n > 0) { + s->writes++; + s->bytes += n; + s->lat_sum_ns += dt; + if (dt > s->lat_max_ns) + s->lat_max_ns = dt; + s->lat_hist[log2_bucket(dt)]++; + } else if (n < 0 && errno == EPIPE) { + break; + } + } + free(buf); + return NULL; +} + +static void *reader(void *arg) +{ + char *buf = aligned_alloc(4096, g_msgsize); + + (void)arg; + if (!buf) + return NULL; + /* + * Drain until EOF (write end closed by main). g_stop is not checked + * here on purpose: writers may be blocked in write() with the pipe + * full when g_stop is set, so the reader must keep draining until + * main closes the write end. + */ + for (;;) { + ssize_t n = read(g_pipe[0], buf, g_msgsize); + + if (n <= 0) + break; + } + free(buf); + return NULL; +} + +static void summarize(struct wstats *all, int nw, int nr) +{ + uint64_t total_writes = 0, total_bytes = 0, total_lat = 0; + uint64_t max_lat = 0; + uint64_t agg[HIST_BUCKETS] = {0}; + uint64_t p50_target, p99_target, p999_target; + uint64_t cum = 0, p50 = 0, p99 = 0, p999 = 0; + uint64_t avg_ns; + double sec; + + for (int i = 0; i < nw; i++) { + total_writes += all[i].writes; + total_bytes += all[i].bytes; + total_lat += all[i].lat_sum_ns; + if (all[i].lat_max_ns > max_lat) + max_lat = all[i].lat_max_ns; + for (int b = 0; b < HIST_BUCKETS; b++) + agg[b] += all[i].lat_hist[b]; + } + + p50_target = total_writes * 50 / 100; + p99_target = total_writes * 99 / 100; + p999_target = total_writes * 999 / 1000; + + for (int b = 0; b < HIST_BUCKETS; b++) { + cum += agg[b]; + if (!p50 && cum >= p50_target) + p50 = 1ULL << b; + if (!p99 && cum >= p99_target) + p99 = 1ULL << b; + if (!p999 && cum >= p999_target) + p999 = 1ULL << b; + } + + sec = g_duration; + avg_ns = total_writes ? total_lat / total_writes : 0; + + printf("config: writers=%d readers=%d msgsize=%zu duration=%d pipe_size=%d memory_pressure=%s\n", + nw, nr, g_msgsize, g_duration, g_pipe_size, + g_memory_pressure ? "yes" : "no"); + printf("writes: total=%llu rate=%.0f/s\n", + (unsigned long long)total_writes, total_writes / sec); + printf("throughput_MBps: %.2f\n", + (total_bytes / sec) / (1024.0 * 1024.0)); + printf("lat_avg_ns: %llu\n", (unsigned long long)avg_ns); + printf("lat_p50_ns_upper: %llu\n", (unsigned long long)p50); + printf("lat_p99_ns_upper: %llu\n", (unsigned long long)p99); + printf("lat_p999_ns_upper: %llu\n", (unsigned long long)p999); + printf("lat_max_ns: %llu\n", (unsigned long long)max_lat); +} + +static pid_t spawn_stress_ng(void) +{ + pid_t pid = fork(); + + if (pid < 0) { + perror("fork"); + return -1; + } + if (pid == 0) { + execlp("stress-ng", "stress-ng", + "--vm", "4", "--vm-bytes", "80%", + "--vm-method", "all", + (char *)NULL); + fprintf(stderr, "exec stress-ng failed: %s\n", + strerror(errno)); + _exit(127); + } + /* Give stress-ng a moment to map its VM regions before measuring. */ + sleep(1); + return pid; +} + +static void kill_stress_ng(pid_t pid) +{ + int status; + + if (pid <= 0) + return; + kill(pid, SIGTERM); + for (int i = 0; i < 20; i++) { + if (waitpid(pid, &status, WNOHANG) > 0) + return; + usleep(100 * 1000); + } + kill(pid, SIGKILL); + waitpid(pid, &status, 0); +} + +static int run_one(int nw, int nr) +{ + pthread_t *wt, *rt; + struct wstats *ws; + + atomic_store(&g_stop, 0); + + if (pipe(g_pipe) < 0) { + perror("pipe"); + return -1; + } + if (fcntl(g_pipe[1], F_SETPIPE_SZ, g_pipe_size) < 0) + perror("F_SETPIPE_SZ (continuing)"); + + wt = calloc(nw, sizeof(*wt)); + rt = calloc(nr, sizeof(*rt)); + ws = calloc(nw, sizeof(*ws)); + + if (!wt || !rt || !ws) { + fprintf(stderr, "alloc failed\n"); + free(wt); + free(rt); + free(ws); + close(g_pipe[0]); + close(g_pipe[1]); + return -1; + } + + for (int i = 0; i < nr; i++) + pthread_create(&rt[i], NULL, reader, NULL); + for (int i = 0; i < nw; i++) + pthread_create(&wt[i], NULL, writer, &ws[i]); + + sleep(g_duration); + atomic_store(&g_stop, 1); + + /* + * Close write end first so any writer blocked in write() gets EPIPE + * and exits, and so the readers see EOF after draining. + */ + close(g_pipe[1]); + for (int i = 0; i < nw; i++) + pthread_join(wt[i], NULL); + for (int i = 0; i < nr; i++) + pthread_join(rt[i], NULL); + close(g_pipe[0]); + + summarize(ws, nw, nr); + fflush(stdout); + + free(wt); + free(rt); + free(ws); + return 0; +} + +int main(int argc, char **argv) +{ + static const int writers_sweep[] = {1, 2, 5}; + static const int readers_sweep[] = {1, 5, 10}; + static const struct option long_opts[] = { + {"memory-pressure", no_argument, NULL, 'M'}, + {0, 0, 0, 0}, + }; + int writers_override = 0, readers_override = 0; + pid_t stress_pid = -1; + int rc = 0, opt; + + while ((opt = getopt_long(argc, argv, "w:r:s:d:p:", + long_opts, NULL)) != -1) { + switch (opt) { + case 'w': + writers_override = atoi(optarg); + break; + case 'r': + readers_override = atoi(optarg); + break; + case 's': + g_msgsize = atol(optarg); + break; + case 'd': + g_duration = atoi(optarg); + break; + case 'p': + g_pipe_size = atoi(optarg); + break; + case 'M': + g_memory_pressure = 1; + break; + default: + fprintf(stderr, + "usage: %s [-w writers] [-r readers] [-s msgsize] [-d secs] [-p pipe_size] [--memory-pressure]\n" + " default: sweep writers={1,2,5} x readers={1,5,10}\n" + " --memory-pressure: spawn stress-ng (--vm 4 --vm-bytes 80%% --vm-method all) for the run\n", + argv[0]); + return 1; + } + } + + signal(SIGPIPE, SIG_IGN); + setvbuf(stdout, NULL, _IOLBF, 0); + setvbuf(stderr, NULL, _IOLBF, 0); + + fprintf(stderr, "pid=%d\n", getpid()); + fflush(stderr); + + if (g_memory_pressure) { + stress_pid = spawn_stress_ng(); + if (stress_pid < 0) { + fprintf(stderr, + "memory_pressure requested but stress-ng could not be spawned\n"); + return 1; + } + } + + if (writers_override > 0 || readers_override > 0) { + int nw = writers_override > 0 ? writers_override : 1; + int nr = readers_override > 0 ? readers_override : 1; + + rc = run_one(nw, nr) < 0 ? 1 : 0; + goto out; + } + + for (size_t i = 0; i < ARRAY_SIZE(writers_sweep); i++) { + for (size_t j = 0; j < ARRAY_SIZE(readers_sweep); j++) { + printf("---\n"); + if (run_one(writers_sweep[i], readers_sweep[j]) < 0) { + rc = 1; + goto out; + } + } + } +out: + kill_stress_ng(stress_pid); + return rc; +} -- 2.53.0-Meta ^ permalink raw reply related [flat|nested] 9+ messages in thread
end of thread, other threads:[~2026-05-22 12:10 UTC | newest] Thread overview: 9+ messages (download: mbox.gz follow: Atom feed -- links below jump to the message on this page -- 2026-05-15 10:28 [PATCH 0/2] fs/pipe: reduce pipe->mutex contention by bulk-allocating outside the lock Breno Leitao 2026-05-15 10:28 ` [PATCH 1/2] fs/pipe: bulk pre-allocate pages outside pipe->mutex in anon_pipe_write Breno Leitao 2026-05-15 20:18 ` Mateusz Guzik 2026-05-20 17:09 ` Breno Leitao 2026-05-21 12:58 ` Mateusz Guzik 2026-05-21 15:38 ` Breno Leitao 2026-05-21 17:26 ` Mateusz Guzik 2026-05-22 12:10 ` Breno Leitao 2026-05-15 10:28 ` [PATCH 2/2] selftests/pipe: add pipe_bench microbenchmark Breno Leitao
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox