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) and move_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_COMPACTIONcompaction 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_MISPLACEDautomatic NUMA balancing migrates a page to the node of the CPU that keeps faulting on it.
  • MR_MEMORY_HOTPLUGmemory hotplug must evacuate every movable page from a memory block before it can be offlined.
  • MR_CONTIG_RANGEalloc_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 userspace migrate_pages(2)/move_pages(2) syscalls and mbind() 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_DAMONDAMON-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:

  1. Lock the source page.
  2. Wait for writeback to complete (in sync modes).
  3. Lock the destination page — locked so that any access to the not-yet-ready new page blocks immediately.
  4. 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.
  5. Take the i_pages lock (the address-space XArray spinlock), blocking mapping lookups.
  6. Re-examine the refcount; back out if any references remain — at this point the migrator must be the only referencer.
  7. Re-check the XArray still points at this page (else someone modified it; back out).
  8. Prep the new page with the relevant settings from the old.
  9. Repoint the XArray at the new page.
  10. Drop the old page’s address-space reference, add one to the new page.
  11. Release i_pages — lookups resume; blocked tasks move from spinning to sleeping on the locked new page.
  12. 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_SPLIT case.)
  13. Copy the remaining page flags.
  14. Clear the old page’s flags — it no longer describes anything.
  15. Trigger any queued writeback on the new page.
  16. Replace the migration entries with real PTEs, re-enabling access for processes not already blocked on the lock.
  17. Drop both page locks; processes waiting on the lock re-run their faults and land on the new page.
  18. 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 policymemory 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 reason alloc_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/-EINVAL in move_pages status.
  • THP that can neither migrate nor splitthp_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