Re: memcg stats flushing (WAS "Re: [PATCH] fuse: disable default bdi strictlimiting")

Yosry Ahmed <[email protected]>
Newsgroups org.kernel.vger.cgroups,dev.linux.lists.fuse-devel,org.kernel.vger.linux-fsdevel,org.kvack.linux-mm
Message-ID <CAO9r8zPLipVW6bvyDK1ypSawbo=LHKvmLqWH0xA6WMR9GYNJpg@mail.gmail.com>
On Wed, Aug 12, 2026 at 1:59 PM Joanne Koong <[email protected]> wrote:
>
> On Mon, Aug 10, 2026 at 5:48 PM Yosry Ahmed <[email protected]> wrote:
> >
> > On Mon, Aug 10, 2026 at 09:49:31AM -0700, Joanne Koong wrote:
> > > On Thu, Aug 6, 2026 at 2:31 PM Yosry Ahmed <[email protected]> wrote:
> > > >
> > > > On Thu, Aug 6, 2026 at 2:04 PM Joanne Koong <[email protected]> wrote:
> > > > >
> > > > > I'm not too familiar with memcg or vmstat so apologies if this is a
> > > > > naive question, but does it make sense for memcg to just do a similar
> > > > > approach to what vmstat does? As I understand it, mod_lruvec_state()
> > > > > updates the node counter and the memcg counter (if memcg applies),
> > > > > where for updating the node counter, mod_node_state() does some
> > > > > batching where it updates the global counter for that stat only once
> > > > > it crosses some threshold (pcp->stat_threshold). Could memcg keep its
> > > > > per-CPU counter as the accumulator but once it reaches some threshold,
> > > > > it then propagates it up the parent/ancestors into a per-memcg atomic
> > > > > global counter? The atomic would then only have to be touched once per
> > > > > batch than on every update, which might eliminate the overhead of the
> > > > > per-memcg atomic you saw previously? It seems like this would get rid
> > > > > of needing to do any flushing altogether, as we could just read that
> > > > > global counter directly.
> > > >
> > > > The memcg stats perform similar per-CPU counting and thresholding. The
> > > > main difference from vmstat AFAICT is the cgroup hierarchy. In vmstat
> > > > we have per-CPU counters and a global atomic counter for each stat.
> > > > For memcg, we have the same for each cgroup, then we also have a
> > > > cgroup tree. When we read the stats of a cgroup, we usually want the
> > > > hierarchical stats including all of its children, so we can't just
> > > > read the global counter.
> > > >
> > > > We use rstat, a cgroup framework that keeps track of which cgroups
> > > > have updates on which CPUs, and then memcg has thresholding logic on
> > > > top to only flush if the number of pending updates exceeds a
> > > > threshold. Essentially, if the magnitude updates on a CPU exceeds
> > > > MEMCG_CHARGE_BATCH, we add it to a global per-memcg counter. We only
> > > > flush if that global per-memcg counter has > NR_CPUS *
> > > > MEMCG_CHARGE_BATCH updates. So in theory I think we tolerate up to 2 *
> > > > NR_CPUS * MEMCG_CHARGE_BATCH of stale stats (combined for all the
> > > > stats).
> > > >
> > > > See memcg_rstat_updated() and memcg_vmstats_needs_flush() for this logic.
> > > >
> > > > Looking at this code again, it made me realize that we already iterate
> > > > the per-CPU stats_updates on every update to increment the number of
> > > > pending stats on this CPU. I wonder if we can replace this with
> > > > actually updating the stat on each parent, then the flush path only
> > > > needs to accumulate CPU counters. Or maybe as you mention, we update
> > > > the global per-memcg counter when updates on a CPU exceed a threshold,
> > > > and forgo the rstat flushing logic completely.
> > > >
> > >
> > > I can run some experiments and see if updating the global per-memcg
> > > counter per batch and forgoing flushing could be a viable solution.
> > > I can use my setup to test it for writeback, but for the zswap use
> > > case, what's the best way to test if the changes are too expensive? Is
> > > there a benchmark program you're running?
> >
> > I was running stress-ng and measuring the latency of zswap_load() and
> > zswap_store() with bpftrace. But I suspect whatever works for writeback
> > should work for zswap :)
> >
> > >
> > > For updating the stat on each parent and having the flush path only
> > > accumulate CPU counters, I think this means we would have to
> > > unconditionally walk the ancestors + update its counters on every
> > > update, as we wouldn't be able to early break if the flushable
> > > threshold was already exceeded. I don't think we could replace the
> > > stats_update update, as it seems like readers still need some way of
> > > knowing when to flush. It seems like this could be a nontrivial
> > > performance hit, but maybe it's a non-factor in reality. If you think
> > > this would be useful to get benchmark numbers on, I can run some
> > > experiments on this too.
> >
> > I think we need to experiment with how much work we can shift around
> > between the update and reader sides.
> >
> > Right now, on the update side, we call __css_rstat_updated(), which
> > iterates the parents and puts them on the per-CPU rstat tree, then we
> > iterate the parents again in memcg_rstat_updated() and:
> > - Check if flushing threshold is already met.
> > - Update per-CPU counter for stats_updates.
> > - If per-CPU update exceeds (another) threshold, we update an atomic for
> >   stats_updates.
> >
> > The flush side checks the flushing threshold and does an rstat flush,
> > which will walk the update trees on all CPUs and iterates all child
> > cgroups on it. Pretty expensive when the threshold is met.
> >
> > I think we discussed two different options:
> >
> > (a) On stats updates, iterate the parents and update the per-CPU
> > counters of the stat directly.
> >
> > In this case, we can't keep using rstat, as we would still need to add
> > the cgroup and all its ancestors to the update tree anyway and the flush
> > cost will remain similar. So on the flush path we'll need to iterate all
> > CPUs and add the counters. We can explore if use a cpumask can help
> > here, but perhaps not since we'll need to update it atomically on all
> > stat updates.
> >
> > I imagine that if we do this we'll drop the stats_updates optimization
> > to avoid adding more cost to the update side. We'll be replacing parent
> > stats_updates modification with parent stat counter modification. But
> > maybe it's feasible to keep both to limit the cost of flusing.
> >
> > This only works if the cost of iterate the CPUs on the flush side ends
> > up being cheaper than the current flush. I imagine the average cost will
> > be higher but the cost should be much more consistent vs the current
> > flush, so the tail should be better.
> >
> > (b) On stats updates, iterate the parents and update the per-CPU
> > counters of the stat directly AND update a global atomic if the per-CPU
> > counter exceeds a threshold. I think updates may get too expensive as we
> > may need to update multiple atomics, especially when updating the root
> > counter as it will be contended by all cgroups.
> >
> > The flush side becomes an atomic read, so should be very cheap, and we
> > can drop other heuristics and optimizations.
> >
> > ---
> >
> > I think ultimately we need to experiment with these approachs (and
> > perhaps others) to check what actually works in practice. You'll
> > probably want some benchmarks or synthetic tests with many cgroups to
> > exercise the worst case scenarios.
> >
> > For the zswap stats, I used to run stress-ng with a bunch of workers
> > (20, 50, 100, ..) in cgroups of different levels. Since all workers are
> > in the same cgroups, all the parents are common and all threads end up
> > competing on the same atomics, so worst case scenario. I was also
> > testing in a VM that has more CPUs than stress-ng workers, to exercise
> > the full extent of the concurrency and contention.
> >
> > This could be a lot of work, so I am not really asking you to do it, but
> > just brainstorming and sharing ideas :)
>
> Thanks for your guidance and insights on this, Yosry! Your responses
> have been very helpful.
>
> I'll do some experimentation with this and try to get a sense of what
> the update costs are.

Looking forward to that!

>
> One thing I was unclear about and have been looking at is how
> pervasive/important this issue is outside of the writeback case. It
> looks like there are roughly 4 other categories of users that rely on
> stats that can be 2s stale: zswap, cachestat(), working set, and
> vmscan.
>
> Of these, vmscan is the only one that looks troublesome. Some armchair
> analysis below:
>
> a) zswap (zswap_shrinker_count()): 2s of staleness looks fine. the
> stats are only used to calculate the compression ratio and the ratio
> scales a count that comes from a list_lru and *isn't* stale. The ratio
> is an average over the whole pool, so it seems like that should be
> fairly steady across 2s. A stale result doesn't seem too bad - afaict,
> it is a heuristic of how zswap should get weighed against the other
> shrinkers.

There is also the zswap limits calculation, which Song sent a patch to
change to using ratelimited flushing. I think that case is more
problematic as it can cause OOM kills if we use stale stats (e.g.
after a burst of writeback frees up space in zswap). Adding Song here.

The shrinker case is more benign.

>
> b) cachestat() syscall (filemap_cachestat()): seems preferable to just
> rely on the 2s flusher worker. The results are returned back to
> userspace and not used for any internal kernel decision-making, seems
> ok to have one field in it (nr_recently_evicted) potentially be stale.
> Seems better to err on the side of less frequent flushing anyways, as
> the call path is invoked from unprivileged userspace and flushes the
> root memcg.

I am not sure how the cachestat() syscall is used, but my preference
would be to treat it like memory.stat and do a full flush on userspace
read, unless it's a syscall that is invoked frequently for some
reason.

>
> c) workingset: looking at the original commits, the calculations are
> estimates that don't require exact stats -
>
> refaulting (workingset_test_recent()): commit 9b3016154c91 ("memcg:
> sync flush only if periodic flush is delayed")
>     Now the question: what are the side-effects of this change? The worst
>     that can happen is the refault codepath will see 4sec old lruvec stats
>     and may cause false (or missed) activations of the refaulted page which
>     may under-or-overestimate the workingset size.  Though that is not very
>     concerning as the kernel can already miss or do false activations.

Incorrect activations can lead to thrashing in some cases, but I am
not sure how practical the concern is.

>
> shadow-node shrinker (count_shadow_nodes()): commit d4a5b369ad6d ("mm:
> ratelimit stat flush from workingset shrinker")
>     On further inspection it seems like we don't really need
>     accurate stats in this function as it was already approximating the amount
>     of appropriate shadow entries to keep for maintaining the refault
>     information.
>
> d) vmscan (prepare_scan_control()): for determining whether reclaim
> can deactivate, there's this logic
>
>                 /*
>                  * When refaults are being observed, it means a new
>                  * workingset is being established. Deactivate to get
>                  * rid of any stale active pages quickly.
>                  */
>                 refaults = lruvec_page_state(target_lruvec,
>                                 WORKINGSET_ACTIVATE_ANON);
>                 if (refaults != target_lruvec->refaults[WORKINGSET_ANON] ||
>                         inactive_is_low(target_lruvec, LRU_INACTIVE_ANON))
>                         sc->may_deactivate |= DEACTIVATE_ANON;
>                 else
>                         sc->may_deactivate &= ~DEACTIVATE_ANON;
>
> (and same for DEACTIVATE_FILE for file-backed folios)
>
> target_lruvec->refaults[] gets set at the end of the reclaim pass. It
> stores the same lruvec_page_state() value that prepare_scan_control()
> will read at the start of the next reclaim pass, which means if
> reclaim passes happen more frequently than the 2s flushes (eg under
> sustained/heavy memory pressure), this logic will incorrectly assume
> no refaults occurred and deactivation is disabled, which afaict means
> folios won't be moved from the active -> inactive list, and reclaim
> will start evicting pages from the inactive list that are hot (eg more
> refaults).
>
> This is from reading the code rather than a reproducer though, so I
> don't know how much this matters in practice. Unlike for the writeback
> case, I don't think flushign more frequently solves the problem. It
> would shrink the staleness window but not eliminate it. Maybe one
> solution is to keep the deactivation state for a period of time if
> refaults were observed.

We do have an internal test that runs a VM under memory pressure and
we started observing OOM kills after the ratelimited flush was added
in vmscan, so I think it's more problematic than just reclaiming hot
memory, but I didn't look too closely.

>
> If it turns out iterating the parents and updating the per-CPU
> counters of the stat on updates is too expensive, perhaps it makes
> sense to keep the existing rstat logic as is and have caller-side
> workarounds, as only writeback and vmscan would require them.

Also zswap limits calculation. I am not sure how benign the workingset
case is, but it has been ratelimited for a while now AFAICT, so maybe
it's fine.

>
> Thanks,
> Joanne
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.