Keeping gigabytes whole: the gigablock design
The gigablock series is a 36-patch change to the Linux page allocator, based on
v7.3-rc1. It concentrates non-movable pageblocks in a small number of
1 GiB-aligned ranges so that the remaining ranges can still supply gigantic
hugetlb pages after the machine has been running for a long time.
Patch numbers refer to the ordered series in section 10. Measurements from older prototypes are labelled as such; they explain design choices but are not presented as measurements of this exact revision.
The code is in the riel/gigablock-2026-10-03 branch
Contents
- The problem
- Scope, terms and activation
- State and accounting
- Allocation policy and supply
- Evacuation
- Bounds, liveness and edge cases
- Measurements and validation
- Limits and deferred work
- Observability
- Patch order
1. The problem
A 1 GiB gigantic page needs a 1 GiB-aligned physical range in which every page is free or movable. One non-movable page can make the whole range unusable, even if almost all of it is free.
The buddy allocator organizes free memory by order and migratetype. Free pages
in MIGRATE_UNMOVABLE and MIGRATE_RECLAIMABLE pageblocks normally serve
kernel allocations; free pages in MIGRATE_MOVABLE pageblocks serve movable
allocations. When the requested non-movable list has no sufficiently large
buddy, the fallback path reaches the movable lists and chooses a pageblock by
free-block size, without considering which 1 GiB range contains it.
Repeated fallbacks therefore spread a small amount of non-movable memory over
many gigantic-page-sized ranges. The problem is placement, not necessarily the
amount of non-movable memory. On a 765 GiB node after 53 days of build and
filesystem work, the median 1 GiB range was 13.0% non-movable while the machine
as a whole was 13.1% non-movable, and none of 754 ranges was clear of
non-movable pageblocks. On a 253 GiB v7.3-rc1 test host, a metadata and memory
workload mixed 251 of 254 ranges in 9.2 hours and a request for eight 1 GiB pages
returned none.
The series changes the fallback decision. If a non-movable allocation must cross into movable memory, it first looks for a usable movable pageblock in a range that is already mixed. It also keeps a bounded reserve of free non-movable pageblocks in those ranges, migrates movable content out of non-movable pageblocks, and makes an unmixed range the last resort for callers that can wait or retry smaller.
flowchart TD
A["non-movable allocation"] --> B{"own migratetype has a buddy<br/>of the requested order or larger?"}
B -->|yes| C["allocate inside an existing<br/>non-movable pageblock"]
B -->|no, stock behavior| D["claim the largest movable buddy<br/>without regard to 1 GiB location"]
D --> E["mix another gigablock"]
E --> F["repeat across the zone"]
F --> G["non-movable content becomes<br/>thinly spread across most gigablocks"]
B -->|no, gigablock policy| H["search already mixed gigablocks first"]
H -->|usable block| I["claim without losing another<br/>gigantic-page candidate"]
H -->|none| J["try other non-movable type,<br/>bounded evacuation, or caller fallback"]
J -->|last resort| K["mix an unmixed gigablock<br/>rather than fail the allocation"]
A prerequisite fix at the bottom of the series restores free-page accounting
for CONFIG_DEBUG_PAGEALLOC guard pages. When a guard buddy merges,
clear_page_guard() no longer updates free counters; the merged block otherwise
reaches a free list with NR_FREE_PAGES and nr_free_nonmovable short by the
guard pages. The fix credits the cleared guard pages under the merged block's
migratetype.
2. Scope, terms and activation
Terms
| Term | Meaning | Code |
|---|---|---|
| pageblock | The migratetype unit; 512 pages at pageblock_order == 9 on x86-64 | pageblock flags |
| gigablock | A physically aligned 1 GiB span; 512 pageblocks on x86-64 | struct gigablock |
| mixed gigablock | A tracked gigablock with at least one non-movable pageblock | nr_nonmovable_blocks > 0 |
| unmixed gigablock | A tracked gigablock with no non-movable pageblock | gigablock_is_unmixed() |
| borrow | Movable content in a non-movable pageblock | PB_migrate_mixed |
| claim | A movable pageblock converted to the allocation's non-movable type | __rmqueue_claim() |
| target | The partly mixed gigablock tried first by claims | zone->gigablock_target |
| seed/refill | Whole free movable pageblocks converted ahead of demand | gigablock_claim_ahead_blocks() |
| release | Whole free non-movable pageblocks returned to movable lists | nonmovable_release_excess() |
| evacuation | Movable pages migrated out of a non-movable pageblock | gigablock_evacuate_block() |
| cycle | One traversal of the zone's gigablock index space by the shared evacuation cursor | zone->gigablock_cycle |
A gigablock is fixed at 1 GiB rather than PUD_SIZE. On arm64 with 16 KiB
pages a PUD is 64 GiB, while 1 GiB is a run of 32 PMDs that the TLB can
coalesce. Larger contiguous allocations require several aligned gigablocks.
Build and boot gate
The feature is capable only when all three configuration options are enabled:
CONFIG_CONTIG_ALLOC && CONFIG_HUGETLB_PAGE && CONFIG_ARCH_HAS_GIGANTIC_PAGE
gigablocks=off disables it at boot. A zone is tracked only when its
boot-time, 1 GiB-aligned span covers at least two gigablocks and the
per-gigablock metadata allocation succeeds. (A failed resistance-table
allocation only warns; the zone stays tracked.)
Kexec HandOver boots stay tracked. Preserved pages get initialized struct pages and sit in memblock ranges the seed reads, so scans see them as ordinary reserved memory holding non-movable content.
Stable geometry and scan readiness
Metadata is allocated from the boot-time zone span. gigablock_zone_start_pfn
and gigablock_zone_end_pfn remain fixed even if hotplug later changes the
zone. Only pageblocks wholly inside that span, with both ends in the zone,
are tracked; a hole inside the pageblock does not exclude it. A partial
pageblock at either zone boundary is left outside tracking: it is never
counted, and the steal refusal and no-mix/last-resort logic do not cover
it. On arm64 with 64 KiB pages the untracked span can reach 512 MiB at
each boundary.
The early boot seed estimates presence and migratetype before all struct pages
exist. page_alloc_init_late() recounts each gigablock after deferred struct
page initialization and hugetlb bootmem initialization, then enables a static
key. Placement, seeding, release and evacuation do no page scans before that
point.
The fixed metadata does not try to follow memory hotplug. Before memory is
moved into or out of a tracked zone, gigablock_hotplug_off() permanently sets
ZONE_GIGABLOCK_OFF, clears the target and disables all placement, marker and
evacuation operations for that zone until reboot. Existing arrays remain
allocated so in-flight readers stay within their original bounds. Debugfs
reports the zone as disabled. This applies even if a hotplug operation later
rolls back.
3. State and accounting
classDiagram
direction LR
class zone {
nr_free_nonmovable
gigablocks[]
nr_gigablocks
gigablock_scan
gigablock_release_cursor
gigablock_target
claims_since_refill
nr_mixed_gigablocks
gigablock_evac_credits
gigablock_borrow_epoch
gigablock_swept_epoch
gigablock_epoch_probe_after
gigablock_epoch_probe_pending
gigablock_evac_cursor
gigablock_cycle
gigablock_cycle_epoch
gigablock_cycle_cleared
gigablock_evac_pass
gigablock_evac_block
gigablock_evac_lock
}
class gigablock {
nr_nonmovable_blocks
nr_nonmovable_blocks_seen
total_blocks
scan_block
scan_missed
resisted[]
}
class pageblock_bits {
migratetype
PB_migrate_mixed
PB_has_nonmovable
}
class task_struct {
in_gigablock_putback
}
class pglist_data {
kcompactd_evacuate_zones
}
zone "1" --> "many" gigablock : boot-time 1 GiB spans
gigablock "1" --> "many" pageblock_bits : valid pageblocks
pglist_data "1" --> "many" zone : kcompactd bitmap
Per-pageblock state
The series adds two pageblock bits:
PB_migrate_mixed /* non-movable block may contain movable pages */
PB_has_nonmovable /* last content scan found non-movable pages */
PB_migrate_mixed is a conservative hint. It is set when a claim leaves
movable pages behind, when a movable allocation takes from a non-movable free
list, when highatomic conversion can strand movable content, when
__isolate_free_page() keeps a block non-movable, when compaction capture puts
a movable request in a non-movable block, and when an LRU arrival is observed
in a non-movable block. An evacuation scan repairs a missing hint. The bit
may remain set after the movable pages leave; that costs a scan but does not
hide work.
The bit is cleared when the pageblock becomes movable, when a whole-block buddy is added to a free list, or when compaction captures a whole block, which bypasses the free list. Moving a pageblock to a movable migratetype and clearing the bit use one masked pageblock-flags update, so a racing arrival cannot leave the type and marker in an impossible intermediate state. The mixed-marker mutators are no-ops for an untracked or hotplug-disabled zone.
There is deliberately no aggregate nr_borrowed_blocks. Maintaining an exact
level across lockless LRU arrivals and type changes introduced a race between
the bit and the aggregate. Liveness is carried by the borrow and swept epochs;
the pass always scans both marked and unmarked non-movable pageblocks.
PB_has_nonmovable is a last-observed content snapshot. Evacuation sets or
clears it after scanning a non-movable pageblock, and whole-block free clears
it. It can miss an unscanned block and can retain a stale positive until the
next scan. Placement does not use it; diagnostics print the per-gigablock
aggregate derived from it.
Hugetlb folios are counted separately. They are movable for contiguous-range analysis, but gigablock evacuation has no hugetlb isolation path, so they are excluded from the actionable movable count. The post-migration recount still sees them, which prevents a block containing hugetlb pages from being reported as cleared.
Per-gigablock state
struct gigablock {
unsigned int nr_nonmovable_blocks;
unsigned int nr_nonmovable_blocks_seen;
unsigned int total_blocks;
unsigned int scan_block;
unsigned int scan_missed;
u32 *resisted;
};
nr_nonmovable_blocks is maintained at the pageblock type-change site under
zone->lock. The first non-movable pageblock increments
zone->nr_mixed_gigablocks; removal of the last decrements it.
total_blocks contains only pageblocks the common validity predicate can walk
whole. scan_block resumes the next mixed-gigablock claim inside this
range. scan_missed accumulates misses across bounded scans and records a full
sweep only after the cursor has passed every pageblock since the last hit.
resisted[] is one u32 per pageblock when CONFIG_COMPACTION is enabled.
The low 16 bits store the actionable movable-page count, the high 16 bits the
evacuation cycle, zero means no record, and U32_MAX means armed but not
recorded. A matching record suppresses attempts in its recording cycle and
the next three, so a permanently unchanged block is retried once every four
cycles. A new allocator mark, LRU arrival, type change or whole-block free
clears it.
Per-zone and per-task state
The main zone fields are:
| Field | Purpose |
|---|---|
nr_free_nonmovable | Exact free pages on unmovable and reclaimable buddy lists; shown in /proc/zoneinfo |
nr_mixed_gigablocks | Number of tracked gigablocks with a non-movable pageblock |
gigablock_target | Preferred partly mixed gigablock, or ULONG_MAX |
gigablock_scan | Round-robin claim-search cursor |
gigablock_release_cursor | Round-robin release-selection cursor |
claims_since_refill | Smaller-only claims since the last refill/release attempt |
gigablock_evac_credits | Saturating count of requested blocks, capped at 64 |
gigablock_borrow_epoch | Lockless movable-arrival generation |
gigablock_swept_epoch | Start epoch of the last complete empty cycle |
gigablock_epoch_probe_after | Earliest time an epoch-only rejection may be probed |
gigablock_epoch_probe_pending | A claim authorized one epoch-bypass pass |
gigablock_evac_cursor | Next gigablock for the shared evacuation walk |
gigablock_cycle, _cycle_epoch, _cycle_cleared | Full-cycle liveness state |
gigablock_evac_pass, _evac_block | Marked/unmarked pass and first unchecked block within the current gigablock |
gigablock_evac_lock | Serializes kcompactd and debugfs passes |
task_struct->in_gigablock_putback prevents an evacuator's own failed-folio
putbacks from looking like new arrivals.
pgdat->kcompactd_evacuate_zones is the per-node bitmap of zones with requested
or autonomous evacuation work. There is no per-node shrink state or work item.
4. Allocation policy and supply
4.1 Closing unaccounted crossings
stateDiagram-v2
direction LR
[*] --> Movable
Movable --> NonmovableWhole : whole-run claim, seed, or refill
Movable --> NonmovableBorrowed : partly used claim; set mixed hint
NonmovableWhole --> NonmovableBorrowed : movable allocation or LRU arrival
NonmovableBorrowed --> NonmovableWhole : evacuation clears movable content; hint may remain
NonmovableWhole --> Movable : release or checked movable claim
NonmovableBorrowed --> NonmovableBorrowed : movable conversion refused while kernel content remains
Movable --> Movable : non-movable steal or capture refused
The accounting is useful only if every non-movable use of a tracked movable pageblock reaches a type change or is refused. The series closes three paths:
__rmqueue_steal()does not let a non-movable allocation take individual pages from a tracked movable pageblock while leaving its type unchanged.- A non-movable claim converts every pageblock in the free run it takes. A
partly used pageblock is converted and marked
PB_migrate_mixed. compaction_capture()refuses a tracked movable block to a non-movable request. The block reaches the movable free list and the allocation retry goes through the normal claim path, which either converts and counts it or refuses it. Other captures update the mixed marker consistently.
The explicit movable-conversion paths inspect content first.
__isolate_free_page() keeps a failing block non-movable, marks it mixed and
counts gigablock_keep_nonmovable. Highatomic unreserve keeps such a block
reserved on its first pass; later passes, including the forced pass before OOM,
release it as MIGRATE_UNMOVABLE.
4.2 Mixed-gigablock claim search
flowchart TD
A["non-movable allocation"] --> O{"own free list serves<br/>requested order?"}
O -->|yes| DONE["allocate"]
O -->|no| WALK["walk orders from MAX_PAGE_ORDER down;<br/>other non-movable type precedes movable"]
WALK --> F{"fallback selected<br/>at this order?"}
F -->|other non-movable type| CLAIM["claim if compatible"]
CLAIM -->|served| DONE
CLAIM -->|not claimable| MORE
F -->|first movable fallback| Q{"request below pageblock_order<br/>and scans ready?"}
Q -->|yes| M["try target, then round-robin<br/>eligible mixed gigablocks<br/>4 content / 256 block / 64 region limits"]
M -->|buddy of requested order or larger| MC["claim whole run or one used block;<br/>mark movable content"]
MC --> DONE
M -->|none| EV["request evacuation if gate opens"]
EV --> C
Q -->|no| C{"movable candidate is in<br/>an unmixed tracked gigablock?"}
C -->|no| CLAIM
C -->|HAS_FALLBACK| REF["refuse candidate"]
C -->|otherwise, first pass| SAVE["remember skipped candidate"]
REF --> MORE{"more orders?"}
SAVE --> MORE
F -->|none| MORE
MORE -->|yes| WALK
MORE -->|no| NF{"ALLOC_NOFRAGMENT?"}
NF -->|no| STEAL["try steal fallbacks;<br/>tracked movable blocks are refused,<br/>untracked movable blocks remain eligible"]
NF -->|yes| AFTER
STEAL -->|served| DONE
STEAL -->|none| AFTER{"ordinary skipped<br/>candidate remembered?"}
AFTER -->|yes| LAST["last-resort claim without<br/>repeating mixed search"]
LAST -->|served| SEED["claim unmixed block; seed if headroom permits"]
LAST -->|not claimable| RET
SEED --> DONE
AFTER -->|no| RET["return NULL;<br/>caller falls back or tries next zone"]
For requests below pageblock_order, the first movable fallback causes one
mixed-gigablock search:
- Try the target first, with half of the four content scans and half of the 256 pageblock checks reserved for the rest of the zone.
- Continue round-robin from
zone->gigablock_scanthrough other eligible mixed gigablocks. - Accept a movable pageblock only if it contains a buddy of the requested order or larger. A sum of smaller buddies cannot serve the allocation on this path.
- Take a buddy of
pageblock_orderor larger whole, retyping every pageblock in the run. Otherwise convert the one pageblock, move its free pages to the requested non-movable type, mark any movable content, and allocate from it. - If the search misses, continue the ordinary size-ordered fallback. The series never fails an allocation merely because placement could not help.
Each claim has three independent limits:
| Limit | Value | What it charges |
|---|---|---|
| content scans | 4 | movable pageblocks whose pages are inspected |
| pageblock checks | 256 | validity and migratetype checks |
| gigablock visits | 64 | eligible, partly mixed gigablocks offered to the scanner |
Unmixed, empty and fully non-movable descriptors do not consume the 64 eligible
visits. Charging them would create a false 64 GiB placement horizon on a large
zone and could mix a clean gigablock while a usable mixed one existed later.
The descriptor walk can therefore cover the zone; the separate pageblock and
content limits bound the expensive work. An exact-tip function-graph run under
claim load recorded a 6.329-microsecond maximum in
gigablock_alloc_flags(). It did not attribute the mixed search itself, and
that build had no IRQ-off tracer, so very large-zone IRQ-off cost remains a
measurement concern.
The target is chosen by a full scan only during boot and the late recount. At runtime no target replacement scans the zone: an empty target is invalidated by the type transition, a full target is invalidated when its next claim misses, and the next successful round-robin claim elects its gigablock. A type change that creates a partly mixed gigablock also elects it when no target exists. A fragmented, partly mixed target can still miss repeatedly, but it receives only half of the per-claim pageblock and content budgets.
4.3 Refusing an unmixed gigablock
One allocation class can postpone the last-resort crossing:
ALLOC_HAS_FALLBACK, a higher-order __GFP_NORETRY allocation with a
smaller caller-level fallback. It skips the unmixed block independent of
the gate, because the smaller retry can use existing non-movable free
memory.
At each order the other non-movable type is tried before movable. If all safer choices fail, an allocation without a fallback still takes the unmixed gigablock; preservation must not turn into allocation failure.
Bulk refills set no flag, so only the universal last-resort rule steers them; a refill that enters steal mode without a claim ends short.
4.4 Seed, refill and release
flowchart TD
MIX["claim first mixes a gigablock"] --> HS{"watermark and one-third<br/>share leave headroom?"}
HS -->|yes| SEED["seed wholly free movable runs"]
HS -->|no| HOLD1["do not seed"]
CLAIMS["64 qualifying smaller-only claims"] --> HR{"watermark and one-third<br/>share leave headroom?"}
HR -->|yes, target exists| FILL["refill wholly free runs in target"]
HR -->|yes, no target| HOLD2["wait for a target"]
HR -->|no| EXCESS{"pool is above high watermark?"}
EXCESS -->|yes| RELEASE["choose one of at most 16 round-robin candidates;<br/>release up to 32 blocks from it"]
EXCESS -->|no| HOLD3["nothing to release"]
EVAC["evacuation clears movable content<br/>from a non-movable block"] --> POOL["non-movable free lists<br/>nr_free_nonmovable"]
SEED --> POOL
FILL --> POOL
POOL --> ALLOC["future non-movable allocations<br/>avoid another crossing"]
RELEASE --> MOV["movable free lists"]
A claim that first mixes a gigablock may seed it with wholly free movable pageblocks. Every 64th qualifying smaller-only claim may refill the target. Both are limited by one snapshot of two headroom values:
- free non-movable pages stay below the zone's high watermark;
- free non-movable pages stay below one third of all free pages;
- no call intentionally takes more than 512 pageblocks, though the final buddy run is moved whole and can cross that count.
Whole-run conversion matters. Moving the free pages of a multi-pageblock buddy without changing every covered pageblock's type would put movable-typed blocks on a non-movable free list and break both accounting and fallback behavior.
Free pages later accumulate on the non-movable lists. When the pool is above
the high watermark, nonmovable_release_excess() returns wholly free
unmovable or reclaimable blocks to movable lists. It excludes the target,
looks at at most 16 candidate gigablocks from a round-robin cursor, and returns
at most 32 pageblocks per call, with the last whole buddy run allowed to cross
the count. It records both a selected gigablock with no releasable block and a
16-entry window with no candidate. Release is called from the 64-claim refill
interval even when no target exists, so a barren candidate cannot permanently
block progress elsewhere.
This series does not invoke slab shrinkers. Shrinking the slab preemptively may help prevent spilling into more gigablocks, but the first version of the series should stay smaller.
5. Evacuation
In order to concentrate kernel allocations in a small number of gigablocks, it is necessary to free up pages in those gigablocks that were used by movable allocations. The current version of the series uses kcompactd to evacuate some page blocks inside gigablocks that have both movable and non-movable allocations.
5.1 Requests and the gate
flowchart TD
A1["mixed search claims a partly used block"] --> G
A2["mixed search finds no block"] --> G{"claim-time evacuation gate"}
G -->|non-movable free at or above high| W["gate_wmark; no request"]
G -->|non-movable share at or above one third| S["gate_share; no request"]
G -->|swept epoch equals borrow epoch| E{"32-second claim probe due?"}
E -->|no| EC["gate_epoch; no request"]
E -->|yes| P["gate_probe; authorize one pass"]
G -->|open| H{"zone still has a mixed gigablock?"}
P --> H
H -->|no| EMPTY["queue helper does nothing"]
H -->|yes| QW["add saturating credit and zone bit"]
QW --> WAKE{"rmqueue or bulk exit<br/>permits a wake?"}
WAKE -->|yes, after zone lock drops| K["kcompactd admission"]
WAKE -->|no| POLL["later wake or node poll"]
POLL --> K
K --> U{"watermark, share and epoch<br/>or pending probe allow work?"}
U -->|no| KEEP["keep credits for a later pass"]
U -->|yes| TAKE["take credits; request max of credits<br/>and high-watermark shortfall"]
TAKE --> RUN["bounded evacuation pass"]
RUN --> REFUND["refund credits not satisfied"]
Two events request evacuation, each only when the claim-time gate passes: a
partly used claim made by the mixed-gigablock search, and a mixed-search miss.
Size-ordered claims mark the block but do not request a pass. A request adds
one credit with a saturating compare/exchange, up to 64, sets the zone bit in kcompactd_evacuate_zones, and defers the actual wake until after
zone->lock is released. rmqueue() wakes only for allocations carrying
ALLOC_KSWAPD, and bulk exit only for __GFP_KSWAPD_RECLAIM.
ALLOC_HAS_FALLBACK requests therefore wait for a later wake-capable allocation
or the kcompactd poll.
The gate evaluates these tests in order:
- watermark: stop if
nr_free_nonmovablealready meets the zone high watermark; - share: stop if the non-movable lists hold at least one third of all free pages;
- epoch: stop if a complete cycle cleared nothing and no observed movable arrival advanced the epoch since that cycle began.
There is no nr_borrowed_blocks or “nothing” gate. The mixed marker is a hint,
and pass 1 deliberately scans unmarked non-movable pageblocks, so an aggregate
of markers was neither necessary nor race-free.
The epoch gate has a bounded escape. A claim rejected only by the epoch test
starts a 32-second deadline. A later claim after the deadline records
gigablock_evac_gate_probe and authorizes one pass. The probe bypasses only the
epoch test: watermark and share are evaluated first. kcompactd polling honors
a pending probe but cannot create one by itself.
Wake-capable requests wake kcompactd immediately. Tracked nodes also poll at 500 milliseconds with proactive compaction enabled, backing off to 32 seconds when a proactive pass makes no progress; with proactive compaction disabled the poll is 32 seconds. A closed gate leaves the credits in place for a later poll or wake. For each zone whose gate is open, kcompactd serves the larger of queued credits and the pageblock shortfall below the high watermark, capped at 64. The gate is checked before credits are consumed. Credits a bounded pass did not satisfy are returned through the same saturating helper.
5.2 One pass
flowchart TD
START["hold gigablock_evac_lock;<br/>resume shared gigablock, phase, and block cursors"] --> STOP{"one cycle or visit limit, requested clears,<br/>fatal signal, zone disabled, or -ENOMEM?"}
STOP -->|yes| SAVE["save first unchecked block<br/>or next unvisited gigablock"]
STOP -->|no| MIX{"gigablock is mixed<br/>and has valid pageblocks?"}
MIX -->|no| NEXT["advance gigablock cursor"]
MIX -->|yes| PHASE{"saved phase"}
PHASE -->|0| MARKED["scan marked non-movable blocks"]
PHASE -->|1| UNMARKED["scan unmarked non-movable blocks"]
MARKED --> CAND["check one pageblock in one IRQ-off section"]
UNMARKED --> CAND
CAND --> LIMIT{"check or attempt limit reached?"}
LIMIT -->|yes| SAVE
LIMIT -->|no| COUNT["count kernel, reserved, and actionable<br/>movable pages net of hugetlb"]
COUNT -->|unchanged resistance record| SKIP["skip until record expires"]
COUNT -->|no actionable movable pages| STALE["count stale"]
COUNT -->|actionable pages| MIG["repair hint; arm record;<br/>drain LRU batching; migrate"]
MIG --> RECOUNT["recount with hugetlb included"]
RECOUNT -->|no movable page remains| CLEAR["clear one requested block"]
RECOUNT -->|movable pages remain| STUCK["record stuck reason;<br/>an error reports aborted"]
STUCK --> FATAL{"migration error is -ENOMEM?"}
FATAL -->|yes| SAVE
FATAL -->|no| MORE
SKIP --> MORE{"more blocks in this phase?"}
STALE --> MORE
CLEAR --> MORE
MORE -->|yes| CAND
MORE -->|no, phase 0| SWITCH["switch to unmarked phase"]
SWITCH --> UNMARKED
MORE -->|no, phase 1| NEXT
NEXT --> STOP
The pass walks mixed gigablocks from a shared cursor. Within each gigablock it
scans non-movable pageblocks in two phases: blocks marked
PB_migrate_mixed, then unmarked blocks. It checks one pageblock in each
IRQ-off section and reschedules between checks.
For a candidate block it:
- counts non-movable, reserved and actionable movable pages, hugetlb
netted out, under
zone->lockand refreshesPB_has_nonmovable; - skips an unchanged resistance record;
- repairs a missing mixed marker;
- drains LRU batching lazily, only before the first migration attempt;
- isolates and migrates movable folios without
zone->lock; - puts failed folios back after each batch while suppressing false arrival accounting;
- recounts the block and calls it cleared when no movable page remains, hugetlb included; otherwise it records the stuck reason.
Passes use MIGRATE_SYNC_LIGHT, disable LRU caches globally from the first
migration attempt until the pass ends, and may record resistance.
The series does not empty and claim movable pageblocks as an evacuation fallback. That recovery experiment was dropped because it migrates from a block that remains movable while migration runs, so destination allocations can reuse the just-freed source pages. Its aggregate free-page precheck guaranteed neither a destination of the needed order nor a coalesced pageblock buddy.
5.3 Bounds and resume state
A kcompactd or debugfs pass tries migration in at most 64 blocks, checks at most 256 pageblocks and visits at most 64 gigablocks.
A failed or stale candidate consumes a check but not a requested clear. The attempt bound limits expensive migration even when nothing clears; the check and visit bounds keep LRU batching and the shared mutex from spanning a zone.
If a bound, signal or -ENOMEM stops the pass inside a gigablock, the saved
phase and pageblock point to the first unchecked work. If the gigablock is
finished, the gigablock cursor points to the next unvisited one. This avoids
restarting at a resistant prefix.
5.4 Resistance and cycles
stateDiagram-v2
[*] --> None
None --> Armed : pass before migration
Armed --> Recorded : cmpxchg stores actionable count and cycle
Armed --> None : block cleared or concurrent arrival clears slot
Armed --> Armed : migration error stores nothing
Recorded --> None : new mark, LRU arrival, type change, or whole-block free
Recorded --> Recorded : same count in recording cycle plus next three
Recorded --> Armed : nonzero count changed or record expired
Before a migration the pass writes GIGABLOCK_RESIST_ARMED. If no
arrival clears it while the lock is dropped, the pass stores the actionable
movable-page count and cycle with cmpxchg(). A matching record suppresses
another attempt in its recording cycle and the next three; the block is
retried once every four cycles. Different content with the same count cannot
hide behind the record because arrivals and type changes clear it first. A
migration error leaves the armed value, which matches no count.
The unbounded cursor value identifies the cycle as
cursor / nr_gigablocks. A cycle records its starting borrow epoch.
sequenceDiagram
participant E as evacuation cursor
participant Z as zone cycle state
participant A as movable arrivals
E->>Z: enter cycle, cycle_epoch = borrow_epoch, cycle_cleared = false
A->>Z: new mixed mark increments borrow_epoch
A->>Z: first arrival in a cycle advances epoch with cmpxchg
E->>Z: clear a block, cycle_cleared = true
E->>Z: cross into next cycle
alt finished cycle cleared nothing
E->>Z: swept_epoch = prior cycle_epoch
alt borrow_epoch still equals swept_epoch
Note over Z: epoch gate closes
else an arrival advanced borrow_epoch
Note over Z: epoch gate remains open
end
else finished cycle cleared work
Note over Z: epoch gate remains open
end
When a cycle finishes without clearing a block, it copies the cycle's start
epoch to gigablock_swept_epoch. If no arrival advanced
gigablock_borrow_epoch, the equal values close the epoch gate. A movable
arrival advances the borrow epoch once per cycle, so an arrival the cursor
already passed still keeps the gate open for another traversal.
6. Bounds, liveness and edge cases
Lock and latency bounds
- The mixed claim search reads at most four pageblocks and checks at most 256. Its 64-region budget charges only eligible mixed gigablocks; descriptor-only skips remain a large-zone measurement concern.
- Seed/refill and release hold
zone->lock, but operate on one gigablock at a time and have explicit block budgets. Release candidate selection looks at 16 gigablocks, not the whole zone. - Runtime target replacement never scans the zone.
- Evacuation checks one pageblock per IRQ-off section and has attempt, check and visit limits.
extfrag/gigablock_contiguses a private seq iterator and emits one gigablock per record, so a small userspace read buffer does not rescan the same gigablock's pages.
Allocation safety
- Skipping an unmixed gigablock never fails an allocation by itself. A fallback caller retries at a smaller order; every other allocation takes the block on the last-resort claim.
gigablocks=off, an incapable build and a hotplug-disabled zone make mixed marker mutators and all policy entry points no-ops.
Hotplug, holes and boundaries
The boot-time metadata is not resized. Hotplug disables the affected tracked zone before its span changes, and all type updates, markers, claims, refill, release, evacuation and diagnostics honor that state. The gigablock listing reports “tracking disabled after memory hotplug”; the contig reader skips the disabled zone.
The common validity rule requires both ends of a pageblock to be in the same zone and online before runtime scans touch it. Holes and partial boundary blocks remain outside the counts. This costs capacity at boundaries but avoids walking uninitialized or foreign struct pages.
Gigantic hugetlb pages
Evacuation's compaction control uses whole_block_folio rather than
alloc_contig, so it can isolate an ordinary pageblock-sized folio without
entering the hugetlb dissolve path. Hugetlb folios have no gigablock evacuation
path. They are excluded from actionable movable counts and resistance
fingerprints, but retained in the final recount. A block that also held
actionable pages can be reported as stuck reason hugetlb; a block whose only
movable count is hugetlb is stale and is not migrated. gigablock_contig
reports huge only for a folio of at least 1 GiB. On architectures whose
hstate supports migration, smaller hugetlb folios pass its unmovable-page test
and are not blockers. alloc_contig_range() behavior is unchanged.
Disabled automatic slab shrink
An automatic global/root slab pass was implemented and tested, but not retained. On matched 256 GiB hosts creating 39.6 million empty files, the treatment ran 3,120 passes, freed 392 objects in 316,812 microseconds, and did not reduce slab, mixed-gigablock or non-movable-pageblock growth. The final series contains no shrink work item, memcg iteration, shrink state, vmstat events or tracepoint. Slab remains ordinary reclaim's responsibility.
7. Measurements and validation
Historical placement soak
Two identical single-node 256 GiB Bergamo machines ran an earlier prototype for 22.1 hours under anonymous memory, tmpfs, 64 GiB of page cache and a slow stream of dentries, sockets and page tables. That prototype differed materially from the final series: claim admission used total free pages rather than a buddy of the requested order, frozen kernel pages were misclassified, failed migration folios were retried within a block, the evacuator's own putbacks looked like arrivals, and pass bounds and target handling were older.
| Result | Host A | Host B |
|---|---|---|
| mixed gigablocks | 33 of 254 | 32 of 254 |
| 1 GiB pages from a request for eight | 8 in 40 ms | 8 in 441 ms |
| mixed-gigablock claims / target-served | 1,036 / 1,030 | 788 / 783 |
| pageblocks cleared by evacuation | 9,076 | 9,053 |
| pages migrated | 583 million | 570 million |
| direct passes | 0 | 0 |
Stock v7.3-rc1 on the same class of hardware and workload mixed 251 of 254
gigablocks in 9.2 hours and supplied none of eight requested 1 GiB pages. A
matched 17-hour run mixed 252 of 254 and supplied 15 of 100 requests. These
results establish the placement effect, not the exact cost or behavior of the
final implementation.
Bulk wake test
A 250 GiB test host running a recent version of the code made 4,096 calls
to alloc_pages_bulk(GFP_NOWAIT | __GFP_NOWARN) for 512 pages each.
GFP_NOWAIT carries __GFP_KSWAPD_RECLAIM, which the later wake condition
requires. Of those, 312 calls queued evacuation work. Every one observed a
wake before the bulk call returned; first request to first wake was at most 143
microseconds and 36.9 microseconds on average. Afterwards the host allocated
and released eight 1 GiB pages without a warning. This predates the final
fold but exercises the bulk wake mechanism retained in it.
Final revision validation
The final validation covered:
- every exact final tree built per commit for
CONFIG_COMPACTION=y,CONFIG_COMPACTION=n, andCONFIG_COMPACTION=nwith CMA enabled; - final bzImage builds for those configurations and
CONFIG_MEMCG=n; - checkpatch with no errors (two quoted-string-split warnings in one zoneinfo hunk); changelog, static and function-growth checks;
- exact-tip warning-free VM ramfs, churn, wake, contiguous-allocation,
baseline, hugetlb and duration-control workloads; the exact-tip config had
lockdep and
DEBUG_VMdisabled; - a separate
CONFIG_ACPI_HOTPLUG_MEMORY=ykernel withmemhp_default_state=offline: 2 GiB add, explicit online into tracked Normal, permanent tracking disable, offline, device removal, seq output, evacuation and contig reads. The default auto-online run placed the DIMM in untrackedZONE_MOVABLEand correctly did not exercise the disable path. QMP removed the DIMM device in both runs, while immediateobject-delof its backend returned “in use”; - exact-tip bare-metal boot, metadata walk, evacuation request and four 1 GiB hugepage allocations with no kernel warning. The evacuation request scanned 155 pageblocks and cleared none.
- an exact-tip KHO round-trip in a 16 GiB VM (
test_kho, 1 GiB preserved,PROVE_LOCKING+DEBUG_VM): first boot tracked, kexec into a KHO boot still tracked,KHO restore succeeded, a debugfs evacuation write ran clean, and the serial log had no warning, lockdep report or oops.
The ENOMEM harness completed, but the build lacked fail_page_alloc; its four
warning matches were intentional SysRq-L traces. Exact-tip function-graph
maxima were 45.298 microseconds for background evacuation and 6.329
microseconds during claim load in gigablock_alloc_flags(). Wall-clock
time was 0.56 milliseconds for the evacuation write. The build had no
IRQ-off tracer, so these are function and wall-time measurements rather than
IRQ-off bounds. The VM, hotplug and bare-metal runs above used the 39-patch
pre-drop tip c3b15041d6c5; no direct pass ran in any of them, and the
per-commit and bzImage builds were re-run on this exact tip.
8. Limits and deferred work
Accepted limits
These are behaviors the series ships with, documented so reviewers know what not to expect.
- The current test evidence does not include injected
fail_page_allocENOMEM. - Skipping an unmixed gigablock can move the allocation to the next zone in the zonelist, a lower local zone or a remote node, before the caller falls back or the last-resort claim runs. That ordering has no locality preference.
- Movable-typed pageblocks are never evacuation candidates. A non-movable allocation that finds a free page in one converts the whole block, marks it mixed, and evacuation then moves the movable strays out — but a block that is 100% full of page cache never enters that chain until reclaim frees something in it. Migrating a movable block's contents out without needing a free page first (the shelved recovery experiment) would close that gap.
- Orders at or above
pageblock_orderuse the size-ordered claim path rather than the mixed-gigablock search. - Highatomic reservation and cross-type buddy adoption can still choose a gigablock without placement policy. Their type changes remain accounted.
- A movable page allocated with
__GFP_MOVABLEthat never reaches the LRU or registersmovable_opscan remain non-movable content in a movable block, with nothing identifying the producer. - Resistance is a count fingerprint, not a classification of every migration failure. A permanently stuck block is retried once every four cycles.
- Release is driven by the 64-claim refill interval. A zone with no qualifying claims does not run release autonomously.
- Dynamic hotplug metadata is not implemented. A span change disables the affected zone until reboot.
- Partial boundary pageblocks are outside tracking, counting and placement protection; the untracked span can reach 512 MiB at each boundary on arm64 with 64 KiB pages.
Placement and search follow-ups
- Untracked edge and hole-adjacent blocks serve kernel demand only through the normal fallback order. Trying them first, since they can never form part of a gigantic page, remains future work.
- The mixed-gigablock claim search scans in the allocation hot path under
zone->lockwith interrupts off. Moving that work into watermark-maintenance slow paths, so claims need no hot-path scan, remains future work. Reducing claim-ahead overhead in the fast path is part of the same effort. - A fragmented, partly mixed target can consume its half of the search budget on repeated misses. Runtime replacement is bounded and event-driven, not an order-specific target cache; whether an order-aware cache pays for itself is unmeasured.
- The eligible-only 64-gigablock budget does not bound descriptor reads across a very large zone. The expensive content and pageblock work is bounded; the descriptor-only worst case remains a measurement gate.
nonmovable_release_excess()picks a gigablock by typed-block count, which says nothing about whether it holds wholly free blocks, then scans it. Walking the non-movable free lists atpageblock_orderand above instead, returning whole free runs until back under the threshold with no gigablock selection, remains future work.- Block device cache is allocated as UNMOVABLE, but has a .migrate_folio callback. If a system does lots of raw block device IO, that can result in many gigablocks becoming unusable for gigantic pages, for no good reason.
Evacuation follow-ups
- The evacuation pass and its helpers live in
mm/page_alloc.c. Cleaning them up and moving them to a better home remains future work. - Evacuation requests name a zone, and each pass scans from the zone cursor. Requesting evacuation for a particular gigablock, so passes go straight to the work, remains future work. Once evacuation and compaction are more targeted, the compaction epoch can be replaced with something smarter.
- Background evacuation reuses kcompactd, and the request, wake, gate and credit plumbing around it is larger than the earlier standalone evacuator. Two simplifications are on the table: separate evacuation code instead of reusing kcompactd, or async evacuation from a work item on the dropped direct-evacuation shape, either of which would shed most of the plumbing. Direct evacuation itself was dropped before the first posting for lack of test coverage.
Tech debt
- page_alloc.c is already too large, things should probably be split out somehow.
Series-order and naming follow-ups
- Move "mm/page_alloc: track pageblocks with non-movable pages seen by scans" and "mm/vmstat: report non-movable pageblocks per gigablock" earlier in the series, so reviewers see before-and-after results with the other allocator changes.
- Go over all the code and comments again, to make sure terminology is consistent with the rest of mm/
- Extract the fixes that can go upstream indendently of this series.
- Move gigablock slow path code into its own gigablock.c file.
9. Observability
/proc/vmstat
| Area | Counters |
|---|---|
| crossings | gigablock_claim_free_block, gigablock_claim_used_block, gigablock_movable_claim_free, gigablock_movable_claim_used, gigablock_convert_to_nonmovable, gigablock_convert_to_movable |
| claim reason | gigablock_claim_smaller_only, gigablock_claim_nothing_free |
| mixed search | gigablock_claim_from_mixed, gigablock_claim_full_scan, gigablock_claim_partial_scan, gigablock_claim_scan_limit, gigablock_no_movable_blocks |
| target and supply | gigablock_target_tried, gigablock_target_served, gigablock_target_moved, gigablock_claim_ahead_blocks, gigablock_claim_ahead_wmark, gigablock_claim_ahead_clamped, gigablock_refill |
| release | gigablock_release_block, gigablock_release_empty, gigablock_release_no_candidate |
| unmixed refusal | gigablock_mix_fallback, gigablock_mix_avoided |
| gate | gigablock_evac_gate_open, gigablock_evac_gate_epoch, gigablock_evac_gate_probe, gigablock_evac_gate_wmark, gigablock_evac_gate_share |
| cycles and requests | gigablock_evac_epoch_arrival, gigablock_evac_cycle_empty, gigablock_evac_request |
| evacuation | gigablock_evac_scanned, gigablock_evac_cleared, gigablock_evac_migrated |
| refused conversions and arrivals | gigablock_steal_skipped, gigablock_keep_nonmovable, gigablock_movable_added_marked, gigablock_movable_added_unmarked, highatomic_free_to_movable |
| contiguous search | contig_frozen_range_skipped, contig_frozen_range_deferred, contig_frozen_range_taken, contig_frozen_range_failed |
There are no recovery, “nothing” gate, or automatic-shrink counters in the final series.
/proc/zoneinfo and debugfs
/proc/zoneinfoprintsfree_nonmovablefor each zone./sys/kernel/debug/extfrag/gigablocksprints one line per tracked gigablock: its type count, last-observed content count and valid pageblock count. A hotplug-disabled zone prints one disabled line.extfrag/gigablock_contigreports one gigablock per seq record: absent, reserved, gigantic-hugetlb and in-use pageblocks, followed by the in-use page classes when present. It holds the memory-hotplug read lock across the seq operation and does not rescan a record merely because userspace used a small buffer.- Writing a block count to
extfrag/gigablock_evacuaterequests a debugfs pass that bypasses the gate but still obeys the background attempt, check and visit limits. Only debugfs passes print detailed evacuation lines to dmesg.
The debugfs files require CONFIG_DEBUG_FS and CONFIG_COMPACTION;
gigablock_contig also requires CONFIG_CONTIG_ALLOC.
10. Patch order
| # | Subject |
|---|---|
| 1 | mm/page_alloc: count a merged buddy's pages under the merged type |
| 2 | mm/page_alloc: count guard pages as free again when their buddy merges |
| 3 | mm/page_alloc: count non-movable pageblocks per gigablock |
| 4 | mm/page_alloc: mark non-movable pageblocks that hold movable pages |
| 5 | mm/page_alloc: check a pageblock's pages before converting it to movable |
| 6 | mm: count movable pages added to non-movable pageblocks |
| 7 | mm/page_alloc: mark the pageblock where a movable page lands |
| 8 | mm/page_alloc: count crossings between the non-movable and movable sides |
| 9 | mm/page_alloc: convert any movable pageblock on a non-movable allocation |
| 10 | mm/page_alloc: refuse compaction capture of tracked movable blocks |
| 11 | mm/page_alloc: skip movable blocks during non-movable steals |
| 12 | mm/page_alloc: prefer claiming pageblocks from mixed gigablocks |
| 13 | mm/page_alloc: count full and partial gigablock scans |
| 14 | mm/page_alloc: count why a pageblock claim happens |
| 15 | mm/page_alloc: split the per-zone range scan out of the contiguous search |
| 16 | mm/compaction: centralize the alloc_contig isolation exception |
| 17 | mm/compaction: let a caller isolate the folio that fills a pageblock |
| 18 | mm/page_alloc: evacuate movable pages from mixed gigablocks |
| 19 | mm/page_alloc: disable the LRU caches only when a block is about to move |
| 20 | mm/page_alloc: skip a block that still holds what evacuation could not move |
| 21 | mm/compaction: evacuate mixed gigablocks when a claim finds no block |
| 22 | mm/page_alloc: ask for evacuation when a claim leaves movable pages behind |
| 23 | mm/page_alloc: concentrate non-movable claims in one gigablock |
| 24 | mm/page_alloc: count free pages on non-movable free lists |
| 25 | mm/page_alloc: seed a newly mixed gigablock with free blocks |
| 26 | mm/page_alloc: refill the target gigablock with whole blocks |
| 27 | mm/page_alloc: wake kcompactd only when a pass can help |
| 28 | mm/page_alloc: expire stale resistance records |
| 29 | mm/page_alloc: return excess free blocks to movable lists |
| 30 | mm/compaction: let kcompactd start an evacuation pass on its own |
| 31 | mm/page_alloc: skip unmixed gigablocks for an allocation with a fallback |
| 32 | mm/page_alloc: try the other non-movable type before unmixed gigablocks |
| 33 | mm/page_alloc: track pageblocks with non-movable pages seen by scans |
| 34 | mm/vmstat: report non-movable pageblocks per gigablock |
| 35 | mm/page_alloc: report what stops each gigablock supplying a gigantic page |
| 36 | mm/page_alloc: count the outcome of each range a contiguous alloc tries |
In groups: patch 1 repairs allocator accounting needed by the later free-list limits; patches 2-10 establish gigablock accounting and close hidden crossings; 11-13 implement and measure bounded mixed-gigablock claims; 14-16 prepare the contiguous and compaction paths; 17-21 add evacuation and its request paths; 22-25 add target placement and bounded supply; 26-32 add gate liveness, resistance expiry, bounded release, autonomous kcompactd work, fallback and last-resort ordering; 33-36 add scan-observed counts and contiguous-range reporting and outcome counters.
