Re: [RFC PATCH] mm: vmscan: avoid anon scanning for GFP_NOIO with low swapcache
From: Bo Zhang
Date: Thu Sep 03 2026 - 22:09:01 EST
On Thu, Sep 03, 2026 at 09:03:04AM -0400, Johannes Weiner wrote:
> On Thu, Sep 03, 2026 at 12:01:31PM +0800, Bo Zhang wrote:
> > We have observed some cases where memory is allocated with GFP_NOIO, so
> > we cannot reclaim any anon folios unless they are in swapcache. We can
> > end up spending more than 150 ms looping in `shrink_folio_list()` scanning
> > non-swapcache folios without reclaiming a single folio. This is pure
> > overhead.
>
> Not entirely. There is some value in aging anon alongside file, so
> that the next __GFP_IO reclaimer doesn't look at a stale list.
You're right, "pure overhead" was too strong - aging anon does have
value for a later __GFP_IO reclaimer, and I don't intend to skip it in
general. Let me describe the case in full, because the reclaim cycle
itself already provides that aging on a later pass, which is what makes
me think the trade-off here leans the other way.
> Can you describe a bit more about what you observed? What workload is
> running, maybe you have a stack trace of which NOIO requests are
> routinely getting stuck in reclaim?
The workload is app launching on Android. The NOIO allocations come from
dm-verity hash-block reads via dm-bufio, which legitimately use GFP_NOIO
because they run underneath the IO path:
worker_thread
process_scheduled_works
verity_work
verity_verify_io
verity_hash_for_block
verity_verify_level
dm_bufio_read_with_ioprio
new_read
__bufio_new
alloc_buffer
gfp_mask: GFP_NOIO | __GFP_NORETRY | __GFP_NOMEMALLOC | __GFP_NOWARN
So the NOIO use itself is correct; the problem is on the reclaim side.
Here is the full picture of one such direct reclaim. It runs two rounds
of do_try_to_free_pages(); the target is 32 folios.
Round 1 - partial (shared) memcg walk, 169.20 ms, 0 folios reclaimed
--------------------------------------------------------------------
prio 12->1 (~1.3 ms):
cache_trim_mode is on, so get_scan_count() picks SCAN_FILE. Only the
file side is scanned. Because this is a shared/partial walk, each
priority only visits a handful of memcgs before the iterator is
handed off, so very few memcgs are looked at on the way down:
428 file folios scanned, 0 reclaimed.
prio 0 (~167.9 ms):
priority hits 0 without meeting the target, so get_scan_count()
forces SCAN_EQUAL. The walk lands on a single memcg with a large,
unswapped anon LRU and a tiny file LRU:
inactive_anon ~335 MB, inactive_file ~4 MB (~84:1)
memcg swap usage ~3.6 MB, so swapcache is negligible
shrink_lruvec() now keeps feeding that huge anon list into
shrink_folio_list() - ~2400 shrink_folio_list() calls, ~93,000 anon
folios scanned - and every folio hits the !__GFP_IO keep_locked path
(not in swapcache, needs a swap slot). This single shrink_lruvec()
pass alone is ~168 ms with 0 folios reclaimed.
Round 1 ends with nr_reclaimed = 0 < target, so reclaim retries with
sc->memcg_full_walk = 1.
Round 2 - full memcg walk, 2.37 ms, 68 folios reclaimed
-------------------------------------------------------
With memcg_full_walk = 1, priority resets to 12 and every priority
now visits the complete subtree (~78 memcgs). Progress is made
entirely from the file side:
prio 12: ... 0 reclaimed
prio 11: ... 0
prio 10: ... 3
prio 9: ... 8
prio 8: ... 18
prio 7: ... 38 -> cumulative 68 >= target 32, done
All 68 reclaimed folios come from file LRUs; anon contributes 0.
So the whole 168 ms is spent scanning an anon list that cannot yield a
single folio under GFP_NOIO, and the actual progress comes from file in
a fast full-walk round that follows.
> 150ms sounds awful indeed. Is this cumulative for a whole reclaim
> cycle or single shrink_folio_list() runs?
It is a single shrink_lruvec() invocation on that one memcg, as above -
not accumulated across the cycle. Each shrink_folio_list() only handles
SWAP_CLUSTER_MAX folios and is fast on its own; it's the prio-0 while
loop over the huge anon list that adds up to ~168 ms.
On the aging trade-off
----------------------
I take your point that this pass would otherwise have aged anon for the
next __GFP_IO reclaimer. But in this cycle that benefit is small and the
cost is large:
- The aging is not lost so much as deferred. Round 2 (and any later
__GFP_IO reclaimer) still walks the full subtree; anon that genuinely
needs IO to be reclaimed gets aged/reclaimed then, once IO is allowed.
- The 168 ms is spent scanning ~93k anon folios that, by construction of
GFP_NOIO + negligible swapcache, cannot be reclaimed on this pass at
all - the aging is the only product, and it comes at the price of a
~168 ms stall in a latency-sensitive path.
That's why I'd argue the balance tips towards skipping anon here rather
than aging it. To keep the change narrow, the check only triggers at
priority 0 (where SCAN_EQUAL is forced) and only when swapcache is far
below the anon LRU (below min(anon >> 6, SWAP_CLUSTER_MAX)), i.e. when
essentially no anon on the list is reclaimable without IO. Outside that
corner anon is scanned and aged exactly as before.
And skipping anon in this corner doesn't cost us the aging in practice:
with no reclaimable anon left to scan, Round 1 simply finishes quickly
and reclaim proceeds to the full-walk retry (Round 2), which resets to
priority 12 and walks the whole subtree - anon included - so anon still
gets aged there, just without the ~168 ms detour first.
Thanks,
Bo