Page Migration
Page migration is the kernel mechanism that moves the contents of a physical page (frame) into a different physical frame while preserving every virtual mapping that points at it — the process keeps seeing the same virtual addresses, only the physical backing changes (kernel page-migration doc). To do this atomically against concurrent access, the kernel temporarily replaces each page-table entry that maps the page with a special migration entry — a swap-like, non-present PTE that makes any task touching the page block until the move finishes — copies the bytes to the new frame, then rewrites the entries to point at the new frame and releases the waiters. Migration is the shared primitive underneath compaction (defragmenting to free contiguous runs), NUMA balancing (moving a page nearer the CPU using it), memory hotplug (evacuating a memory block before offlining it), and CMA (the Contiguous Memory Allocator carving out a contiguous region). It is exposed to userspace through
migrate_pages(2)andmove_pages(2). Everything below is pinned to Linux 6.12 LTS / 6.18 LTS source trees.
The hard part of page migration is not copying bytes — it is doing so race-free while other CPUs may be reading, writing, faulting, or reclaiming the same page. The migration entry is the synchronization trick that makes the whole thing safe.
Mental Model
A physical page is referenced from two directions at once: from below by the page tables of every process that maps it (and the reverse-map structures that index those mappings), and from above by the page cache / address space and the LRU. To move a page you must briefly freeze both directions, copy, then re-point them — and any concurrent accessor must be parked, not allowed to read stale or torn data. The migration entry is the freeze: it is installed in place of the real PTE so a faulting accessor sleeps on the page lock instead of reading the half-moved page; once migration completes, real PTEs are restored and the sleepers re-fault straight onto the new frame.
flowchart LR subgraph BEFORE["Before"] P1["PTE (proc A)"] --> OLD["OLD frame<br/>contents X"] P2["PTE (proc B)"] --> OLD end subgraph DURING["During — frozen"] M1["migration entry<br/>(proc A) — non-present"] -.->|"accessor blocks<br/>on page lock"| OLD2["OLD frame<br/>(being copied)"] M2["migration entry<br/>(proc B)"] -.-> OLD2 OLD2 ==>|"copy bytes + flags"| NEW["NEW frame"] end subgraph AFTER["After"] P1b["PTE (proc A)"] --> NEW2["NEW frame<br/>contents X"] P2b["PTE (proc B)"] --> NEW2 end BEFORE --> DURING --> AFTER
Figure: the three states of a migration. What it shows: the page starts mapped by two processes; migration replaces both PTEs with non-present migration entries so concurrent accessors block rather than race the copy; the bytes (and page flags) are copied to a new frame; finally the entries are rewritten to point at the new frame and the waiters re-fault onto it. The insight: virtual addresses never change — only the physical frame and the page-table entries pointing at it do — so migration is invisible to the program; the migration entry is purely a synchronization device that exists only for the duration of the move.
Mechanical Walk-through
Who drives migration, and why
The in-kernel migrate_pages() function (in mm/migrate.c) takes a list of folios to move and a caller-supplied new_folio_t allocator callback that produces a destination folio for each source. The reason for the move is recorded as a migrate_reason, and the v6.12 enum (migrate_mode.h) is itself a catalogue of every migration client:
MR_COMPACTION— compaction moves movable pages out of a target pageblock to assemble a free contiguous high-order run (the dominant reason high-order/huge-page allocations succeed under fragmentation).MR_NUMA_MISPLACED— automatic NUMA balancing migrates a page to the node of the CPU that keeps faulting on it.MR_MEMORY_HOTPLUG— memory hotplug must evacuate every movable page from a memory block before it can be offlined.MR_CONTIG_RANGE—alloc_contig_range(), the engine behind the Contiguous Memory Allocator (CMA) and large contiguous allocations, migrates pages out of the requested range.MR_SYSCALL/MR_MEMPOLICY_MBIND— the userspacemigrate_pages(2)/move_pages(2)syscalls andmbind()policy changes (also cpusets).MR_MEMORY_FAILURE— soft memory-failure handling relocates data off a frame with a detected hardware error (MADV_SOFT_OFFLINE).MR_DEMOTION— tiered-memory reclaim demotes pages to a slower memory node.MR_DAMON— DAMON-driven access-aware migration.MR_LONGTERM_PIN— migrating a page out of a movable zone before a long-term GUP pin.
The three migration modes
migrate_pages() runs in one of three modes (migrate_mode.h):
MIGRATE_ASYNC— never blocks: if a folio is locked, under writeback, or otherwise busy, it is skipped rather than waited on. This is what latency-sensitive callers like NUMA balancing and the first compaction pass use, so they never stall a fault path.MIGRATE_SYNC_LIGHT— may block on the page lock but avoids waiting on slow writeback.MIGRATE_SYNC— fully synchronous: waits on the page lock and on writeback completion. Used when the move must succeed (e.g. memory hotplug offlining a block that has to be vacated).
Step 1 — isolation from the LRU
Before a folio can be migrated it must be isolated: removed from the LRU list via folio_isolate_lru(). Isolation does two things — it takes an extra reference so the folio cannot be freed underneath the migration, and it hides the folio from reclaim and other scanners so they do not also try to act on it (page_migration.rst). The caller assembles a list of isolated folios, supplies the new_folio_t allocator, and calls migrate_pages().
Step 2 — the low-level move (the 18 steps)
migrate_pages() makes several passes over its list; a folio is moved only when all references to it are removable at that moment. The kernel doc enumerates the low-level sequence (page_migration.rst); the load-bearing steps:
- Lock the source page.
- Wait for writeback to complete (in sync modes).
- Lock the destination page — locked so that any access to the not-yet-ready new page blocks immediately.
- Convert every page-table reference to a migration entry. This is the crux: each PTE mapping the page is replaced with a non-present migration entry, which decrements the page’s mapcount. If the resulting mapcount is not zero, migration aborts — there is still an unaccounted mapping. Every userspace process that now touches the page blocks on the page lock or on the migration entry being removed.
- Take the
i_pageslock (the address-space XArray spinlock), blocking mapping lookups. - Re-examine the refcount; back out if any references remain — at this point the migrator must be the only referencer.
- Re-check the XArray still points at this page (else someone modified it; back out).
- Prep the new page with the relevant settings from the old.
- Repoint the XArray at the new page.
- Drop the old page’s address-space reference, add one to the new page.
- Release
i_pages— lookups resume; blocked tasks move from spinning to sleeping on the locked new page. - Copy the page contents to the new frame. (For a folio that cannot be moved in place — e.g. a THP under a non-THP-capable path — migration may instead split it and retry the sub-pages; this is the
THP_MIGRATION_SPLITcase.) - Copy the remaining page flags.
- Clear the old page’s flags — it no longer describes anything.
- Trigger any queued writeback on the new page.
- Replace the migration entries with real PTEs, re-enabling access for processes not already blocked on the lock.
- Drop both page locks; processes waiting on the lock re-run their faults and land on the new page.
- Put the new page on the LRU, so reclaim can scan it again.
The result: the data lives in a new frame, every mapping points there, and no accessor ever observed a torn or stale copy.
The migration entry, concretely
A migration entry is encoded as a special swap entry — it occupies a non-present PTE just like a swapped-out page does, but its “swap type” is one of the migration types rather than a real swap device. In v6.12 (swapops.h) there are three: SWP_MIGRATION_READ, SWP_MIGRATION_READ_EXCLUSIVE, and SWP_MIGRATION_WRITE, constructed by make_readable_migration_entry(), make_readable_exclusive_migration_entry(), and make_writable_migration_entry() and detected by is_migration_entry(). The read/write distinction preserves the original writability of the mapping so the restored PTE has the correct permissions; the migration code can also carry the page’s “young” (accessed) state across the move via make_migration_entry_young() when the architecture supports it. Because a migration entry is non-present, the hardware MMU faults on any access, and the fault handler recognizes the migration type and calls migration_entry_wait(), parking the faulting task on the page lock until step 16/17 installs the real PTE — at which point the task re-faults cleanly onto the new frame.
The address-space callback: migrate_folio
How the bytes and metadata move depends on the page’s backing. For file-backed and other address-space-managed pages, the kernel calls the migrate_folio operation in struct address_space_operations (fs.h, v6.12):
int (*migrate_folio)(struct address_space *, struct folio *dst,
struct folio *src, enum migrate_mode);This is the modern folio-era successor to the historical migratepage callback (renamed during the folio conversion). A filesystem that does nothing special points it at the generic migrate_folio() helper, which copies contents and folio state. A filesystem that cannot migrate a dirty page and has no writeback path leaves it unset — which is why move_pages(2) returns -EIO/-EINVAL in the status array for “a dirty page cannot be moved [because] the filesystem does not provide a migration function” (move_pages(2)).
Non-LRU (driver) page migration
Migration originally targeted LRU (user/file) pages, but compaction also wants to relocate non-LRU pages such as zsmalloc (zram/zswap backing) and virtio-balloon pages, to assemble contiguous runs. A driver makes its pages movable by defining a struct movable_operations and calling __SetPageMovable() on each page; this repurposes the page->mapping field to point at the driver’s ops, so that field is then unavailable for other driver use (page_migration.rst).
Userspace Interfaces
migrate_pages(2) — move a whole process between node sets
#include <numaif.h> /* from libnuma-devel; not in glibc */
long migrate_pages(int pid, unsigned long maxnode,
const unsigned long *old_nodes,
const unsigned long *new_nodes);Moves all pages of process pid currently on any node in old_nodes to the nodes in new_nodes, preserving the relative topology where possible (migrate_pages(2), since Linux 2.6.16). It returns the number of pages that could not be moved (0 = full success). Moving another process’s pages, or moving pages shared with another process, requires CAP_SYS_NICE. Crucially, the man page warns that migrate_pages() may place pages in violation of the established memory policy — memory policy does not constrain the destination nodes.
move_pages(2) — move (or query) individual pages
#include <numaif.h>
long move_pages(int pid, unsigned long count, void *pages[count],
const int nodes[count], int status[count], int flags);Moves the listed pages of process pid to the per-page target nodes, writing each page’s result into status (move_pages(2), since Linux 2.6.18). Passing nodes == NULL turns it into a query: it does not move anything but fills status with the current node of each page — the standard way a NUMA profiler discovers where a process’s pages live. flags is MPOL_MF_MOVE (only pages used exclusively by the process) or MPOL_MF_MOVE_ALL (also pages shared across processes — needs CAP_SYS_NICE). The per-page status codes are a precise window into migration failure modes: -EBUSY (page under I/O or pinned by another subsystem — retry later), -EIO/-EINVAL (dirty page with no migration callback), -ENOMEM (no memory on the target node), -EFAULT (zero page or unmapped), -EACCES (multiply-mapped, needs MPOL_MF_MOVE_ALL). Since Linux 4.17 a positive return value is the count of non-migrated pages from non-fatal reasons. (Note the permission rules tightened over time: pre-4.13 used a UID/CAP_SYS_NICE check; since 4.13 cross-process access is governed by a PTRACE_MODE_READ_REALCREDS check, closing an ASLR-disclosure hole — see move_pages(2).)
cat /proc/<pid>/numa_maps shows where a process’s pages currently reside, the easy way to review placement before and after.
Monitoring
/proc/vmstat exposes migration counters (page_migration.rst): pgmigrate_success and pgmigrate_fail count migrated/failed pages — and for a THP or hugetlb page they increment by the number of base sub-pages (migrating one 2 MiB THP of 4 KiB pages adds 512). The THP-specific counters thp_migration_success, thp_migration_fail, and thp_migration_split distinguish a THP migrated whole, a THP that could neither be migrated nor split, and a THP that had to be split first then migrated as sub-pages; the THP counters also fold into the matching pgmigrate_* totals.
Failure Modes
-EBUSY/ migration aborts at the mapcount check. If, after converting PTEs to migration entries, the mapcount is non-zero, an un-accounted mapping or a transient reference exists; migration backs out (step 4/6). Common with pinned pages (GUP-pinned for DMA), pages under writeback in async mode, or pages another subsystem holds a reference on. This is the reasonalloc_contig_range()/CMA and hotplug-offline can fail: a single un-migratable pinned page in the target range blocks the whole contiguous allocation.- Dirty page with no
migrate_folio. A filesystem lacking a migration callback and writeback path cannot move a dirty page —-EIO/-EINVALinmove_pagesstatus. - THP that can neither migrate nor split —
thp_migration_fail; the huge page stays put and the high-order operation that wanted its frame fails. - Long-term pins defeat movable zones. A page pinned indefinitely (
MR_LONGTERM_PIN) cannot live in a movable zone; the kernel migrates it out first, and if it cannot, the pin or the movable guarantee is violated.
Alternatives and Relationship to Other Mechanisms
Page migration is not usually a user-facing choice but a primitive other mechanisms invoke:
- Memory Compaction is migration applied systematically within a zone to free contiguous runs — migration is its engine. Compaction is the “why,” migration the “how.”
- Automatic NUMA Balancing uses async migration triggered by NUMA hinting faults; the policy (which pages, when) lives there, the move here.
- Memory Hotplug uses sync migration to evacuate a block; if any page resists migration, offline fails.
- CMA /
alloc_contig_range()migrates everything out of a requested range to hand back a physically contiguous block to a device driver. - Swap (Linux Swap Subsystem) is the other way to vacate a frame — but it writes contents to disk and frees the frame, where migration moves contents to another frame in RAM. Migration preserves residency; swap surrenders it.
When you genuinely want explicit placement (a batch scheduler relocating a job’s pages after the scheduler moved the task to a distant node), the userspace migrate_pages(2)/move_pages(2) syscalls are the tool; for everything else migration happens implicitly under compaction, balancing, hotplug, and CMA.
Production Notes
The single most common place page migration visibly matters in production is high-order allocation failure under fragmentation: a 2 MiB THP or a driver’s CMA request needs a contiguous run, compaction tries to assemble it by migrating movable pages aside, and a few un-migratable pages (long-term GUP pins, kernel allocations in the way, busy pages) can make the whole high-order allocation fail or stall — visible as compaction churn, pgmigrate_fail climbing, and latency spikes on THP faults. The lesson cuts across subsystems: anything that pins a page (DMA, get_user_pages(FOLL_LONGTERM)) or makes it non-movable sabotages migration and therefore compaction, hotplug, and CMA downstream — which is why the kernel works hard to keep long-term pins out of movable zones and to migrate pages off before pinning them (MR_LONGTERM_PIN).
See Also
- Memory Compaction — migration’s biggest client; defragments by migrating movable pages to free contiguous runs.
- Automatic NUMA Balancing — async migration of pages toward the CPU faulting on them.
- Memory Hotplug — sync migration to evacuate a block before offlining.
- get_user_pages and Page Pinning — pins are the main thing that blocks migration.
- The Page Fault Handler — recognizes migration entries and parks faulting tasks until the move finishes.
- The LRU Lists — folios are isolated from the LRU before migration and returned after.
- Folios and the Folio Conversion — why the callback is
migrate_folio, not the oldmigratepage. - NUMA Memory Policies —
mbind/set_mempolicy; note migration syscalls bypass policy on the destination. - Kernel Samepage Merging — a heavily-shared KSM page’s rmap walk (bounded by
max_page_sharing) is part of migration’s cost. - MOC: Linux Memory Management MOC (§15, Advanced and Cross-Cutting MM Facilities).