* [PATCH v2 1/4] mm: zswap: fix shrinker count for the root memcg
2026-11-10 1:53 [PATCH v2 0/4] mm: read root memcg stats from global counters instead of flushing Joanne Koong
@ 2026-11-10 1:53 ` Joanne Koong
2026-11-12 16:43 ` Johannes Weiner
2026-11-10 1:53 ` [PATCH v2 2/4] mm: memcg: read root lruvec stats from the node counters Joanne Koong
` (2 subsequent siblings)
3 siblings, 1 reply; 6+ messages in thread
From: Joanne Koong @ 2026-11-10 1:53 UTC (permalink / raw)
To: akpm
Cc: yosry, shakeel.butt, hannes, mhocko, roman.gushchin, muchun.song,
nphamcs, chengming.zhou, sj, david, ljs, linux-mm, cgroups, damon
zswap_shrinker_count() scales the number of freeable entries on a
memcg's zswap LRU by the memcg's compression ratio. That compression
ratio is computed from the memcg's MEMCG_ZSWAP_B and MEMCG_ZSWAPPED
stats.
However, for the root memcg, the zswap entries owned by the root memcg
itself are never accounted for in the stats (obj_cgroup_charge_zswap()
skips the root objcg, and before the root memcg had an objcg, its
entries weren't charged at all). The stats only reflect the entries
owned by the root memcg's descendants. When the root memcg's descendants
have no zswap entries (eg when the whole workload runs in the root
cgroup), the shrinker reports nothing to reclaim for the root memcg, and
its zswap entries are never written back by the shrinker. When its
descendants do have entries, the root memcg's entries are scaled by the
descendants' compression ratio instead of a ratio that includes them.
Fix this by using the zswap pool-wide counters for the root memcg
instead. The zswap pool-wide counters cover every zswap entry, including
those owned by the root memcg.
This also gets rid of having to flush the root memcg's stats, which
flushes every cgroup in the system.
In a VM running a reclaim-heavy workload in the root cgroup with the
zswap shrinker enabled, the shrinker wrote back no pages before this
change (zswpwb stayed at 0) and 1016 pages with it.
Fixes: b5ba474f3f51 ("zswap: shrink zswap pool based on memory pressure")
Acked-by: Yosry Ahmed <yosry@kernel.org>
Reviewed-by: SJ Park <sj@kernel.org>
Acked-by: Nhat Pham <nphamcs@gmail.com>
Signed-off-by: Joanne Koong <joannelkoong@gmail.com>
---
mm/zswap.c | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/mm/zswap.c b/mm/zswap.c
index 37d217860a6b..c11cd2a6e24e 100644
--- a/mm/zswap.c
+++ b/mm/zswap.c
@@ -1332,9 +1332,12 @@ static unsigned long zswap_shrinker_count(struct shrinker *shrinker,
* runtime-disabled: we can get sc->memcg == NULL, which is ok
* for the lruvec, but not for memcg_page_state().
*
- * Without memcg, use the zswap pool-wide metrics.
+ * Without memcg, or for the root memcg, use the zswap pool-wide
+ * metrics. The root memcg's stats don't count the entries it owns
+ * itself (obj_cgroup_charge_zswap() skips the root objcg), whereas the
+ * pool-wide metrics cover every entry and don't need a flush.
*/
- if (!mem_cgroup_disabled()) {
+ if (!mem_cgroup_disabled() && !mem_cgroup_is_root(memcg)) {
mem_cgroup_flush_stats_ratelimited(memcg);
nr_backing = memcg_page_state(memcg, MEMCG_ZSWAP_B) >> PAGE_SHIFT;
nr_stored = memcg_page_state(memcg, MEMCG_ZSWAPPED);
--
2.52.0
^ permalink raw reply related [flat|nested] 6+ messages in thread* Re: [PATCH v2 1/4] mm: zswap: fix shrinker count for the root memcg
2026-11-10 1:53 ` [PATCH v2 1/4] mm: zswap: fix shrinker count for the root memcg Joanne Koong
@ 2026-11-12 16:43 ` Johannes Weiner
0 siblings, 0 replies; 6+ messages in thread
From: Johannes Weiner @ 2026-11-12 16:43 UTC (permalink / raw)
To: Joanne Koong
Cc: akpm, yosry, shakeel.butt, mhocko, roman.gushchin, muchun.song,
nphamcs, chengming.zhou, sj, david, ljs, linux-mm, cgroups, damon
On Mon, Nov 09, 2026 at 05:53:18PM -0800, Joanne Koong wrote:
> zswap_shrinker_count() scales the number of freeable entries on a
> memcg's zswap LRU by the memcg's compression ratio. That compression
> ratio is computed from the memcg's MEMCG_ZSWAP_B and MEMCG_ZSWAPPED
> stats.
>
> However, for the root memcg, the zswap entries owned by the root memcg
> itself are never accounted for in the stats (obj_cgroup_charge_zswap()
> skips the root objcg, and before the root memcg had an objcg, its
> entries weren't charged at all). The stats only reflect the entries
> owned by the root memcg's descendants. When the root memcg's descendants
> have no zswap entries (eg when the whole workload runs in the root
> cgroup), the shrinker reports nothing to reclaim for the root memcg, and
> its zswap entries are never written back by the shrinker. When its
> descendants do have entries, the root memcg's entries are scaled by the
> descendants' compression ratio instead of a ratio that includes them.
>
> Fix this by using the zswap pool-wide counters for the root memcg
> instead. The zswap pool-wide counters cover every zswap entry, including
> those owned by the root memcg.
>
> This also gets rid of having to flush the root memcg's stats, which
> flushes every cgroup in the system.
>
> In a VM running a reclaim-heavy workload in the root cgroup with the
> zswap shrinker enabled, the shrinker wrote back no pages before this
> change (zswpwb stayed at 0) and 1016 pages with it.
>
> Fixes: b5ba474f3f51 ("zswap: shrink zswap pool based on memory pressure")
> Acked-by: Yosry Ahmed <yosry@kernel.org>
> Reviewed-by: SJ Park <sj@kernel.org>
> Acked-by: Nhat Pham <nphamcs@gmail.com>
> Signed-off-by: Joanne Koong <joannelkoong@gmail.com>
Reviewed-by: Johannes Weiner <hannes@cmpxchg.org>
^ permalink raw reply [flat|nested] 6+ messages in thread
* [PATCH v2 2/4] mm: memcg: read root lruvec stats from the node counters
2026-11-10 1:53 [PATCH v2 0/4] mm: read root memcg stats from global counters instead of flushing Joanne Koong
2026-11-10 1:53 ` [PATCH v2 1/4] mm: zswap: fix shrinker count for the root memcg Joanne Koong
@ 2026-11-10 1:53 ` Joanne Koong
2026-11-10 1:53 ` [PATCH v2 3/4] mm: workingset: skip the root memcg flush in workingset_test_recent() Joanne Koong
2026-11-10 1:53 ` [PATCH v2 4/4] mm/damon/core: use node counters instead of flushing the root memcg Joanne Koong
3 siblings, 0 replies; 6+ messages in thread
From: Joanne Koong @ 2026-11-10 1:53 UTC (permalink / raw)
To: akpm
Cc: yosry, shakeel.butt, hannes, mhocko, roman.gushchin, muchun.song,
nphamcs, chengming.zhou, sj, david, ljs, linux-mm, cgroups, damon
For the root memcg, lruvec_page_state() returns the rstat value, which
needs a flush to be up to date. Flushing the root memcg is expensive, as
it flushes every memcg in the system.
Most memcg node stats only track memory charged to a memcg, so the root
memcg's hierarchical value equals the node counter, which is always up
to date and doesn't need a flush. For those stats, have
lruvec_page_state() and lruvec_page_state_monotonic() read the node
counter for the root memcg, as they already do when memcg is disabled.
Callers that only read the root memcg's own lruvec then no longer need
to flush it, which the following patches take advantage of. It also
makes the root memcg's stats more accurate, as callers that ratelimit
the flush can otherwise read values that are seconds old.
The exceptions are the kernel memory stats (slab, kernel stack, page
tables, secondary page tables and vmalloc), hugetlb, and
WORKINGSET_NODERECLAIM, which counts reclaimed xarray nodes (slab
objects). Their node counters also count memory that isn't charged to a
memcg, which falls into two categories:
a) the allocation isn't accounted to any memcg (eg kernel page tables,
or hugetlb when hugetlb accounting is off)
b) the allocation is accounted, but it comes from a task running in the
root cgroup, and the root memcg isn't charged for it
As such, these stats still need to read the rstat value, since the node
counter value can be higher than the rstat value.
One user-visible change is that the root memcg's memory.numa_stat (and
the hierarchical_* entries of the cgroup v1 memory.numa_stat) now
reports the node counters for the non-excluded stats, as it reads
lruvec_page_state(). This is more accurate, but as a result, it may not
exactly match the root memcg's memory.stat, which still reads the rstat
values.
Signed-off-by: Joanne Koong <joannelkoong@gmail.com>
---
mm/memcontrol.c | 52 ++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 49 insertions(+), 3 deletions(-)
diff --git a/mm/memcontrol.c b/mm/memcontrol.c
index 1ebc9330aeae..2575bf2ba0ea 100644
--- a/mm/memcontrol.c
+++ b/mm/memcontrol.c
@@ -389,7 +389,14 @@ ino_t page_cgroup_ino(const struct page *page)
}
EXPORT_SYMBOL_GPL(page_cgroup_ino);
-/* Subset of node_stat_item for memcg stats */
+/*
+ * Subset of node_stat_item for memcg stats.
+ *
+ * Stats whose node counters can also count memory that isn't charged to a memcg
+ * must be listed in node_stat_includes_unaccounted(). Otherwise,
+ * lruvec_page_state() returns the node counter for them for the root memcg,
+ * which can be larger than the root memcg's actual value.
+ */
static const unsigned int memcg_node_stat_items[] = {
NR_INACTIVE_ANON,
NR_ACTIVE_ANON,
@@ -504,13 +511,52 @@ struct lruvec_stats {
long state_pending[NR_MEMCG_NODE_STAT_ITEMS];
};
+/*
+ * Whether the node counter for @idx can also count memory that isn't charged
+ * to a memcg (eg kernel memory that isn't accounted). For these stats, the
+ * node counter can be larger than the root memcg's value, so the root memcg
+ * has to read its rstat value instead.
+ */
+static bool node_stat_includes_unaccounted(enum node_stat_item idx)
+{
+ switch (idx) {
+ case NR_SLAB_RECLAIMABLE_B:
+ case NR_SLAB_UNRECLAIMABLE_B:
+ case WORKINGSET_NODERECLAIM:
+ case NR_VMALLOC:
+ case NR_KERNEL_STACK_KB:
+ case NR_PAGETABLE:
+ case NR_SECONDARY_PAGETABLE:
+#ifdef CONFIG_HUGETLB_PAGE
+ case NR_HUGETLB:
+#endif
+ return true;
+ default:
+ return false;
+ }
+}
+
+/*
+ * Whether @lruvec should read the node counter for @idx instead of its rstat
+ * value. For the root memcg, stats that only count memory charged to a memcg
+ * have the same hierarchical value as the node counter. The node counter is
+ * preferred since it is always up to date, whereas the rstat value needs a
+ * flush, which for the root memcg flushes every cgroup in the system.
+ */
+static bool lruvec_use_node_stat(const struct lruvec *lruvec,
+ enum node_stat_item idx)
+{
+ return mem_cgroup_is_root(lruvec_memcg(lruvec)) &&
+ !node_stat_includes_unaccounted(idx);
+}
+
unsigned long lruvec_page_state(const struct lruvec *lruvec, enum node_stat_item idx)
{
const struct mem_cgroup_per_node *pn;
long x;
int i;
- if (mem_cgroup_disabled())
+ if (mem_cgroup_disabled() || lruvec_use_node_stat(lruvec, idx))
return node_page_state(lruvec_pgdat(lruvec), idx);
i = memcg_stats_index(idx);
@@ -551,7 +597,7 @@ unsigned long lruvec_page_state_monotonic(const struct lruvec *lruvec,
const struct mem_cgroup_per_node *pn;
int i;
- if (mem_cgroup_disabled())
+ if (mem_cgroup_disabled() || lruvec_use_node_stat(lruvec, idx))
return node_page_state_monotonic(lruvec_pgdat(lruvec), idx);
i = memcg_stats_index(idx);
--
2.52.0
^ permalink raw reply related [flat|nested] 6+ messages in thread* [PATCH v2 3/4] mm: workingset: skip the root memcg flush in workingset_test_recent()
2026-11-10 1:53 [PATCH v2 0/4] mm: read root memcg stats from global counters instead of flushing Joanne Koong
2026-11-10 1:53 ` [PATCH v2 1/4] mm: zswap: fix shrinker count for the root memcg Joanne Koong
2026-11-10 1:53 ` [PATCH v2 2/4] mm: memcg: read root lruvec stats from the node counters Joanne Koong
@ 2026-11-10 1:53 ` Joanne Koong
2026-11-10 1:53 ` [PATCH v2 4/4] mm/damon/core: use node counters instead of flushing the root memcg Joanne Koong
3 siblings, 0 replies; 6+ messages in thread
From: Joanne Koong @ 2026-11-10 1:53 UTC (permalink / raw)
To: akpm
Cc: yosry, shakeel.butt, hannes, mhocko, roman.gushchin, muchun.song,
nphamcs, chengming.zhou, sj, david, ljs, linux-mm, cgroups, damon
We can skip the rstat flush in workingset_test_recent() when the
eviction memcg is the root memcg. lruvec_page_state() now reads the LRU
sizes of the root memcg from the node counters, which are always up to
date, and workingset_test_recent() only reads the eviction memcg's own
lruvec. This avoids an expensive flush on the refault path, as flushing
the root memcg flushes every memcg in the system.
For reclaim however, we still need to keep the flush (for now) since
global and root reclaim also read the rstat stats of the child memcgs
they scan, which the flush is needed for in order to keep up to date.
On an 80-CPU machine, a root flush took 69us on average with little
cgroup activity and ~0.9ms with 128 memcgs actively allocating (wall
time, including waiting for the rstat lock). Reading a node counter
doesn't add any cost. It took the same amount of time as reading the
rstat counter (~150ns, which includes bpftrace probe overhead).
Signed-off-by: Joanne Koong <joannelkoong@gmail.com>
---
mm/workingset.c | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/mm/workingset.c b/mm/workingset.c
index 1504f91cdca5..383f951eaad4 100644
--- a/mm/workingset.c
+++ b/mm/workingset.c
@@ -483,10 +483,13 @@ bool workingset_test_recent(void *shadow, bool file, bool *workingset,
* section (for e.g, in cachestat) - these callers need to skip flushing
* stats (via the flush argument).
*
+ * For the root memcg, the LRU sizes are read directly from the node
+ * counters (see lruvec_page_state()).
+ *
* XXX: With per-memcg flushing and thresholding, is ratelimiting
* still needed here?
*/
- if (flush)
+ if (flush && !mem_cgroup_is_root(eviction_memcg))
mem_cgroup_flush_stats_ratelimited(eviction_memcg);
eviction_lruvec = mem_cgroup_lruvec(eviction_memcg, pgdat);
--
2.52.0
^ permalink raw reply related [flat|nested] 6+ messages in thread
* [PATCH v2 4/4] mm/damon/core: use node counters instead of flushing the root memcg
2026-11-10 1:53 [PATCH v2 0/4] mm: read root memcg stats from global counters instead of flushing Joanne Koong
` (2 preceding siblings ...)
2026-11-10 1:53 ` [PATCH v2 3/4] mm: workingset: skip the root memcg flush in workingset_test_recent() Joanne Koong
@ 2026-11-10 1:53 ` Joanne Koong
3 siblings, 0 replies; 6+ messages in thread
From: Joanne Koong @ 2026-11-10 1:53 UTC (permalink / raw)
To: akpm
Cc: yosry, shakeel.butt, hannes, mhocko, roman.gushchin, muchun.song,
nphamcs, chengming.zhou, sj, david, ljs, linux-mm, cgroups, damon
damos_get_node_memcg_used_bp() flushes the memcg stats before reading
the LRU sizes of the goal's memcg on the goal's node. For the root
memcg, that flushes every cgroup in the system. The flush is not
ratelimited, which means it happens every time the goal is evaluated.
lruvec_page_state() now reads the LRU sizes of the root memcg from the
node counters, which are always up to date, so skip the flush for the
root memcg.
Signed-off-by: Joanne Koong <joannelkoong@gmail.com>
---
mm/damon/core.c | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/mm/damon/core.c b/mm/damon/core.c
index 0a056949604f..14f2a36c1025 100644
--- a/mm/damon/core.c
+++ b/mm/damon/core.c
@@ -3057,7 +3057,12 @@ static unsigned long damos_get_node_memcg_used_bp(
return 10000;
}
- mem_cgroup_flush_stats(memcg);
+ /*
+ * The root memcg's LRU sizes are read directly from the node counters
+ * (see lruvec_page_state()).
+ */
+ if (!mem_cgroup_is_root(memcg))
+ mem_cgroup_flush_stats(memcg);
lruvec = mem_cgroup_lruvec(memcg, NODE_DATA(goal->nid));
used_pages = lruvec_page_state(lruvec, NR_ACTIVE_ANON);
used_pages += lruvec_page_state(lruvec, NR_INACTIVE_ANON);
--
2.52.0
^ permalink raw reply related [flat|nested] 6+ messages in thread