* [PATCH v2 0/5] mm: fix allocator races with memory hotplug
@ 2026-10-19 17:23 Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 1/5] mm/page_alloc: detect zonelist rebuilds from memory hot-add Gregory Price (Meta)
` (4 more replies)
0 siblings, 5 replies; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang
Five fixes for allocator paths that can act on state another CPU is
updating: zonelists rebuilt by memory hotplug, and the pagesets of a
zone that offlining empties.
I've packaged these together because they're all related to hotplug
in some way, but they may end up backporting differently.
Patch 1: the zonelist_update_seq retry is compiled out without
CONFIG_MEMORY_HOTREMOVE, but memory hot-add rewrites zonelists in place
too. A __GFP_THISNODE allocation that sees the list mid-rewrite fails
even though the node has free memory.
Patch 2: onlining a zone shifts zonelist slots under an in-flight walk,
so the allocator can use a zone its filters rejected: a GFP_KERNEL page
from ZONE_MOVABLE, which later makes offlining that block fail, or a
page from outside the nodemask. Without CONFIG_MEMORY_HOTREMOVE, it
relies on patch 1 to retry a walk that skips a zone and runs off the end
of the list.
Patch 3: offlining a zone's last block can move the zonelist's NULL
terminator onto an allocation's preferred slot, which the allocator then
dereferences in zone_statistics().
Patch 4: zone_pcp_reset() frees an emptied zone's pagesets while the
vmstat worker can still use them. The pagesets are now kept for the life
of the zone, which costs 320 bytes per possible CPU for each zone that
was onlined and then emptied.
Patch 5: __next_zones_zonelist() tests a slot's zone for NULL, then
reads the slot again to look up its node. If offlining turns the slot
into the terminator in between, zone_to_nid() dereferences NULL.
Current GCC and clang merge the two reads, but nothing requires it.
Reproducers
===========
All five races were reproduced on unmodified source in a QEMU guest
with three NUMA nodes:
node 0: 3G, CPUs 0-1
node 1: 1G ZONE_NORMAL, CPU 2
node 2: 1G ZONE_NORMAL, CPU 3
And a 512M pc-dimm cold-plugged on node 1, left offline at boot
(CONFIG_ACPI_HOTPLUG_MEMORY=y).
Patch 1 needs CONFIG_MEMORY_HOTREMOVE=n to show the failure
Patch 5 needs mm/mmzone.o built with -fno-inline -fno-ipa-sra,
without which the two reads are merged.
The windows are a few instructions wide, so I used a test module to
widen them with kprobes that stall one CPU inside the window. This
emulates an interrupt or vCPU preemption. The vCPU preemption is the
more likely event to cause this at large scale with memory hotplug.
Patch 1 (zonelist):
Online node 1's first memory block as ZONE_MOVABLE, which rebuilds
node 1's zonelists.
Stall the rebuild after it writes ZONE_MOVABLE into the first entry
but before it writes ZONE_NORMAL into the second.
On another CPU, allocate GFP_KERNEL | __GFP_THISNODE from node 1.
Before: the allocation fails, 3/3 runs.
After: the allocation waits for the rebuild and succeeds, 3/3 runs.
Patch 2 (zonelist slots):
On one CPU, allocate GFP_KERNEL with a nodemask, and stall it after
the iterator approves its first zonelist slot but before the
allocator loads the zone from that slot.
Online node 1's first memory block as ZONE_MOVABLE, which inserts
ZONE_MOVABLE at the front of node 1's zonelists.
Before: nodemask {1} gets a node 1 ZONE_MOVABLE page and nodemask
{2} a node 1 ZONE_NORMAL page, for single-page and bulk
allocations, 3/3 runs each.
After: nodemask {1} gets node 1 ZONE_NORMAL and nodemask {2} gets
node 2 ZONE_NORMAL, 3/3 runs each.
Patch 3 (preferred zone):
Online node 1's block as ZONE_MOVABLE, so node 1's local zonelist is
[MOVABLE, NORMAL].
On another CPU, allocate GFP_KERNEL | __GFP_THISNODE from node 1,
whose preferred slot is NORMAL, and stall it after it loads the zone.
Offline the block, which rebuilds the list as [NORMAL] and moves the
terminator into the preferred slot.
Before: NULL dereference in get_page_from_freelist(), 3/3 runs.
After: the allocation succeeds from ZONE_NORMAL, 3/3 runs.
Patch 4 (pagesets):
Online node 1's block as ZONE_MOVABLE, then offline it.
Stall the offline before the zone empties, and stall the vmstat
worker on another CPU (kicked through /proc/sys/vm/stat_refresh) in
decay_pcp_high() for that zone. Let the offline free the pagesets,
reallocate the freed percpu area with a known value, then release
the worker.
Before: decay_pcp_high() runs on the freed pageset, 3/3 runs. In
1/3 the area was reallocated before the worker resumed, and
decay_pcp_high() rewrote the new object.
After: the pageset is not freed, 3/3 runs.
Repeated offline/online of the block, and ejecting and re-adding the
only DIMM of a CPU-less node over QMP, reuse the same pagesets.
Patch 5 (slot reread):
Online node 1's block as ZONE_MOVABLE, so node 1's local zonelist is
[MOVABLE, NORMAL].
On another CPU, allocate GFP_KERNEL | __GFP_THISNODE from node 1
with nodemask {1}, and stall it after __next_zones_zonelist() tests
the NORMAL slot for NULL but before it reads the slot again.
Offline the block, which rebuilds the list as [NORMAL] and moves the
terminator into that slot.
Before: NULL dereference in zone_to_nid(), 4/4 runs.
After: no oops, 3/3 runs.
All five reproducers also pass on the series tip, 3/3 runs each.
Changes in v2
=============
- Added patches 2-5.
- Pulled cpuset patch out by itself
Link: https://lore.kernel.org/linux-mm/20261014222456.2318284-1-gourry@gourry.net/
Link: https://lore.kernel.org/linux-mm/20261015210539.3243563-1-gourry@gourry.net/
Gregory Price (Meta) (5):
mm/page_alloc: detect zonelist rebuilds from memory hot-add
mm/page_alloc: validate zonelist slots against concurrent rebuilds
mm/page_alloc: snapshot the preferred zone against shrink rebuilds
mm/page_alloc: keep a zone's pagesets when it goes empty
mm/mmzone: read each zoneref slot once when filtering by nodemask
mm/mmzone.c | 11 +++----
mm/page_alloc.c | 77 +++++++++++++++++++++++++++++++++++++------------
2 files changed, 65 insertions(+), 23 deletions(-)
--
2.53.0-Meta
^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH v2 1/5] mm/page_alloc: detect zonelist rebuilds from memory hot-add
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
@ 2026-10-19 17:23 ` Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds Gregory Price (Meta)
` (3 subsequent siblings)
4 siblings, 0 replies; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
The zonelist_update_seq retry was limited to CONFIG_MEMORY_HOTREMOVE,
presumably because only hot-remove takes zones out of a zonelist.
However, build_zonerefs_node() rewrites the zonelists in place, so
onlining the first block of a zone shifts the entries that follow it
in every zonelist containing that zone. This can cause a spurious
allocation failure for a __GFP_THISNODE caller.
For a node with only ZONE_NORMAL, onlining its first ZONE_MOVABLE block
rewrites the node's ZONELIST_NOFALLBACK list as:
[NORMAL, NULL] -> [MOVABLE, NULL] -> [MOVABLE, NORMAL, NULL]
A concurrent GFP_KERNEL | __GFP_THISNODE allocation can observe the
intermediate list, skip ZONE_MOVABLE, and find no eligible zone. With
CONFIG_MEMORY_HOTREMOVE=n, check_retry_zonelist() returns 0 and the
allocation fails even though the node has free ZONE_NORMAL memory.
Gate the sequence checks on CONFIG_MEMORY_HOTPLUG, which covers every
configuration that rebuilds zonelists after boot.
Fixes: 3d36424b3b58 ("mm/page_alloc: fix race condition between build_all_zonelists and page allocation")
Cc: stable@vger.kernel.org
Reviewed-by: Vlastimil Babka (SUSE) <vbabka@kernel.org>
Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
---
mm/page_alloc.c | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/mm/page_alloc.c b/mm/page_alloc.c
index 2daf358269ae7..c7125f6693574 100644
--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -4404,7 +4404,7 @@ static DEFINE_SEQLOCK(zonelist_update_seq);
static unsigned int zonelist_iter_begin(void)
{
- if (IS_ENABLED(CONFIG_MEMORY_HOTREMOVE))
+ if (IS_ENABLED(CONFIG_MEMORY_HOTPLUG))
return read_seqbegin(&zonelist_update_seq);
return 0;
@@ -4412,7 +4412,7 @@ static unsigned int zonelist_iter_begin(void)
static unsigned int check_retry_zonelist(unsigned int seq)
{
- if (IS_ENABLED(CONFIG_MEMORY_HOTREMOVE))
+ if (IS_ENABLED(CONFIG_MEMORY_HOTPLUG))
return read_seqretry(&zonelist_update_seq, seq);
return seq;
--
2.53.0-Meta
^ permalink raw reply related [flat|nested] 9+ messages in thread
* [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 1/5] mm/page_alloc: detect zonelist rebuilds from memory hot-add Gregory Price (Meta)
@ 2026-10-19 17:23 ` Gregory Price (Meta)
2026-10-20 15:22 ` Vlastimil Babka (SUSE)
2026-10-20 17:24 ` sashiko-bot
2026-10-19 17:23 ` [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds Gregory Price (Meta)
` (2 subsequent siblings)
4 siblings, 2 replies; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
Onlining the first block of a zone rebuilds every zonelist in place,
shifting the slots after the new zone. An in-flight walk can use a
zone it previously filtered. e.g.:
- a GFP_KERNEL request can land in ZONE_MOVABLE,
- a node not in the nodemask can have its zones selected.
With kprobes holding the walk across the online, I was able to get this
zone/node misplacement to happen consistently across hotplug events.
The iterator approves a slot by its zone index and node, and the
allocator then loads the zone pointer from the slot again:
CPU 0 CPU 1
first_zones_zonelist(nodemask {1})
slot 0 (NORMAL1) approved
online_pages()
build_all_zonelists()
[NORMAL1, NORMAL2, ...]
-> [MOVABLE1, NORMAL1, ...]
get_page_from_freelist()
zone = zonelist_zone(slot 0) /* MOVABLE1 */
Every later step of the walk filters and loads the same way, so a slot
approved for one node can also hold another node's zone by that time.
Recheck the loaded zone against highest_zoneidx and the nodemask in both
allocator loops, and skip it on a mismatch. Hot-add only inserts zones,
so the zone that was in a skipped slot is still ahead in the walk, and a
walk that runs off the end meanwhile is retried by the zonelist seq-check.
The recheck adds about a dozen instructions per zone visited. The two
fields it reads share a cacheline with zone->per_cpu_pageset, which the
pcp allocation path loads anyway.
Fixes: c33d6c06f60f7 ("mm, page_alloc: avoid looking up the first zone in a zonelist twice")
Cc: stable@vger.kernel.org
Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
---
mm/page_alloc.c | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/mm/page_alloc.c b/mm/page_alloc.c
index c7125f6693574..aa3cd44fdaa5b 100644
--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -3784,6 +3784,19 @@ static inline unsigned int alloc_flags_cma(gfp_t gfp_mask)
return ALLOC_DEFAULT;
}
+/*
+ * The zonelist can be rebuilt in place while it is walked, so the iterator's
+ * filters may have passed a slot that now holds a different zone. Recheck the
+ * zone itself.
+ */
+static inline bool zonelist_zone_valid(struct zone *zone,
+ const struct alloc_context *ac)
+{
+ if (zone_idx(zone) > ac->highest_zoneidx)
+ return false;
+ return !ac->nodemask || node_isset(zone_to_nid(zone), *ac->nodemask);
+}
+
/*
* get_page_from_freelist goes through the zonelist trying to allocate
* a page.
@@ -3812,6 +3825,9 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
struct page *page;
unsigned long mark;
+ if (unlikely(!zonelist_zone_valid(zone, ac)))
+ continue;
+
if (cpusets_enabled() &&
(alloc_flags & ALLOC_CPUSET) &&
!__cpuset_zone_allowed(zone, gfp_mask))
@@ -5236,6 +5252,9 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
for_next_zone_zonelist_nodemask(zone, z, ac.highest_zoneidx, ac.nodemask) {
unsigned long mark;
+ if (unlikely(!zonelist_zone_valid(zone, &ac)))
+ continue;
+
if (cpusets_enabled() && (alloc_flags & ALLOC_CPUSET) &&
!__cpuset_zone_allowed(zone, gfp)) {
continue;
--
2.53.0-Meta
^ permalink raw reply related [flat|nested] 9+ messages in thread
* [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 1/5] mm/page_alloc: detect zonelist rebuilds from memory hot-add Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds Gregory Price (Meta)
@ 2026-10-19 17:23 ` Gregory Price (Meta)
2026-10-20 17:24 ` sashiko-bot
2026-10-19 17:23 ` [PATCH v2 4/5] mm/page_alloc: keep a zone's pagesets when it goes empty Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 5/5] mm/mmzone: read each zoneref slot once when filtering by nodemask Gregory Price (Meta)
4 siblings, 1 reply; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
get_page_from_freelist() and alloc_pages_bulk_noprof() reread
ac->preferred_zoneref->zone at every use. Offlining a zone's last block
rebuilds the zonelists in place, and the shrunk list's NULL terminator
can land on the preferred slot mid-walk:
CPU 0 CPU 1
get_page_from_freelist(GFP_KERNEL | __GFP_THISNODE)
zone = NORMAL (slot 1)
offline_pages()
build_all_zonelists()
[MOVABLE, NORMAL, NULL]
-> [NORMAL, NULL]
rmqueue(zonelist_zone(slot 1), zone)
zone_statistics(NULL, zone)
BUG: kernel NULL pointer dereference, address: 0000000000000060
RIP: 0010:get_page_from_freelist+0x6a5/0x15f0
Call Trace:
__alloc_frozen_pages_noprof+0x125/0x330
Read the slot once. Zones are never freed, so a stale zone is safe, and
a NULL slot fails the same way as walking off the end.
Fixes: c33d6c06f60f7 ("mm, page_alloc: avoid looking up the first zone in a zonelist twice")
Cc: stable@vger.kernel.org
Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
---
mm/page_alloc.c | 31 +++++++++++++++++++++++--------
1 file changed, 23 insertions(+), 8 deletions(-)
diff --git a/mm/page_alloc.c b/mm/page_alloc.c
index aa3cd44fdaa5b..75a440ebcead6 100644
--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -3807,12 +3807,21 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
{
struct zoneref *z;
struct zone *zone;
+ struct zone *preferred_zone;
struct pglist_data *last_pgdat = NULL;
bool last_pgdat_dirty_ok = false;
bool no_fallback;
bool skip_kswapd_nodes = nr_online_nodes > 1;
bool skipped_kswapd_nodes = false;
+ /*
+ * A concurrent zonelist rebuild can NULL or replace this slot. Zones
+ * are never freed, so reading it once keeps a usable zone.
+ */
+ preferred_zone = READ_ONCE(ac->preferred_zoneref->zone);
+ if (!preferred_zone)
+ return NULL;
+
retry:
/*
* Scan zonelist, looking for a zone with enough free.
@@ -3862,7 +3871,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
}
if (no_fallback && !defrag_mode && nr_online_nodes > 1 &&
- zone != zonelist_zone(ac->preferred_zoneref)) {
+ zone != preferred_zone) {
int local_nid;
/*
@@ -3870,7 +3879,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
* fragmenting fallbacks. Locality is more important
* than fragmentation avoidance.
*/
- local_nid = zonelist_node_idx(ac->preferred_zoneref);
+ local_nid = zone_to_nid(preferred_zone);
if (zone_to_nid(zone) != local_nid) {
alloc_flags &= ~ALLOC_NOFRAGMENT;
goto retry;
@@ -3932,7 +3941,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
goto try_this_zone;
if (!node_reclaim_enabled() ||
- !zone_allows_reclaim(zonelist_zone(ac->preferred_zoneref), zone))
+ !zone_allows_reclaim(preferred_zone, zone))
continue;
if (!node_reclaim(zone->zone_pgdat, gfp_mask, order))
@@ -3945,7 +3954,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
}
try_this_zone:
- page = rmqueue(zonelist_zone(ac->preferred_zoneref), zone, order,
+ page = rmqueue(preferred_zone, zone, order,
gfp_mask, alloc_flags, ac->migratetype);
if (page) {
prep_new_page(page, order, gfp_mask, alloc_flags);
@@ -5199,6 +5208,7 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
{
struct page *page;
struct zone *zone;
+ struct zone *preferred_zone;
struct zoneref *z;
struct per_cpu_pages *pcp;
struct list_head *pcp_list;
@@ -5247,6 +5257,11 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
if (!prepare_alloc_pages(gfp, 0, preferred_nid, nodemask, &ac, &gfp, &alloc_flags))
goto out;
+ /* Read once; see get_page_from_freelist(). */
+ preferred_zone = READ_ONCE(ac.preferred_zoneref->zone);
+ if (!preferred_zone)
+ goto failed;
+
/* Find an allowed local zone that meets the low watermark. */
z = ac.preferred_zoneref;
for_next_zone_zonelist_nodemask(zone, z, ac.highest_zoneidx, ac.nodemask) {
@@ -5260,8 +5275,8 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
continue;
}
- if (nr_online_nodes > 1 && zone != zonelist_zone(ac.preferred_zoneref) &&
- zone_to_nid(zone) != zonelist_node_idx(ac.preferred_zoneref)) {
+ if (nr_online_nodes > 1 && zone != preferred_zone &&
+ zone_to_nid(zone) != zone_to_nid(preferred_zone)) {
goto failed;
}
@@ -5269,7 +5284,7 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
retry_this_zone:
mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK) + nr_pages - nr_populated;
if (zone_watermark_fast(zone, 0, mark,
- zonelist_zone_idx(ac.preferred_zoneref),
+ zone_idx(preferred_zone),
alloc_flags, gfp)) {
break;
}
@@ -5328,7 +5343,7 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
pcp_spin_unlock(pcp);
__count_zid_vm_events(PGALLOC, zone_idx(zone), nr_account);
- zone_statistics(zonelist_zone(ac.preferred_zoneref), zone, nr_account);
+ zone_statistics(preferred_zone, zone, nr_account);
out:
return nr_populated;
--
2.53.0-Meta
^ permalink raw reply related [flat|nested] 9+ messages in thread
* [PATCH v2 4/5] mm/page_alloc: keep a zone's pagesets when it goes empty
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
` (2 preceding siblings ...)
2026-10-19 17:23 ` [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds Gregory Price (Meta)
@ 2026-10-19 17:23 ` Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 5/5] mm/mmzone: read each zoneref slot once when filtering by nodemask Gregory Price (Meta)
4 siblings, 0 replies; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
When offlining empties a zone, zone_pcp_reset() frees its pagesets while
the vmstat worker can still hold one across cond_resched():
CPU 0 CPU 1
refresh_cpu_vm_stats()
pcp = zone->per_cpu_pageset
cond_resched()
offline_pages()
zone_pcp_reset()
free_percpu(zone->per_cpu_pageset)
decay_pcp_high(zone, this_cpu_ptr(pcp))
decay_pcp_high() then reads, and can write, memory the percpu allocator
may already have handed out again. With kprobes holding the worker
across the offline, it rewrote a new allocation at the freed address in
2 of 3 runs.
Keep the pagesets for the life of the zone, as pgdat->per_cpu_nodestats
already is, and only fold the stats in zone_pcp_reset().
Commit 340175b7d14d ("mm/hotplug: free zone->pageset when a zone becomes
empty") freed them because onlining the zone again allocated a new set
and leaked the old one. setup_zone_pageset() now reuses the existing
set, and zone_pcp_init() keeps it when the node is re-added, so nothing
is allocated twice. The pageset address stayed the same across repeated
offline/online cycles and across removing and re-adding the node.
The pagesets take 320 bytes per possible CPU: 30KiB per zone with 96
CPUs, 160KiB with 512 and 2.5MiB with 8192. A populated zone already
holds this memory, we just no longer give it back on offline.
Fixes: 340175b7d14d ("mm/hotplug: free zone->pageset when a zone becomes empty")
Cc: stable@vger.kernel.org
Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
---
mm/page_alloc.c | 23 +++++++++++++++--------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/mm/page_alloc.c b/mm/page_alloc.c
index 75a440ebcead6..417c427b196d9 100644
--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -6356,6 +6356,12 @@ void __meminit setup_zone_pageset(struct zone *zone)
{
int cpu;
+ /* Reuse the pagesets zone_pcp_reset() kept. */
+ if (zone->per_cpu_pageset != &boot_pageset) {
+ zone_set_pageset_high_and_batch(zone, 0);
+ return;
+ }
+
/* Size may be 0 on !SMP && !NUMA */
if (sizeof(struct per_cpu_zonestat) > 0)
zone->per_cpu_zonestats = alloc_percpu(struct per_cpu_zonestat);
@@ -6452,9 +6458,12 @@ __meminit void zone_pcp_init(struct zone *zone)
* per cpu subsystem is not up at this point. The following code
* relies on the ability of the linker to provide the
* offset of a (static) per cpu variable into the per cpu area.
+ * A re-added node keeps the pagesets zone_pcp_reset() kept.
*/
- zone->per_cpu_pageset = &boot_pageset;
- zone->per_cpu_zonestats = &boot_zonestats;
+ if (!zone->per_cpu_pageset || zone->per_cpu_pageset == &boot_pageset) {
+ zone->per_cpu_pageset = &boot_pageset;
+ zone->per_cpu_zonestats = &boot_zonestats;
+ }
zone->pageset_high_min = BOOT_PAGESET_HIGH;
zone->pageset_high_max = BOOT_PAGESET_HIGH;
zone->pageset_batch = BOOT_PAGESET_BATCH;
@@ -7728,6 +7737,10 @@ void zone_pcp_enable(struct zone *zone)
mutex_unlock(&pcp_batch_high_lock);
}
+/*
+ * Keep the pagesets of an emptied zone: refresh_cpu_vm_stats() can hold
+ * one across cond_resched(), and nothing waits for it to let go.
+ */
void zone_pcp_reset(struct zone *zone)
{
int cpu;
@@ -7738,12 +7751,6 @@ void zone_pcp_reset(struct zone *zone)
pzstats = per_cpu_ptr(zone->per_cpu_zonestats, cpu);
drain_zonestat(zone, pzstats);
}
- free_percpu(zone->per_cpu_pageset);
- zone->per_cpu_pageset = &boot_pageset;
- if (zone->per_cpu_zonestats != &boot_zonestats) {
- free_percpu(zone->per_cpu_zonestats);
- zone->per_cpu_zonestats = &boot_zonestats;
- }
}
}
--
2.53.0-Meta
^ permalink raw reply related [flat|nested] 9+ messages in thread
* [PATCH v2 5/5] mm/mmzone: read each zoneref slot once when filtering by nodemask
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
` (3 preceding siblings ...)
2026-10-19 17:23 ` [PATCH v2 4/5] mm/page_alloc: keep a zone's pagesets when it goes empty Gregory Price (Meta)
@ 2026-10-19 17:23 ` Gregory Price (Meta)
4 siblings, 0 replies; 9+ messages in thread
From: Gregory Price (Meta) @ 2026-10-19 17:23 UTC (permalink / raw)
To: linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, vbabka, rppt,
surenb, mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
__next_zones_zonelist() tests a slot's zone for NULL, then reads the slot
again to look up the zone's node. Offlining a zone's last block rebuilds
the zonelists in place, so the second read can return the terminator:
CPU 0 CPU 1
__next_zones_zonelist(nodemask)
zonelist_zone(slot 1) = NORMAL
offline_pages()
build_all_zonelists()
[MOVABLE, NORMAL, NULL]
-> [NORMAL, NULL]
zref_in_nodemask(slot 1)
zone_to_nid(NULL)
Current GCC and clang merge the two reads, but nothing requires it. With
mm/mmzone.o built with -fno-inline -fno-ipa-sra, holding a nodemask
allocation between the reads while the node's last movable block goes
offline oopses every time:
BUG: kernel NULL pointer dereference, address: 0000000000000060
RIP: 0010:zone_to_nid+0x0/0x10
Call Trace:
zref_in_nodemask+0x9/0x20
__next_zones_zonelist+0x23/0x70
prepare_alloc_pages.constprop.0+0x16c/0x190
__alloc_frozen_pages_noprof+0xaf/0x330
Read the slot once and test that zone. A NULL zone still ends the walk.
Fixes: 11cd8638c37f6 ("mm, page_alloc: remove stop_machine from build_all_zonelists")
Cc: stable@vger.kernel.org
Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
---
mm/mmzone.c | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/mm/mmzone.c b/mm/mmzone.c
index 9cc9ef588580d..009f1dd75dde8 100644
--- a/mm/mmzone.c
+++ b/mm/mmzone.c
@@ -43,13 +43,14 @@ struct zone *next_zone(struct zone *zone)
return zone;
}
-static inline int zref_in_nodemask(struct zoneref *zref,
- const nodemask_t *nodes)
+/* A NULL zone terminates the zonelist, so it ends the search. */
+static inline bool zone_in_nodemask(const struct zone *zone,
+ const nodemask_t *nodes)
{
#ifdef CONFIG_NUMA
- return node_isset(zonelist_node_idx(zref), *nodes);
+ return !zone || node_isset(zone_to_nid(zone), *nodes);
#else
- return 1;
+ return true;
#endif /* CONFIG_NUMA */
}
@@ -67,7 +68,7 @@ struct zoneref *__next_zones_zonelist(struct zoneref *z,
z++;
else
while (zonelist_zone_idx(z) > highest_zoneidx ||
- (zonelist_zone(z) && !zref_in_nodemask(z, nodes)))
+ !zone_in_nodemask(READ_ONCE(z->zone), nodes))
z++;
return z;
--
2.53.0-Meta
^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds
2026-10-19 17:23 ` [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds Gregory Price (Meta)
@ 2026-10-20 15:22 ` Vlastimil Babka (SUSE)
2026-10-20 17:24 ` sashiko-bot
1 sibling, 0 replies; 9+ messages in thread
From: Vlastimil Babka (SUSE) @ 2026-10-20 15:22 UTC (permalink / raw)
To: Gregory Price (Meta), linux-mm
Cc: linux-kernel, kernel-team, akpm, david, ljs, liam, rppt, surenb,
mhocko, brendan.jackman, hannes, ziy, mgorman, jiang.liu,
Bessel.Wang, stable
On 10/19/26 19:23, Gregory Price (Meta) wrote:
> Onlining the first block of a zone rebuilds every zonelist in place,
> shifting the slots after the new zone. An in-flight walk can use a
> zone it previously filtered. e.g.:
> - a GFP_KERNEL request can land in ZONE_MOVABLE,
> - a node not in the nodemask can have its zones selected.
>
> With kprobes holding the walk across the online, I was able to get this
> zone/node misplacement to happen consistently across hotplug events.
>
> The iterator approves a slot by its zone index and node, and the
> allocator then loads the zone pointer from the slot again:
>
> CPU 0 CPU 1
> first_zones_zonelist(nodemask {1})
> slot 0 (NORMAL1) approved
> online_pages()
> build_all_zonelists()
> [NORMAL1, NORMAL2, ...]
> -> [MOVABLE1, NORMAL1, ...]
> get_page_from_freelist()
> zone = zonelist_zone(slot 0) /* MOVABLE1 */
Since the zonelist_zone_valid() checks you're adding happen before the
"zone = zonelist_zone(slot 0)" part, I don't understand how they can be
helping here. Aren't we just reapproving slot 0 when CPU 1 can mess it up
between that reapproval and load? Am I missing something?
Furthermore, since we have the zonelist_update_seq anyway, wouldn't it be
better to use it to avoid these kinds of races rather than the more tricky
way of relying on what exact updates in what order can derail the allocator
side and trying to protect against those specifically?
> Every later step of the walk filters and loads the same way, so a slot
> approved for one node can also hold another node's zone by that time.
>
> Recheck the loaded zone against highest_zoneidx and the nodemask in both
> allocator loops, and skip it on a mismatch. Hot-add only inserts zones,
> so the zone that was in a skipped slot is still ahead in the walk, and a
> walk that runs off the end meanwhile is retried by the zonelist seq-check.
>
> The recheck adds about a dozen instructions per zone visited. The two
> fields it reads share a cacheline with zone->per_cpu_pageset, which the
> pcp allocation path loads anyway.
>
> Fixes: c33d6c06f60f7 ("mm, page_alloc: avoid looking up the first zone in a zonelist twice")
> Cc: stable@vger.kernel.org
> Signed-off-by: Gregory Price (Meta) <gourry@gourry.net>
> ---
> mm/page_alloc.c | 19 +++++++++++++++++++
> 1 file changed, 19 insertions(+)
>
> diff --git a/mm/page_alloc.c b/mm/page_alloc.c
> index c7125f6693574..aa3cd44fdaa5b 100644
> --- a/mm/page_alloc.c
> +++ b/mm/page_alloc.c
> @@ -3784,6 +3784,19 @@ static inline unsigned int alloc_flags_cma(gfp_t gfp_mask)
> return ALLOC_DEFAULT;
> }
>
> +/*
> + * The zonelist can be rebuilt in place while it is walked, so the iterator's
> + * filters may have passed a slot that now holds a different zone. Recheck the
> + * zone itself.
> + */
> +static inline bool zonelist_zone_valid(struct zone *zone,
> + const struct alloc_context *ac)
> +{
> + if (zone_idx(zone) > ac->highest_zoneidx)
> + return false;
> + return !ac->nodemask || node_isset(zone_to_nid(zone), *ac->nodemask);
> +}
> +
> /*
> * get_page_from_freelist goes through the zonelist trying to allocate
> * a page.
> @@ -3812,6 +3825,9 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> struct page *page;
> unsigned long mark;
>
> + if (unlikely(!zonelist_zone_valid(zone, ac)))
> + continue;
> +
> if (cpusets_enabled() &&
> (alloc_flags & ALLOC_CPUSET) &&
> !__cpuset_zone_allowed(zone, gfp_mask))
> @@ -5236,6 +5252,9 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> for_next_zone_zonelist_nodemask(zone, z, ac.highest_zoneidx, ac.nodemask) {
> unsigned long mark;
>
> + if (unlikely(!zonelist_zone_valid(zone, &ac)))
> + continue;
> +
> if (cpusets_enabled() && (alloc_flags & ALLOC_CPUSET) &&
> !__cpuset_zone_allowed(zone, gfp)) {
> continue;
^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds
2026-10-19 17:23 ` [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds Gregory Price (Meta)
2026-10-20 15:22 ` Vlastimil Babka (SUSE)
@ 2026-10-20 17:24 ` sashiko-bot
1 sibling, 0 replies; 9+ messages in thread
From: sashiko-bot @ 2026-10-20 17:24 UTC (permalink / raw)
To: Gregory Price (Meta), linux-mm
Cc: kernel-team, akpm, david, ljs, liam, vbabka, rppt, surenb, mhocko,
brendan.jackman, hannes, ziy, mgorman, jiang.liu, Bessel.Wang
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] mm/page_alloc: stale preferred_zoneref after zonelist_zone_valid() skips slot
--
commit c301c4c2a3915ab844aff8ba7e717c949c45cabd
Author: Gregory Price (Meta) <gourry@gourry.net>
mm/page_alloc: validate zonelist slots against concurrent rebuilds
This commit adds zonelist_zone_valid() to recheck the loaded zone pointer
against highest_zoneidx and the nodemask in get_page_from_freelist() and
alloc_pages_bulk_noprof(), preventing in-flight zonelist walks from using an
ineligible zone after a concurrent hotplug rebuild shifts zonelist slots.
> diff --git a/mm/page_alloc.c b/mm/page_alloc.c
> index c7125f6693574..aa3cd44fdaa5b 100644
> --- a/mm/page_alloc.c
> +++ b/mm/page_alloc.c
> @@ -3784,6 +3784,19 @@ static inline unsigned int alloc_flags_cma(gfp_t gfp_mask)
> return ALLOC_DEFAULT;
> }
>
> +/*
> + * The zonelist can be rebuilt in place while it is walked, so the iterator's
> + * filters may have passed a slot that now holds a different zone. Recheck the
> + * zone itself.
> + */
> +static inline bool zonelist_zone_valid(struct zone *zone,
> + const struct alloc_context *ac)
> +{
> + if (zone_idx(zone) > ac->highest_zoneidx)
> + return false;
> + return !ac->nodemask || node_isset(zone_to_nid(zone), *ac->nodemask);
> +}
> +
> /*
> * get_page_from_freelist goes through the zonelist trying to allocate
> * a page.
> @@ -3812,6 +3825,9 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> struct page *page;
> unsigned long mark;
>
> + if (unlikely(!zonelist_zone_valid(zone, ac)))
> + continue;
> +
> if (cpusets_enabled() &&
> (alloc_flags & ALLOC_CPUSET) &&
> !__cpuset_zone_allowed(zone, gfp_mask))
> @@ -5236,6 +5252,9 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> for_next_zone_zonelist_nodemask(zone, z, ac.highest_zoneidx, ac.nodemask) {
> unsigned long mark;
>
> + if (unlikely(!zonelist_zone_valid(zone, &ac)))
> + continue;
> +
> if (cpusets_enabled() && (alloc_flags & ALLOC_CPUSET) &&
> !__cpuset_zone_allowed(zone, gfp)) {
> continue;
[Severity: Medium]
When zonelist_zone_valid() rejects and skips the initial slot at
z == ac->preferred_zoneref after a concurrent online_pages() ->
build_all_zonelists() shifts an ineligible zone (such as ZONE_MOVABLE for a
GFP_KERNEL request, or a zone from a node outside ac->nodemask) into that
slot, does ac->preferred_zoneref remain pointing at the rejected slot for the
rest of the walk?
In both get_page_from_freelist() and alloc_pages_bulk_noprof(), the walk
starts at z = ac->preferred_zoneref (and in the follow-up commit
"mm/page_alloc: snapshot the preferred zone against shrink rebuilds",
preferred_zone = READ_ONCE(ac->preferred_zoneref->zone) is snapshotted before
the loop without validating or updating it when the initial slot is skipped).
In alloc_pages_bulk_noprof(), subsequent iterations still consult
ac.preferred_zoneref for the local-node check and watermark check, as well as
for statistics at the end of the function:
mm/page_alloc.c:alloc_pages_bulk_noprof() {
...
if (nr_online_nodes > 1 && zone != zonelist_zone(ac.preferred_zoneref) &&
zone_to_nid(zone) != zonelist_node_idx(ac.preferred_zoneref)) {
goto failed;
}
cond_accept_memory(zone, 0, alloc_flags);
retry_this_zone:
mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK) + nr_pages - nr_populated;
if (zone_watermark_fast(zone, 0, mark,
zonelist_zone_idx(ac.preferred_zoneref),
alloc_flags, gfp)) {
break;
}
...
__count_zid_vm_events(PGALLOC, zone_idx(zone), nr_account);
zone_statistics(zonelist_zone(ac.preferred_zoneref), zone, nr_account);
}
If a zone from a disallowed node was shifted into ac.preferred_zoneref, the
node comparison against zonelist_node_idx(ac.preferred_zoneref) will trigger
on the first allowed zone in ac.nodemask and jump to failed, aborting the
bulk allocation into the single-page fallback. Additionally,
zone_watermark_fast() receives zonelist_zone_idx(ac.preferred_zoneref) as
highest_zoneidx, indexing zone->lowmem_reserve[] with the rejected zone's
index (for example, ZONE_MOVABLE for a GFP_KERNEL allocation).
Similarly, in get_page_from_freelist(), later iterations still reference
ac->preferred_zoneref:
mm/page_alloc.c:get_page_from_freelist() {
...
if (no_fallback && !defrag_mode && nr_online_nodes > 1 &&
zone != zonelist_zone(ac->preferred_zoneref)) {
int local_nid;
local_nid = zonelist_node_idx(ac->preferred_zoneref);
if (zone_to_nid(zone) != local_nid) {
alloc_flags &= ~ALLOC_NOFRAGMENT;
goto retry;
}
}
...
if (!node_reclaim_enabled() ||
!zone_allows_reclaim(zonelist_zone(ac->preferred_zoneref), zone))
continue;
...
try_this_zone:
page = rmqueue(zonelist_zone(ac->preferred_zoneref), zone, order,
gfp_mask, alloc_flags, ac->migratetype);
}
Could this cause get_page_from_freelist() to treat the first valid zone on
the allowed node as remote and prematurely strip ALLOC_NOFRAGMENT, as well as
pass the rejected zone to zone_allows_reclaim() and rmqueue() ->
zone_statistics() (and prior to the follow-up commit snapshotting
preferred_zone, risk a NULL pointer dereference if a concurrent offline
shrinks the zonelist so ac->preferred_zoneref->zone becomes NULL)?
--
Sashiko AI review · https://sashiko.dev/#/patchset/20261019172346.3344005-1-gourry@gourry.net?part=2
^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds
2026-10-19 17:23 ` [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds Gregory Price (Meta)
@ 2026-10-20 17:24 ` sashiko-bot
0 siblings, 0 replies; 9+ messages in thread
From: sashiko-bot @ 2026-10-20 17:24 UTC (permalink / raw)
To: Gregory Price (Meta), linux-mm
Cc: kernel-team, akpm, david, ljs, liam, vbabka, rppt, surenb, mhocko,
brendan.jackman, hannes, ziy, mgorman, jiang.liu, Bessel.Wang
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] mm/page_alloc: unvalidated preferred_zone snapshot on zonelist rebuild
--
commit 90d885c28b5b5085097cfbd698658be9e20b2d0b
Author: Gregory Price (Meta) <gourry@gourry.net>
mm/page_alloc: snapshot the preferred zone against shrink rebuilds
This commit snapshots ac->preferred_zoneref->zone once in
get_page_from_freelist() and alloc_pages_bulk_noprof() to prevent a NULL
pointer dereference if a concurrent zonelist rebuild overwrites the preferred
slot with a NULL terminator mid-walk.
> diff --git a/mm/page_alloc.c b/mm/page_alloc.c
> index aa3cd44fdaa5b..75a440ebcead6 100644
> --- a/mm/page_alloc.c
> +++ b/mm/page_alloc.c
> @@ -3807,12 +3807,21 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> {
> struct zoneref *z;
> struct zone *zone;
> + struct zone *preferred_zone;
> struct pglist_data *last_pgdat = NULL;
> bool last_pgdat_dirty_ok = false;
> bool no_fallback;
> bool skip_kswapd_nodes = nr_online_nodes > 1;
> bool skipped_kswapd_nodes = false;
>
> + /*
> + * A concurrent zonelist rebuild can NULL or replace this slot. Zones
> + * are never freed, so reading it once keeps a usable zone.
> + */
> + preferred_zone = READ_ONCE(ac->preferred_zoneref->zone);
> + if (!preferred_zone)
> + return NULL;
> +
> retry:
> /*
> * Scan zonelist, looking for a zone with enough free.
> @@ -3862,7 +3871,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> }
>
> if (no_fallback && !defrag_mode && nr_online_nodes > 1 &&
> - zone != zonelist_zone(ac->preferred_zoneref)) {
> + zone != preferred_zone) {
> int local_nid;
>
> /*
> @@ -3870,7 +3879,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> * fragmenting fallbacks. Locality is more important
> * than fragmentation avoidance.
> */
> - local_nid = zonelist_node_idx(ac->preferred_zoneref);
> + local_nid = zone_to_nid(preferred_zone);
> if (zone_to_nid(zone) != local_nid) {
> alloc_flags &= ~ALLOC_NOFRAGMENT;
> goto retry;
> @@ -3932,7 +3941,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> goto try_this_zone;
>
> if (!node_reclaim_enabled() ||
> - !zone_allows_reclaim(zonelist_zone(ac->preferred_zoneref), zone))
> + !zone_allows_reclaim(preferred_zone, zone))
> continue;
>
> if (!node_reclaim(zone->zone_pgdat, gfp_mask, order))
> @@ -3945,7 +3954,7 @@ get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
> }
>
> try_this_zone:
> - page = rmqueue(zonelist_zone(ac->preferred_zoneref), zone, order,
> + page = rmqueue(preferred_zone, zone, order,
> gfp_mask, alloc_flags, ac->migratetype);
> if (page) {
> prep_new_page(page, order, gfp_mask, alloc_flags);
> @@ -5199,6 +5208,7 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> {
> struct page *page;
> struct zone *zone;
> + struct zone *preferred_zone;
> struct zoneref *z;
> struct per_cpu_pages *pcp;
> struct list_head *pcp_list;
> @@ -5247,6 +5257,11 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> if (!prepare_alloc_pages(gfp, 0, preferred_nid, nodemask, &ac, &gfp, &alloc_flags))
> goto out;
>
> + /* Read once; see get_page_from_freelist(). */
> + preferred_zone = READ_ONCE(ac.preferred_zoneref->zone);
> + if (!preferred_zone)
> + goto failed;
> +
> /* Find an allowed local zone that meets the low watermark. */
> z = ac.preferred_zoneref;
> for_next_zone_zonelist_nodemask(zone, z, ac.highest_zoneidx, ac.nodemask) {
> @@ -5260,8 +5275,8 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> continue;
> }
>
> - if (nr_online_nodes > 1 && zone != zonelist_zone(ac.preferred_zoneref) &&
> - zone_to_nid(zone) != zonelist_node_idx(ac.preferred_zoneref)) {
> + if (nr_online_nodes > 1 && zone != preferred_zone &&
> + zone_to_nid(zone) != zone_to_nid(preferred_zone)) {
> goto failed;
> }
>
> @@ -5269,7 +5284,7 @@ unsigned long alloc_pages_bulk_noprof(gfp_t gfp, int preferred_nid,
> retry_this_zone:
> mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK) + nr_pages - nr_populated;
> if (zone_watermark_fast(zone, 0, mark,
> - zonelist_zone_idx(ac.preferred_zoneref),
> + zone_idx(preferred_zone),
> alloc_flags, gfp)) {
> break;
> }
[Severity: Medium]
What happens if build_all_zonelists() replaces ac->preferred_zoneref->zone
with an ineligible zone before preferred_zone is read?
After prepare_alloc_pages() or __alloc_pages_slowpath() initializes
ac->preferred_zoneref via first_zones_zonelist(), a concurrent zonelist
rebuild can overwrite that slot with a zone whose index exceeds
ac->highest_zoneidx (such as ZONE_MOVABLE for a GFP_KERNEL allocation) or a
zone on a node excluded by ac->nodemask.
Both get_page_from_freelist() and alloc_pages_bulk_noprof() only check
preferred_zone for NULL without checking zonelist_zone_valid(). Inside the
zonelist walk, for_next_zone_zonelist_nodemask() reloads
ac->preferred_zoneref->zone into zone and skips it when
!zonelist_zone_valid(zone, ac):
z = ac->preferred_zoneref;
for_next_zone_zonelist_nodemask(zone, z, ac->highest_zoneidx,
ac->nodemask) {
struct page *page;
unsigned long mark;
if (unlikely(!zonelist_zone_valid(zone, ac)))
continue;
However, preferred_zone remains pointing to the rejected zone:
1. In alloc_pages_bulk_noprof(), zone_watermark_fast() receives
zone_idx(preferred_zone) as highest_zoneidx. If preferred_zone was
replaced by ZONE_MOVABLE while ac.highest_zoneidx is ZONE_NORMAL,
__zone_watermark_ok() and zone_watermark_fast() will index
zone->lowmem_reserve[ZONE_MOVABLE] instead of
zone->lowmem_reserve[ZONE_NORMAL]. And if preferred_zone was replaced by a
zone on a node outside ac.nodemask, the check
zone_to_nid(zone) != zone_to_nid(preferred_zone) will abort the bulk
allocation via goto failed as soon as it visits the first valid zone in
ac.nodemask.
2. In get_page_from_freelist(), if preferred_zone was replaced by a zone on a
disallowed node, visiting the first valid zone in ac->nodemask sees
zone != preferred_zone and zone_to_nid(zone) != zone_to_nid(preferred_zone),
prematurely clearing ALLOC_NOFRAGMENT and jumping to retry. It also passes
the disallowed preferred_zone to zone_allows_reclaim() and to
rmqueue() -> zone_statistics(), misaccounting local allocations on the
first valid node as NUMA_MISS and NUMA_FOREIGN.
Should preferred_zone be validated with zonelist_zone_valid() (or set from
the first valid zone encountered in the loop) so that an invalidated slot is
not used as the preferred zone?
[ ... ]
--
Sashiko AI review · https://sashiko.dev/#/patchset/20261019172346.3344005-1-gourry@gourry.net?part=3
^ permalink raw reply [flat|nested] 9+ messages in thread
end of thread, other threads:[~2026-10-20 17:24 UTC | newest]
Thread overview: 9+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-19 17:23 [PATCH v2 0/5] mm: fix allocator races with memory hotplug Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 1/5] mm/page_alloc: detect zonelist rebuilds from memory hot-add Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 2/5] mm/page_alloc: validate zonelist slots against concurrent rebuilds Gregory Price (Meta)
2026-10-20 15:22 ` Vlastimil Babka (SUSE)
2026-10-20 17:24 ` sashiko-bot
2026-10-19 17:23 ` [PATCH v2 3/5] mm/page_alloc: snapshot the preferred zone against shrink rebuilds Gregory Price (Meta)
2026-10-20 17:24 ` sashiko-bot
2026-10-19 17:23 ` [PATCH v2 4/5] mm/page_alloc: keep a zone's pagesets when it goes empty Gregory Price (Meta)
2026-10-19 17:23 ` [PATCH v2 5/5] mm/mmzone: read each zoneref slot once when filtering by nodemask Gregory Price (Meta)
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox