Skip to contentSkip to conversation

mm: mglru: fix stale batch updates after memcg reparenting

PATCH Superseded Qi Zheng 4 participants 9 messages Jul 1 – Jul 10

Subsystems (4)

v4 of 5 Superseded Latest is v5 →

lore
Share & tools
lore

Qi Zheng

author reviewer
#0 lore raw

PATCH v4 1/1 mm: mglru: fix stale batch updates after memcg reparenting

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

    --> update_batch_size
        --> walk->nr_pages += delta

                              mem_cgroup_css_offline
                              --> memcg_reparent_objcgs
                                  --> lock lruvec
                                      lru_gen_reparent_memcg
                                      --> reparent child folios to parent
                                      unlock lruvec

    lock lruvec
    reset_batch_size
    --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

 mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +34 -7
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
 	walk->nr_pages[new_gen][type][zone] += delta;
 }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);
+
+	return lruvec;
+}
+#else
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	lruvec_lock_irq(lruvec);
+
+	return lruvec;
+}
+#endif
+
 static void reset_batch_size(struct lru_gen_mm_walk *walk)
 {
 	int gen, type, zone;
-	struct lruvec *lruvec = walk->lruvec;
+	struct lruvec *lruvec = lock_batch_lruvec(walk->lruvec);
 	struct lru_gen_folio *lrugen = &lruvec->lrugen;
 
 	walk->batched = 0;
@@ -3285,6 +3315,8 @@ static void reset_batch_size(struct lru_gen_mm_walk *walk)
 			lru += LRU_ACTIVE;
 		__update_lru_size(lruvec, lru, zone, delta);
 	}
+
+	lruvec_unlock_irq(lruvec);
 }
 
 static int should_skip_vma(unsigned long start, unsigned long end, struct mm_walk *args)
@@ -3779,11 +3811,8 @@ static void walk_mm(struct mm_struct *mm, struct lru_gen_mm_walk *walk)
 			mmap_read_unlock(mm);
 		}
 
-		if (walk->batched) {
-			lruvec_lock_irq(lruvec);
+		if (walk->batched)
 			reset_batch_size(walk);
-			lruvec_unlock_irq(lruvec);
-		}
 
 		cond_resched();
 	} while (err == -EAGAIN);
@@ -4867,9 +4896,7 @@ static int evict_folios(unsigned long nr_to_scan, struct lruvec *lruvec,
 	walk = current->reclaim_state->mm_walk;
 	if (walk && walk->batched) {
 		walk->lruvec = lruvec;
-		lruvec_lock_irq(lruvec);
 		reset_batch_size(walk);
-		lruvec_unlock_irq(lruvec);
 	}
 
 	mod_lruvec_state(lruvec, PGDEMOTE_KSWAPD + reclaimer_offset(sc),
Signature · 1 line
2.54.0
#1 lore raw
Quoted 87 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

    --> update_batch_size
        --> walk->nr_pages += delta

                              mem_cgroup_css_offline
                              --> memcg_reparent_objcgs
                                  --> lock lruvec
                                      lru_gen_reparent_memcg
                                      --> reparent child folios to parent
                                      unlock lruvec

    lock lruvec
    reset_batch_size
    --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

 mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +8 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
 	walk->nr_pages[new_gen][type][zone] += delta;
 }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 10 lines
mm/vmscan.c +10 -0
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

Quoted 56 lines
mm/vmscan.c +16 -7
+
+	return lruvec;
+}
+#else
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	lruvec_lock_irq(lruvec);
+
+	return lruvec;
+}
+#endif
+
 static void reset_batch_size(struct lru_gen_mm_walk *walk)
 {
 	int gen, type, zone;
-	struct lruvec *lruvec = walk->lruvec;
+	struct lruvec *lruvec = lock_batch_lruvec(walk->lruvec);
 	struct lru_gen_folio *lrugen = &lruvec->lrugen;
 
 	walk->batched = 0;
@@ -3285,6 +3315,8 @@ static void reset_batch_size(struct lru_gen_mm_walk *walk)
 			lru += LRU_ACTIVE;
 		__update_lru_size(lruvec, lru, zone, delta);
 	}
+
+	lruvec_unlock_irq(lruvec);
 }
 
 static int should_skip_vma(unsigned long start, unsigned long end, struct mm_walk *args)
@@ -3779,11 +3811,8 @@ static void walk_mm(struct mm_struct *mm, struct lru_gen_mm_walk *walk)
 			mmap_read_unlock(mm);
 		}
 
-		if (walk->batched) {
-			lruvec_lock_irq(lruvec);
+		if (walk->batched)
 			reset_batch_size(walk);
-			lruvec_unlock_irq(lruvec);
-		}
 
 		cond_resched();
 	} while (err == -EAGAIN);
@@ -4867,9 +4896,7 @@ static int evict_folios(unsigned long nr_to_scan, struct lruvec *lruvec,
 	walk = current->reclaim_state->mm_walk;
 	if (walk && walk->batched) {
 		walk->lruvec = lruvec;
-		lruvec_lock_irq(lruvec);
 		reset_batch_size(walk);
-		lruvec_unlock_irq(lruvec);
 	}
 
 	mod_lruvec_state(lruvec, PGDEMOTE_KSWAPD + reclaimer_offset(sc),
Signature · 1 line
2.54.0
#2 lore raw
Quoted 93 lines · Usama Arif
Quoted 91 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

    --> update_batch_size
        --> walk->nr_pages += delta

                              mem_cgroup_css_offline
                              --> memcg_reparent_objcgs
                                  --> lock lruvec
                                      lru_gen_reparent_memcg
                                      --> reparent child folios to parent
                                      unlock lruvec

    lock lruvec
    reset_batch_size
    --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

 mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +12 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
 	walk->nr_pages[new_gen][type][zone] += delta;
 }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
Quoted 8 lines

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 6 lines
mm/vmscan.c +6 -0
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

lruvec_unlock_irq() does that.

Signature · 2 lines
Cheers,
Harry / Hyeonggon
maintainer
#3 lore raw
Quoted 105 lines · Harry Yoo
Quoted 93 lines · Usama Arif
Quoted 91 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

    --> update_batch_size
        --> walk->nr_pages += delta

                              mem_cgroup_css_offline
                              --> memcg_reparent_objcgs
                                  --> lock lruvec
                                      lru_gen_reparent_memcg
                                      --> reparent child folios to parent
                                      unlock lruvec

    lock lruvec
    reset_batch_size
    --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

 mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +12 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
 	walk->nr_pages[new_gen][type][zone] += delta;
 }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
Quoted 8 lines

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 6 lines
mm/vmscan.c +6 -0
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

Quoted 1 line

lruvec_unlock_irq() does that.

Yeah, that tripped me up too. And it makes me think Shakeel was right after all: this should live next to the other lruvec_lock() primitives.

Sure, MGLRU is the only user, but it's still much easier to understand this if the code sits next to the rest of the API (and the unlock!).

lruvec_live_lock_irq()?

author reviewer
#4 lore raw
Quoted 108 lines · Johannes Weiner
Quoted 105 lines · Harry Yoo
Quoted 93 lines · Usama Arif
Quoted 91 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

     --> update_batch_size
         --> walk->nr_pages += delta

                               mem_cgroup_css_offline
                               --> memcg_reparent_objcgs
                                   --> lock lruvec
                                       lru_gen_reparent_memcg
                                       --> reparent child folios to parent
                                       unlock lruvec

     lock lruvec
     reset_batch_size
     --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

  mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
  1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +12 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
  	walk->nr_pages[new_gen][type][zone] += delta;
  }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
Quoted 8 lines

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 6 lines
mm/vmscan.c +6 -0
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

Quoted 1 line

lruvec_unlock_irq() does that.

Quoted 7 lines

Yeah, that tripped me up too. And it makes me think Shakeel was right after all: this should live next to the other lruvec_lock() primitives.

Sure, MGLRU is the only user, but it's still much easier to understand this if the code sits next to the rest of the API (and the unlock!).

lruvec_live_lock_irq()?

But lruvec_lock_irq() grabs the rcu lock too. :(

maintainer
#5 lore raw
Quoted 114 lines · Qi Zheng
Quoted 111 lines · Johannes Weiner
Quoted 105 lines · Harry Yoo
Quoted 93 lines · Usama Arif
Quoted 91 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

     --> update_batch_size
         --> walk->nr_pages += delta

                               mem_cgroup_css_offline
                               --> memcg_reparent_objcgs
                                   --> lock lruvec
                                       lru_gen_reparent_memcg
                                       --> reparent child folios to parent
                                       unlock lruvec

     lock lruvec
     reset_batch_size
     --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

  mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
  1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +12 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
  	walk->nr_pages[new_gen][type][zone] += delta;
  }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
Quoted 8 lines

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 6 lines
mm/vmscan.c +6 -0
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

Quoted 1 line

lruvec_unlock_irq() does that.

Yeah, that tripped me up too. And it makes me think Shakeel was right after all: this should live next to the other lruvec_lock() primitives.

Quoted 6 lines
Quoted 4 lines

Sure, MGLRU is the only user, but it's still much easier to understand this if the code sits next to the rest of the API (and the unlock!).

lruvec_live_lock_irq()?

But lruvec_lock_irq() grabs the rcu lock too. :(

Yes, but it's self-explanatory if you put it with those definitions:

static inline void lruvec_lock_irq(struct lruvec *lruvec)

{
        rcu_read_lock();
        spin_lock_irq(&lruvec->lru_lock);
}

static struct lruvec *lruvec_live_lock_irq(struct lruvec *lruvec)

{
	struct mem_cgroup *memcg = lruvec_memcg(lruvec);

	rcu_read_lock();
	while (unlikely(memcg && css_is_dying(&memcg->css))) {
		memcg = parent_mem_cgroup(memcg);
		lruvec = mem_cgroup_lruvec(memcg, lruvec_pgdat(lruvec));
	}
	spin_lock_irq(&lruvec->lru_lock);
}

static inline void lruvec_unlock_irq(struct lruvec *lruvec)

{
        spin_unlock_irq(&lruvec->lru_lock);
        rcu_read_unlock();
}

etc.

author reviewer
#6 lore raw
Quoted 131 lines · Johannes Weiner
Quoted 114 lines · Qi Zheng
Quoted 111 lines · Johannes Weiner
Quoted 105 lines · Harry Yoo
Quoted 93 lines · Usama Arif
Quoted 91 lines · On Wed, 1 Jul 2026 15:52:51 +0800 Qi Zheng <[email protected]> wrote:

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

      --> update_batch_size
          --> walk->nr_pages += delta

                                mem_cgroup_css_offline
                                --> memcg_reparent_objcgs
                                    --> lock lruvec
                                        lru_gen_reparent_memcg
                                        --> reparent child folios to parent
                                        unlock lruvec

      lock lruvec
      reset_batch_size
      --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630

Changes in v3:
- re-implement lock_batch_lruvec() by checking CSS_DYING under the RCU lock (suggested by Harry)
- update the commit message (suggested by Harry)
- temporarily drop the previous Reviewed-by tags
(since the sync method has changed)
- rebase onto the next-20260624

Changes in v2:
- update the commit message (pointed by Barry)
- collect Reviewed-by

   mm/vmscan.c | 41 ++++++++++++++++++++++++++++++++++-------
   1 file changed, 34 insertions(+), 7 deletions(-)
mm/vmscan.c +12 -0
index 35c3bb15ae96..ca1e2a870d51 100644
@@ -3262,10 +3262,40 @@ static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio,
   	walk->nr_pages[new_gen][type][zone] += delta;
   }
 
+#ifdef CONFIG_MEMCG
+static struct lruvec *lock_batch_lruvec(struct lruvec *lruvec)
+{
+	struct pglist_data *pgdat = lruvec_pgdat(lruvec);
+	struct mem_cgroup *memcg = lruvec_memcg(lruvec);
+
+	rcu_read_lock();
+
+	/*
+	 * The memcg can be NULL when the memory controller is disabled.
+	 * Otherwise, the caller keeps the memcg owning @lruvec alive.
+	 */
Quoted 8 lines

mm/vmscan.c static void update_batch_size(struct lru_gen_mm_walk *walk, struct folio *folio, · line 3282

Quoted 6 lines
mm/vmscan.c +6 -0
+	while (unlikely(memcg && css_is_dying(&memcg->css))) {
+		memcg = parent_mem_cgroup(memcg);
+		lruvec = mem_cgroup_lruvec(memcg, pgdat);
+	}
+
+	spin_lock_irq(&lruvec->lru_lock);

Do we need an rcu_read_unlock() here?

Quoted 1 line

lruvec_unlock_irq() does that.

Yeah, that tripped me up too. And it makes me think Shakeel was right after all: this should live next to the other lruvec_lock() primitives.

Quoted 6 lines
Quoted 4 lines

Sure, MGLRU is the only user, but it's still much easier to understand this if the code sits next to the rest of the API (and the unlock!).

lruvec_live_lock_irq()?

But lruvec_lock_irq() grabs the rcu lock too. :(

Yes, but it's self-explanatory if you put it with those definitions:

static inline void lruvec_lock_irq(struct lruvec *lruvec)

{
         rcu_read_lock();
         spin_lock_irq(&lruvec->lru_lock);
}

static struct lruvec *lruvec_live_lock_irq(struct lruvec *lruvec)

Quoted 10 lines
{
	struct mem_cgroup *memcg = lruvec_memcg(lruvec);

	rcu_read_lock();
	while (unlikely(memcg && css_is_dying(&memcg->css))) {
		memcg = parent_mem_cgroup(memcg);
		lruvec = mem_cgroup_lruvec(memcg, lruvec_pgdat(lruvec));
	}
	spin_lock_irq(&lruvec->lru_lock);
}

All right, should the implementation for !CONFIG_MEMCG be placed here too?

Will send the v5.

Thanks,
Qi

Quoted 8 lines

static inline void lruvec_unlock_irq(struct lruvec *lruvec)

{
         spin_unlock_irq(&lruvec->lru_lock);
         rcu_read_unlock();
}

etc.

maintainer
#7 lore raw
Quoted 27 lines · Qi Zheng
Quoted 15 lines · Johannes Weiner
Quoted 4 lines · Qi Zheng
Quoted 1 line · Johannes Weiner

lruvec_live_lock_irq()?

But lruvec_lock_irq() grabs the rcu lock too. :(

Yes, but it's self-explanatory if you put it with those definitions:

static inline void lruvec_lock_irq(struct lruvec *lruvec)

{
         rcu_read_lock();
         spin_lock_irq(&lruvec->lru_lock);
}

static struct lruvec *lruvec_live_lock_irq(struct lruvec *lruvec)

Quoted 10 lines
{
	struct mem_cgroup *memcg = lruvec_memcg(lruvec);

	rcu_read_lock();
	while (unlikely(memcg && css_is_dying(&memcg->css))) {
		memcg = parent_mem_cgroup(memcg);
		lruvec = mem_cgroup_lruvec(memcg, lruvec_pgdat(lruvec));
	}
	spin_lock_irq(&lruvec->lru_lock);
}
Quoted 2 lines

All right, should the implementation for !CONFIG_MEMCG be placed here too?

Yes. You can just #ifdef, #else the function body itself.

static struct lruvec *lruvec_live_lock_irq()
{

#ifdef CONFIG_MEMCG
	...
#else
	lruvec_lock_irq(lruvec);
	return lruvec;

#endif
}

Quoted 1 line

Will send the v5.

Thanks.

author reviewer
#8 lore raw
In reply to Qi Zheng

Hi Andrew and David,

Gentle ping.

Is this patch good to be applied?

Thanks,
Qi

Quoted 56 lines · Qi Zheng

From: Qi Zheng <[email protected]>

The mglru page table walker batches per-generation size deltas in walk->nr_pages while walking page tables without holding the lruvec lock. The reset_batch_size() later folds those deltas into walk->lruvec under the lruvec lock.

The page table walker can run concurrently with the memcg reparenting path as follows:

CPU0 CPU1
==== ====

walk_mm
--> walk_page_range

     --> update_batch_size
         --> walk->nr_pages += delta

                               mem_cgroup_css_offline
                               --> memcg_reparent_objcgs
                                   --> lock lruvec
                                       lru_gen_reparent_memcg
                                       --> reparent child folios to parent
                                       unlock lruvec

     lock lruvec
     reset_batch_size
     --> child lrugen->nr_pages += delta

This will trigger the following warning in lru_gen_exit_memcg():

	VM_WARN_ON_ONCE(memchr_inv(lruvec->lrugen.nr_pages, 0,
				   sizeof(lruvec->lrugen.nr_pages)));

And the user-visible impact of underestimated nr_pages in MGLRU was premature OOMs because MGLRU does not try to reclaim memory when nr_pages reaches zero, but there are still more pages.

To fix it, make reset_batch_size() check CSS_DYING under RCU before flushing the pending batch. A non-dying memcg keeps the original lruvec stable against RCU-delayed offlining; a dying memcg redirects the deltas to the first non-dying ancestor.

---
Changes in v4:
- re-implement lock_batch_lruvec() in a simpler way (suggested by Johannes and Harry)
- collect Reviewed-by
- rebase onto the next-20260630