Transparent Huge Pages

Transparent Huge Pages (THP) is the Linux kernel mechanism that automatically and opportunistically backs a process’s virtual memory with huge pages — large, physically-contiguous, naturally-aligned page-table mappings (classically the Page Middle Directory (PMD)-sized 2 MiB page on x86-64, alongside the 4 KiB base page) — without the application having to reserve them up front the way hugetlbfs demands. The kernel either allocates a huge page directly in the page-fault path when a mapping is large and aligned, or lets the [[khugepaged and THP Collapse|khugepaged]] daemon promote runs of base pages into huge pages after the fact (admin guide, v6.12). The payoff is fewer Translation Lookaside Buffer (TLB) misses — one TLB entry covers 2 MiB instead of 4 KiB — and 512× fewer page faults over a region; the cost is allocation latency, internal fragmentation (a 2 MiB page whose owner touches one byte still wires up 2 MiB), and the splitting/collapse machinery needed to keep the illusion graceful. THP works for anonymous memory and tmpfs/shmem only, and since 6.8 also supports multi-size THP (mTHP) — PTE-mapped huge pages smaller than the PMD size.

Version pin. Everything below is read against the Linux 6.12 LTS tree (v6.12, released 2024-11-17, still maintained — 6.12.107 has shipped). Where a behaviour changed after 6.12 the change is called out with the release that introduced it, verified by fetching the same file at several tags and comparing. Mainline as of this writing is 7.2 (v7.2 Makefile, VERSION = 7 / PATCHLEVEL = 2, “Baby Opossum Posse”).

Source note. lwn.net returned HTTP 429 to direct fetches throughout this research; every LWN article and mail-archive posting cited below was therefore read through the Internet Archive, and both URLs are listed in sources:. Kernel source and documentation were read with curl from raw.githubusercontent.com at explicit tags, never from memory; every “landed in release X” claim below is an existence check — the same file fetched at consecutive tags, comparing presence and absence.

THP is one of two ways to use huge pages in Linux; see Huge Pages Overview for how it contrasts with the explicit-reservation hugetlbfs route, and Compound Pages and Large Folios for the struct folio machinery that physically represents a huge page.

Mental Model — Promotion and Demotion Are Both Always Available

Think of THP as the kernel quietly upgrading your memory behind your back whenever it can, and gracefully downgrading it whenever something cannot cope with a huge page. A 2 MiB region that is mapped, aligned, and faulted in one go gets a single huge PMD entry — one TLB slot maps the whole 2 MiB. The moment any code path that does not understand huge PMDs touches part of that region (an mprotect() on half of it, a partial munmap(), a swap-out), the kernel splits the huge mapping back down to 512 ordinary Page Table Entries (PTEs), each pointing at a 4 KiB sub-page, and everything keeps working. This “graceful fallback” is the core design principle of THP (design doc, v6.12): a huge page is always something the kernel can fall back from.

flowchart TD
  FAULT["Page fault on a large,<br/>aligned anon/shmem region"] -->|"defrag policy allows it,<br/>order-9 run available"| HUGE["PMD-mapped 2 MiB THP<br/>one TLB entry covers 2 MiB"]
  FAULT -->|"no contiguous run,<br/>VM_FAULT_FALLBACK"| MTHP["mTHP: highest enabled order<br/>16K..1M, PTE-mapped"]
  MTHP -->|"no order enabled or<br/>no run of that order"| BASE["512 x 4 KiB base pages<br/>PTE-mapped, order-0"]
  BASE -->|"khugepaged scans and<br/>collapses later"| HUGE
  HUGE -->|"mprotect / partial munmap"| SPLITPMD["split_huge_pmd:<br/>mapping only, never fails"]
  SPLITPMD --> PTEMAP["same 2 MiB folio,<br/>now 512 PTEs"]
  HUGE -->|"swap-out, migration,<br/>underused shrinker"| SPLITPAGE["split_huge_page:<br/>splits the folio, can fail"]
  SPLITPAGE --> BASE
  PTEMAP -->|"partial unmap"| DEFER["deferred split list,<br/>split under memory pressure"]
  DEFER --> BASE

The THP life cycle as a two-way street. What it shows: a fault on a suitable region gets, in strict preference order, a PMD-sized huge page, then the highest enabled mTHP order, then base pages; khugepaged can promote base pages afterwards; and two distinct split operations demote in the other direction — one that only rewrites page tables and one that actually takes the folio apart. The insight: THP is never a one-way commitment. Every promotion has a corresponding demotion path, and that is precisely what lets THP be “transparent” — no caller is ever stuck holding a huge page it cannot deal with. Note also that the two splits are not the same operation: split_huge_pmd cannot fail, split_huge_page can.

What “PMD-mapped” actually means in the page tables

The word “huge page” hides the mechanism. On x86-64 with 4-level paging, a virtual address is chopped into 9-bit indices, one per level, plus a 12-bit page offset; each table is 512 entries × 8 bytes = one 4 KiB page (pgtable_64_types.h, v6.12: PGDIR_SHIFT 39, PUD_SHIFT 30, PMD_SHIFT 21, PTRS_PER_PMD 512, PTRS_PER_PTE 512). A huge page is not a special kind of memory — it is a PMD entry with the hardware’s “page size” bit set, so the walk stops one level early and the remaining 21 bits become the offset.

Virtual address, x86-64, 4-level paging
 63      48 47    39 38    30 29    21 20    12 11         0
+---------+--------+--------+--------+--------+------------+
|  sign   |  PGD   |  PUD   |  PMD   |  PTE   |   offset   |
| extend  | 9 bits | 9 bits | 9 bits | 9 bits |  12 bits   |
+---------+--------+--------+--------+--------+------------+

BASE-PAGE MAPPING (4 KiB)              PMD-MAPPED HUGE PAGE (2 MiB)
  PGD -> PUD -> PMD -> PTE table         PGD -> PUD -> PMD
                        |                              |  (PSE bit set:
                        v                              v   walk stops here)
                  +-----------+                 +-------------------+
                  | 4 KiB pg  |                 |   2 MiB physical  |
                  +-----------+                 |   contiguous run  |
   4 memory refs to translate                   |   (order-9 folio) |
   1 TLB entry covers 4 KiB                     +-------------------+
   512 PTEs + 1 PTE table page                3 memory refs to translate
   needed to cover 2 MiB                      1 TLB entry covers 2 MiB
                                              0 PTE table pages needed

Page-table walk for a base page versus a PMD-mapped huge page. Fallback used: an ASCII box diagram rather than mermaid, because this is a bit-field decomposition plus a tree walk shown side by side — packet-beta can draw the address split but not the two walks next to it. What it shows: the address decomposition into four 9-bit indices, and how setting the page-size bit in the PMD terminates the walk one level early. The insight: a huge page buys three things at once, and they are usually conflated — a shorter walk on a TLB miss (3 memory references instead of 4), 512× more virtual address space per TLB entry, and the elimination of an entire 4 KiB PTE table page per 2 MiB of mapping. The last of these is invisible in benchmarks but real: a 24 GiB anonymous mapping needs 48 MiB of PTE tables at base-page granularity and essentially none when PMD-mapped, which is why [[Copy-on-Write and fork|fork()]] of a large process is dramatically cheaper under THP.

Why Huge Pages Help — the Mechanism, Quantified

The kernel doc spells out two effects, and is unusually blunt about their relative importance (admin guide, v6.12). The first factor is “almost completely irrelevant” — its own words — and is fault reduction: touching a 2 MiB region takes a single fault instead of 512, “reducing the enter/exit kernel frequency by a 512 times factor”. The doc immediately qualifies this: it “only matters the first time the memory is accessed for the lifetime of a memory mapping”, and it “will also have the downside of requiring larger clear-page copy-page in page faults which is a potentially negative effect”. Zeroing 2 MiB in one fault is a 2 MiB memset on the critical path of that fault.

The second factor is the one that matters, and it “will affect all subsequent accesses to the memory for the whole runtime of the application”. It has two components: “(1) the TLB miss will run faster (especially with virtualization using nested pagetables but almost always also on bare metal without virtualization)” and “(2) a single TLB entry will be mapping a much larger amount of virtual memory in turn reducing the number of TLB misses.” Under virtualization the doc notes the compounding: “With virtualization and nested pagetables the TLB can be mapped of larger size only if both KVM and the Linux guest are using hugepages but a significant speedup already happens if only one of the two is using hugepages just because of the fact the TLB miss is going to run faster.”

The scarce resource being defended is TLB reach — the total amount of memory the TLB can map at one time. Reach is simply entries × page size, so the arithmetic is unforgiving:

TLB entries (illustrative)Reach with 4 KiB pagesReach with 64 KiB mTHPReach with 2 MiB PMD THP
64256 KiB4 MiB128 MiB
5122 MiB32 MiB1 GiB
20488 MiB128 MiB4 GiB

TLB reach as a function of entry count and page size (entry counts are illustrative round numbers, not a specific microarchitecture — reach is just the product). The insight: no plausible TLB has enough entries to cover a multi-gigabyte working set at 4 KiB granularity; the only lever that moves reach by orders of magnitude is page size. This is why a workload whose working set fits in TLB reach under THP and does not under base pages shows a step change, not a gradual one — and why workloads with small or sequential working sets show nothing at all.

flowchart LR
  VA["CPU issues a<br/>virtual address"] --> TLB{"TLB lookup"}
  TLB -->|"hit — the common case,<br/>~1 cycle, no memory traffic"| PA["physical address<br/>translation done"]
  TLB -->|"miss"| WALK{"what does the<br/>PMD entry hold?"}
  WALK -->|"a pointer to a PTE table<br/>(base-page mapping)"| W4["page-table walk:<br/>PGD, PUD, PMD, PTE<br/>= 4 memory references"]
  WALK -->|"a leaf with the<br/>page-size bit set (THP)"| W3["page-table walk:<br/>PGD, PUD, PMD<br/>= 3 memory references"]
  W4 --> FILL["fill a TLB entry<br/>covering 4 KiB"]
  W3 --> FILL2["fill a TLB entry<br/>covering 2 MiB"]
  FILL --> PA
  FILL2 --> PA
  FILL -.->|"evicts an entry;<br/>next 4 KiB misses again"| TLB
  FILL2 -.->|"one entry now serves<br/>the next 512 accesses"| TLB

The two effects of a huge page on address translation, drawn as one path. What it shows: a TLB miss triggers a walk whose length depends on where the walk terminates — four memory references for a base page, three when the PMD is itself a leaf — and the entry that gets installed afterwards covers either 4 KiB or 2 MiB. The insight: these are the doc’s factors (1) and (2), and they are multiplicative, not alternatives. Huge pages make each miss ~25% cheaper and make misses 512× rarer over a sequential sweep. The dotted feedback edges are where the real difference lives: with 4 KiB pages a linear walk through a large array evicts and refills the TLB continuously; with 2 MiB pages the same walk touches one entry for 512 consecutive accesses. Under nested paging (a VM guest) each level of the walk is itself a walk, so the “3 versus 4” saving compounds — which is why the doc singles out virtualization.

See The Translation Lookaside Buffer and TLB Shootdowns for why the TLB is small and a miss is costly, and Memory Compaction for how the contiguous runs THP needs are manufactured.

The Allocation and Fallback Path, Step by Step

The single most useful thing to internalise about THP is that the fault path is a cascade of attempts with a fallback at every rung, and that the return code VM_FAULT_FALLBACK is the mechanism that walks down it. __handle_mm_fault() in mm/memory.c (v6.12) is where this is visible in the plainest form:

/* __handle_mm_fault(), v6.12, abridged — the PMD rung of the cascade */
if (pmd_none(*vmf.pmd) &&
    thp_vma_allowable_order(vma, vm_flags,
                            TVA_IN_PF | TVA_ENFORCE_SYSFS, PMD_ORDER)) {
        ret = create_huge_pmd(&vmf);          /* -> do_huge_pmd_anonymous_page() */
        if (!(ret & VM_FAULT_FALLBACK))
                return ret;                   /* got a 2 MiB THP; done */
}
...
return handle_pte_fault(&vmf);                /* fell back: go to PTE granularity */

Reading this line by line: pmd_none(*vmf.pmd) requires that nothing is mapped anywhere in this 2 MiB range yet — a single already-present PTE in the region disqualifies the huge attempt, which is why a region that was touched sparsely before it was grown will never get a fault-time THP. thp_vma_allowable_order(..., PMD_ORDER) applies the policy: is this VMA of an eligible kind, is enabled set to always, or to madvise with VM_HUGEPAGE set on this VMA, and is the huge range aligned and fully inside the VMA? TVA_ENFORCE_SYSFS is what makes this call obey the sysfs knobs (the MADV_COLLAPSE path deliberately omits that flag, which is why it ignores never — see below). If all of that holds, create_huge_pmd calls do_huge_pmd_anonymous_page, and only if that returns VM_FAULT_FALLBACK does control drop through to handle_pte_fault.

Inside do_huge_pmd_anonymous_page (mm/huge_memory.c, v6.12) there are three exits worth knowing. A read fault on a never-written region does not allocate anything at all: if transparent_hugepage_use_zero_page() is on, the kernel installs a PMD entry pointing at a shared, read-only huge zero folio (mm_get_huge_zero_folio), so 2 MiB of freshly-read-but-never-written memory costs zero physical pages. This is the huge-page analogue of the ordinary zero page and is controlled by /sys/kernel/mm/transparent_hugepage/use_zero_page (default on). A write fault calls vma_alloc_folio(gfp, HPAGE_PMD_ORDER, ...); if that returns NULL, the function bumps THP_FAULT_FALLBACK and returns VM_FAULT_FALLBACK. And if the address is not PMD-aligned or the huge range would spill outside the VMA, thp_vma_suitable_order() rejects it before anything is attempted.

flowchart TD
  PF["#PF on a not-present address<br/>__handle_mm_fault()"] --> PUD{"pud_none and<br/>PUD_ORDER allowable?"}
  PUD -->|"yes"| CPUD["create_huge_pud()<br/>1 GiB, DAX/devmap only"]
  CPUD -->|"VM_FAULT_FALLBACK"| PMD
  PUD -->|"no"| PMD{"pmd_none and<br/>PMD_ORDER allowable?<br/>(policy + alignment)"}
  PMD -->|"yes"| DHPAP["do_huge_pmd_anonymous_page()"]
  DHPAP --> RW{"write fault?"}
  RW -->|"no, and use_zero_page=1"| HZP["map huge zero folio<br/>2 MiB read-only, 0 pages allocated"]
  RW -->|"yes"| ALLOC["vma_alloc_folio(gfp, order 9)<br/>gfp from vma_thp_gfp_mask()"]
  ALLOC -->|"success"| DONE["PMD-mapped THP installed<br/>thp_fault_alloc++"]
  ALLOC -->|"NULL"| FB["thp_fault_fallback++<br/>return VM_FAULT_FALLBACK"]
  PMD -->|"no"| HPF
  FB --> HPF["handle_pte_fault()"]
  HPF --> DAP["do_anonymous_page()<br/>-> alloc_anon_folio()"]
  DAP --> ORD["orders = enabled mTHP orders,<br/>filtered by alignment and<br/>pte_range_none()"]
  ORD --> LOOP{"try highest<br/>remaining order"}
  LOOP -->|"folio allocated"| MOK["mTHP folio installed<br/>anon_fault_alloc++ for that size"]
  LOOP -->|"NULL"| NEXT["anon_fault_fallback++<br/>next_order()"]
  NEXT --> LOOP
  LOOP -->|"orders exhausted"| ZERO["folio_prealloc(): single 4 KiB page"]

The full fault-time allocation cascade in v6.12. What it shows: each rung tries the largest thing it is permitted to try, and signals failure with VM_FAULT_FALLBACK so the next rung down gets a turn; the mTHP rung is itself a loop over descending orders. The insight: THP never blocks a fault. The worst case is not an error but a 4 KiB page — which is exactly why a fragmented machine can silently stop producing huge pages with no symptom other than thp_fault_fallback climbing in /proc/vmstat. If you do not watch that counter you will never notice that THP quietly stopped working.

Where a huge page comes from: the GFP semantics

A THP allocation is just a high-order request to the buddy allocator — order-9 (2⁹ = 512 pages) for a 2 MiB PMD page, since HPAGE_PMD_ORDER = HPAGE_PMD_SHIFT - PAGE_SHIFT = 21 - 12 = 9 (huge_mm.h, v6.12). The crucial subtlety is how hard the kernel tries, and that is encoded entirely in the __GFP_* flags (GFP Flags and Allocation Contexts) passed to the allocator (gfp_types.h, v6.12):

#define GFP_TRANSHUGE_LIGHT  ((GFP_HIGHUSER_MOVABLE | __GFP_COMP | \
             __GFP_NOMEMALLOC | __GFP_NOWARN) & ~__GFP_RECLAIM)
#define GFP_TRANSHUGE        (GFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIM)

Symbol by symbol: GFP_HIGHUSER_MOVABLE says this is user memory that may live in high memory and may be migrated (essential — a non-movable huge page would itself fragment the zone it lives in); __GFP_COMP says allocate this as a compound page, i.e. a single folio with a head page carrying the shared state; __GFP_NOMEMALLOC forbids dipping into emergency reserves; __GFP_NOWARN suppresses the allocation-failure splat, because failure here is routine and expected. The decisive part is & ~__GFP_RECLAIM, which masks out both __GFP_DIRECT_RECLAIM and __GFP_KSWAPD_RECLAIM. The kernel’s own comment states the division of labour: the _LIGHT version “does not attempt reclaim/compaction at all and is by default used in page fault path, while the non-light is used by khugepaged.”

That is the mechanical reason the fault path is cheap-or-nothing while collapse can afford to work harder. But the defrag knob modulates it per fault, and vma_thp_gfp_mask() in huge_memory.c is the exact translation table:

defrag settingVMA has MADV_HUGEPAGEGFP flags returnedBehaviour on a fragmented system
alwaysyesGFP_TRANSHUGEstall in direct reclaim + compaction, retry hard
alwaysnoGFP_TRANSHUGE | __GFP_NORETRYstall, but give up after one attempt
defereitherGFP_TRANSHUGE_LIGHT | __GFP_KSWAPD_RECLAIMwake kswapd/kcompactd, fail this fault immediately
defer+madviseyesGFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIMstall for advised regions only
defer+madvisenoGFP_TRANSHUGE_LIGHT | __GFP_KSWAPD_RECLAIMbackground only
madvise (default)yesGFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIMstall for advised regions only
madvise (default)noGFP_TRANSHUGE_LIGHTfail fast, no reclaim, no background work

The defrag sysfs setting decoded into actual allocator flags, transcribed from vma_thp_gfp_mask() (mm/huge_memory.c, v6.12). The insight: the row that produces the multi-millisecond latency spikes people blame THP for is exactly one — defrag=always with no madvise (GFP_TRANSHUGE plus __GFP_NORETRY), and defrag=always with madvise. The default (madvise, un-advised region) is the bottom row: bare GFP_TRANSHUGE_LIGHT, which cannot stall at all because it has no reclaim bit set. A latency complaint attributed to “THP” on a default-configured kernel is therefore almost always about khugepaged’s compaction work or about the 2 MiB clear-page, not about a stall in the fault path.

The enabled Policy: always / madvise / never — and the Real Argument

The headline global control is /sys/kernel/mm/transparent_hugepage/enabled:

echo always  >/sys/kernel/mm/transparent_hugepage/enabled
echo madvise >/sys/kernel/mm/transparent_hugepage/enabled
echo never   >/sys/kernel/mm/transparent_hugepage/enabled
  • always — every eligible anonymous mapping is a THP candidate, system-wide. Maximum TLB benefit, maximum risk of memory bloat.
  • madvise — THP is used only in regions a process explicitly opts into via madvise(addr, len, MADV_HUGEPAGE) (available “since Linux 2.6.38”, per madvise(2)).
  • never — THP disabled for anonymous memory.

The upstream kernel default is always, not madvise. This is worth stating plainly because it is widely misreported. mm/Kconfig (v6.12) contains a three-way choice — TRANSPARENT_HUGEPAGE_ALWAYS, _MADVISE, _NEVER — whose default is TRANSPARENT_HUGEPAGE_ALWAYS, and transparent_hugepage_flags in huge_memory.c is initialised from exactly those Kconfig symbols. The defrag default is separate and is madvise (TRANSPARENT_HUGEPAGE_DEFRAG_REQ_MADV_FLAG is set unconditionally in that initialiser). So a stock kernel ships enabled=[always] madvise never and defrag=always defer defer+madvise [madvise] never — a combination that Vlastimil Babka spelled out on LWN in April 2026, quoting his own machine (LWN comment thread, retrieved via the Internet Archive). Distributions frequently override enabled to madvise; the kernel does not.

The layering is easy to get backwards, so state it explicitly: enabled decides whether a region is a candidate; defrag decides how hard to work to satisfy a candidate. They compose:

defrag=neverdefrag=madvise (default)defrag=always
enabled=always, plain mappingTHP if a free order-9 run exists, else base pagessame (no reclaim for un-advised)fault stalls in reclaim + compaction
enabled=always, MADV_HUGEPAGETHP if free, else base pagesfault stalls to make onefault stalls to make one
enabled=madvise, plain mappingnever a fault-time THP; khugepaged may still collapse it latersamesame
enabled=madvise, MADV_HUGEPAGETHP if freefault stalls to make onefault stalls to make one
enabled=neverno fault-time THP at all — but MADV_COLLAPSE still workssamesame

The enabled × defrag policy matrix. The insight: the two knobs are not redundant and the interesting cells are the diagonal. With the common distribution pairing enabled=madvise/defrag=madvise, an un-advised mapping never gets a fault-time THP at all, so an application that expected THP and did not call madvise() gets nothing and no error — while an advised one is precisely the case that can block the faulting thread in synchronous compaction. The setting people reach for to “make THP safe” is the setting that concentrates all the latency risk onto the applications that asked for THP.

flowchart TD
  START["Anonymous write fault<br/>on a not-present address"] --> PR{"prctl(PR_SET_THP_DISABLE)<br/>set on this process?"}
  PR -->|"1 — fully disabled"| NO["base pages<br/>(only MADV_COLLAPSE can override)"]
  PR -->|"3 — except-advised (6.18+)<br/>and VMA not advised"| NO
  PR -->|"no override, or advised<br/>under mode 3"| NOHP{"VM_NOHUGEPAGE set?<br/>(MADV_NOHUGEPAGE)"}
  NOHP -->|"yes"| NO
  NOHP -->|"no"| EN{"transparent_hugepage/enabled"}
  EN -->|"never"| NOK["base pages at fault time<br/>khugepaged also shut down"]
  EN -->|"madvise"| ADV{"VM_HUGEPAGE set?<br/>(MADV_HUGEPAGE)"}
  ADV -->|"no"| NOK2["base pages at fault time<br/>khugepaged may still collapse later"]
  ADV -->|"yes"| ALIGN
  EN -->|"always"| ALIGN{"thp_vma_suitable_order:<br/>address PMD-aligned AND<br/>whole 2 MiB inside the VMA?"}
  ALIGN -->|"no"| MT["fall through to the<br/>mTHP rung — smaller orders<br/>have easier alignment"]
  ALIGN -->|"yes"| EMPTY{"pmd_none — nothing<br/>mapped in this 2 MiB yet?"}
  EMPTY -->|"no"| MT
  EMPTY -->|"yes"| DEFRAG["allocate with the GFP mask<br/>vma_thp_gfp_mask() picks<br/>from defrag x VM_HUGEPAGE"]
  DEFRAG -->|"success"| THP["PMD-mapped 2 MiB THP"]
  DEFRAG -->|"failure"| MT

Every gate a fault must pass to get a PMD-sized THP in v6.12, in the order the kernel applies them. What it shows: enabled is only the third check — two per-process opt-outs come first — and two purely mechanical conditions (alignment and pmd_none) come after it, before policy ever reaches the allocator. The insight: most “why is THP not working?” investigations start and end at the enabled knob, but the two nodes that silently reject the most faults in practice are ALIGN and EMPTY. A heap grown by brk() in small increments, or a region that was touched sparsely before it was extended, fails pmd_none forever no matter what enabled says — which is exactly why the doc’s “Optimizing the applications” advice is a single sentence about posix_memalign(): “To be guaranteed that the kernel will map a THP immediately in any memory region, the mmap region has to be hugepage naturally aligned.”

The boot-time default for the top-level knob is transparent_hugepage=always|madvise|never on the kernel command line.

never does not mean never: MADV_COLLAPSE

madvise(addr, len, MADV_COLLAPSE) asks the kernel to synchronously collapse a range into huge pages right now, in the caller’s context, paying the compaction cost on the caller’s own clock rather than khugepaged’s. It first appears in Linux 6.1: MADV_COLLAPSE is present in include/uapi/asm-generic/mman-common.h at the v6.1 tag and absent at v6.0.

Crucially, MADV_COLLAPSE ignores the sysfs policy entirely. The v6.12 doc does not say so; the v6.18 doc adds an explicit note: “Setting ‘never’ in all sysfs THP controls does not disable Transparent Huge Pages globally. This is because madvise(..., MADV_COLLAPSE) ignores these settings and collapses ranges to PMD-sized huge pages unconditionally.” The behaviour is the same in 6.12 — the collapse path simply does not pass TVA_ENFORCE_SYSFS — the documentation only caught up later.

Uncertain

Verify: MADV_COLLAPSE is not documented in the madvise(2) man page as of the master branch of the man-pages repository consulted here (grep for MADV_COLLAPSE returns zero hits, while every other advice from MADV_REMOVE through MADV_PAGEOUT carries a “since Linux X.Y” line). The 6.1 introduction above is pinned by an existence check on the kernel UAPI header, not by the man page. Reason: man-pages lag. To resolve: check a later man-pages release or git log --diff-filter=A on the mm/khugepaged.c madvise_collapse() entry point. uncertain

Per-process control: prctl(PR_SET_THP_DISABLE)

A process can override system policy for its whole address space with prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0). This predates 6.12 and is the “big hammer”: Jonathan Corbet notes it “will override any madvise() calls that the process may subsequently make to enable THP for an address range”, and that real software uses it — “MariaDB uses this feature” (Corbet, Improving control over transparent huge page use, LWN, 2025-08-05, read via the Internet Archive).

The gap that hammer leaves is the “do not use THP unless I ask” case. Hildenbrand explains why the obvious fix was rejected: one could let madvise() override PR_SET_THP_DISABLE, but “this would change the documented semantics quite a bit”, and there are callers for whom the current absolute semantics are what is wanted. So a new mode was added instead. PR_THP_DISABLE_EXCEPT_ADVISED, from a Hildenbrand patch carried in Usama Arif’s series, landed in Linux 6.18: the symbol is present in include/uapi/linux/prctl.h at v6.18 and absent at v6.17, v6.16 and v6.15.

prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0);                             /* off entirely, even MADV_COLLAPSE */
prctl(PR_SET_THP_DISABLE, 1, PR_THP_DISABLE_EXCEPT_ADVISED, 0, 0); /* off except where advised (post-6.12) */
prctl(PR_SET_THP_DISABLE, 0, 0, 0, 0);                             /* re-enable */

PR_GET_THP_DISABLE returns a two-bit value: 0 = no override, 1 = fully disabled, 3 = disabled-except-advised. The first form is the only way to truly turn THP off for a process, precisely because MADV_COLLAPSE overrides the sysfs never.

Hildenbrand’s own changelog admits the knob is a stopgap: “Likely, the future will use bpf or something similar to implement better policies, in particular to also make better decisions about THP sizes to use, but this will certainly take a while as that work just started.” That work is Yafang Shao’s struct-ops hook, int (*get_suggested_order)(struct mm_struct *mm, unsigned long tva_flags, int order), which lets a BPF program cap the order chosen for each allocation, returning zero to disable huge pages for that case (both per Corbet 2025).

That BPF hook has not landed as of mainline 7.2: get_suggested_order appears in neither include/linux/huge_mm.h nor mm/huge_memory.c at the v7.0 or v7.2 tags, and mm/Makefile at v7.2 builds only bpf_memcontrol.o under CONFIG_BPF_SYSCALL. Treat BPF-driven THP policy as a proposal, not a feature.

Multi-Size THP (mTHP) — the Significant Modern Change

For its first thirteen years THP meant exactly one size: PMD-sized, 2 MiB on x86-64. Multi-size THP (mTHP) breaks that assumption by backing anonymous memory with huge pages that are bigger than a base page but smaller than the PMD size — 16 KiB, 32 KiB, 64 KiB, 128 KiB, and so on in power-of-two page counts. This is the single most important thing that has happened to THP recently, and a description of THP that does not mention it is describing a kernel from before 2024.

mTHP landed in Linux 6.8 (March 2024). The pin is a documentation existence check: Documentation/admin-guide/mm/transhuge.rst at the v6.8 tag documents “multi-size THP (mTHP)” and the hugepages-<size>kB/enabled knobs; the same file at v6.7 contains neither string. The design rationale is in Ryan Roberts’ (Arm) v9 cover letter, dated 2023-12-07 and explicitly aimed at that release — “hopefully can be added to mm-unstable for some testing, then fingers crossed for v6.8” (Multi-size THP for anonymous memory, LWN mirror of the patch posting, read via the Internet Archive).

Roberts gives two independent motivations. The first is pure kernel-overhead reduction and applies everywhere: “Since SW (the kernel) is dealing with larger chunks of memory than base pages, there are efficiency savings to be had; fewer page faults, batched PTE and RMAP manipulation, reduced lru list, etc. In short, we reduce kernel overhead. This should benefit all architectures.” The second is hardware-specific: “Since we are now mapping physically contiguous chunks of memory, we can take advantage of HW TLB compression techniques. A reduction in TLB pressure speeds up kernel and user space. arm64 systems have 2 mechanisms to coalesce TLB entries; ‘the contiguous bit’ (architectural) and HPA (uarch).” On arm64 with 4 KiB base pages the contiguous-PTE hint operates on 16-page (64 KiB) aligned runs — which is exactly why 64 KiB is the size everyone reaches for first on Arm.

The reported gains are real but wildly workload-dependent, and the cover letter is careful to attribute rather than claim: “John Hubbard at Nvidia has indicated dramatic 10x performance improvements for some workloads” (measured with the mTHP series plus the arm64 contiguous-PTE follow-on), while “Kefeng Wang at Huawei has also indicated he sees improvements … although there are some latency regressions also.” A separate microbenchmark in the same letter checks that mTHP costs nothing when disabled, and the numbers are a useful sanity anchor for how small “no regression” is:

kernelApple M2 VM, mean ΔAmpere Altra bare metal, mean Δ
baseline0.000%0.000%
anonfolio-v8+0.005%+5.068%
anonfolio-v9−0.013%+0.107%

Write-fault microbenchmark regression with mTHP disabled, from the v9 cover letter. The insight: v8 carried a 5% write-fault regression on Altra even with the feature off, caused by an out-of-line order check; v9 fixed it by inlining the check into thp_vma_allowable_orders(). This is the reason mTHP is opt-in per size — the maintainers demanded proof that the code path costs nothing for people who do not want it, and the first attempt failed that bar.

How an mTHP is chosen: descending-order fallback

Unlike PMD THP, an mTHP stays PTE-mapped — it is a contiguous run of ordinary PTEs, represented internally as a large folio. There is no separate page-table shape; the win comes from batching and, on capable hardware, TLB coalescing. The selection logic in alloc_anon_folio() (mm/memory.c, v6.12) is a two-stage filter followed by a descending loop:

/* alloc_anon_folio(), v6.12, abridged */
if (unlikely(userfaultfd_armed(vma)))        /* uffd needs per-page fidelity */
        goto fallback;
 
orders = thp_vma_allowable_orders(vma, vma->vm_flags,
                TVA_IN_PF | TVA_ENFORCE_SYSFS, BIT(PMD_ORDER) - 1);  /* sysfs-enabled orders */
orders = thp_vma_suitable_orders(vma, vmf->address, orders);         /* alignment + fits in VMA */
if (!orders) goto fallback;
 
order = highest_order(orders);
while (orders) {                             /* stage 1: find the largest all-empty range */
        addr = ALIGN_DOWN(vmf->address, PAGE_SIZE << order);
        if (pte_range_none(pte + pte_index(addr), 1 << order)) break;
        order = next_order(&orders, order);
}
...
while (orders) {                             /* stage 2: try to allocate it, descending */
        folio = vma_alloc_folio(gfp, order, vma, addr, true);
        if (folio) { ...; return folio; }
next:
        count_mthp_stat(order, MTHP_STAT_ANON_FAULT_FALLBACK);
        order = next_order(&orders, order);
}
fallback:
        return folio_prealloc(vma->vm_mm, vma, vmf->address, true);  /* one 4 KiB page */

The two loops are doing different jobs and it matters. The first loop is about the virtual address space: it walks down from the largest enabled order looking for one where the naturally-aligned range around the faulting address is entirely pte_none(). A single already-present neighbour PTE knocks you down an order. The second loop is about physical memory: for each remaining order it asks the buddy allocator, and on failure bumps that size’s anon_fault_fallback counter and drops down. The userfaultfd_armed() bail at the top is the sharpest edge — any VMA registered with userfaultfd gets base pages unconditionally, because uffd’s contract is per-4 KiB-page fault fidelity and a large folio would deliver one notification for many pages.

Which sizes exist. The set of anonymous orders is THP_ORDERS_ALL_ANON, defined in huge_mm.h (v6.12) as ((BIT(PMD_ORDER + 1) - 1) & ~(BIT(0) | BIT(1))) — every order up to and including PMD_ORDER, minus order-0 (“not huge”) and, per the comment, order-1, “which is a limitation of the THP implementation”. On x86-64 that yields orders 2–9:

OrderSize (4 KiB base)sysfs directoryNotes
04 KiBbase page, not THP
18 KiBabsentexcluded by THP_ORDERS_ALL_ANON
216 KiBhugepages-16kB/smallest anon mTHP
332 KiBhugepages-32kB/
464 KiBhugepages-64kB/arm64 contiguous-PTE sweet spot
5–8128 KiB – 1 MiBhugepages-128kB/hugepages-1024kB/
92 MiBhugepages-2048kB/the classic PMD-mapped THP

Anonymous THP orders on x86-64 with 4 KiB base pages. The insight: the missing 8 KiB row is not an oversight in this table — order-1 anonymous large folios genuinely do not exist, because folio->_deferred_list lives in the third page of the folio (split_huge_page_to_list_to_order() spells this out: “Splitting to order-1 anonymous folios is not supported for non-file-backed folios, because folio->_deferred_list … is stored in subpage 2, but an order-1 folio only has subpages 0 and 1”). A data-structure placement decision is visible from userspace as a missing sysfs directory.

The difference between a PMD THP and an mTHP is best seen in the page tables, because it is entirely a page-table difference — the physical memory is a contiguous run either way:

2 MiB PMD-mapped THP                    64 KiB mTHP (order-4), PTE-mapped
--------------------                    ---------------------------------
   PMD table                               PMD table
  +-----------+                           +-----------+
  | leaf entry|--- PSE bit set             |  pointer  |--> PTE table (4 KiB page,
  +-----------+     |                      +-----------+     512 entries)
                    |                                             |
                    v                            +----------------+---------------+
        +-------------------------+              |  PTE  PTE  PTE ... PTE   (16 of |
        |  2 MiB physical run     |              |   0    1    2      15    them)  |
        |  (order-9 folio)        |              +----+----+----+-------+----------+
        +-------------------------+                   |    |    |       |
                                                      v    v    v       v
   page tables consumed: 0 extra              +--------------------------------+
   TLB entries needed:   1                    |  64 KiB physical run           |
   splits via:  split_huge_pmd (cannot fail)  |  (order-4 folio, contiguous)   |
                                              +--------------------------------+

                                          page tables consumed: 1 PTE table page
                                          TLB entries needed: 16 on x86-64,
                                            or 1 if the arm64 contiguous bit
                                            is set on all 16 PTEs
                                          splits via: nothing to split at the
                                            page-table level - already PTEs

PMD-mapped versus PTE-mapped huge pages. Fallback used: an ASCII box diagram rather than mermaid, because this contrasts two page-table shapes including a one-to-many fan-out with counts; packet-beta draws bit layouts and mermaid flowcharts draw graphs, but neither draws “one entry here, sixteen entries there, same physical run”. What it shows: a PMD THP replaces a whole level of the page-table tree; an mTHP does not touch the tree’s shape at all — it is 16 ordinary PTEs that happen to point at consecutive physical pages of one folio. The insight: this is why mTHP’s benefits and costs are both smaller and different in kind. It saves no page-table memory and, on x86-64, no TLB entries — its wins are the kernel-side ones Roberts listed (fewer faults, batched PTE and reverse-map manipulation, shorter LRU lists). The TLB win only materialises on hardware that can coalesce consecutive PTEs, which is why the arm64 contiguous bit — architecturally defined over 16-page, 64 KiB-aligned runs when the base page is 4 KiB — makes 64 KiB the size everyone reaches for on Arm and makes it unremarkable on x86-64. It is also why an mTHP has no split_huge_pmd analogue: there is no huge page-table entry to take apart.

Configuring mTHP

Each supported size gets its own sysfs directory, with the same three-way policy plus an inherit mode:

echo always  >/sys/kernel/mm/transparent_hugepage/hugepages-64kB/enabled
echo madvise >/sys/kernel/mm/transparent_hugepage/hugepages-64kB/enabled
echo never   >/sys/kernel/mm/transparent_hugepage/hugepages-2048kB/enabled
echo inherit >/sys/kernel/mm/transparent_hugepage/hugepages-2048kB/enabled

The default arrangement, stated verbatim by the doc: “By default, PMD-sized hugepages have enabled=“inherit” and all other hugepage sizes have enabled=“never”.” So out of the box only the PMD size is active — following the top-level enabled — and every smaller size must be opted in explicitly. If several are enabled, “the kernel will select the most appropriate enabled size for a given allocation”, which the code above shows means the largest that fits and can be allocated.

Boot-time configuration uses thp_anon=<size>[KMG]...:<state>, e.g. thp_anon=16K-64K:always;128K,512K:inherit;256K:madvise;1M-2M:never. Two traps: if thp_anon= is given at all, “any anon THP sizes not explicitly configured on the command line are implicitly set to never” — including the PMD size, whose policy “will default to never” if not listed. And a valid thp_anon overrides transparent_hugepage=. The thp_anon= parameter is itself newer than the sysfs interface: it is documented in the v6.12 transhuge.rst and absent from v6.8v6.11.

khugepaged did not collapse to mTHP in 6.12. The v6.12 doc is explicit: “khugepaged currently only searches for opportunities to collapse to PMD-sized THP and no attempt is made to collapse to other THP sizes.” mTHP therefore came only from the fault path. This changed in Linux 7.2: the 7.2 doc says khugepaged “collapses sequences of basic pages into huge pages of either PMD size or mTHP sizes, if the system is configured to do so”, and it is a documentation existence check that pins the release — the sentence “Only anonymous memory will attempt to collapse to other THP” is present at v7.2 and absent at v7.1, v7.0 and v6.18. The 7.2 mTHP-collapse support carries real restrictions: max_ptes_none accepts “only 0 or (HPAGE_PMD_NR - 1)” for mTHP collapse (“Any intermediate value will emit a warning and mTHP collapse will default to max_ptes_none=0”), khugepaged “does not support collapsing regions that contain shared or swapped out pages”, and “madvise_collapse only supports collapsing to PMD-sized THPs”. If you are running 6.12 LTS, assume PMD-only collapse. See khugepaged and THP Collapse.

Failure Modes and Gotchas

THP almost never fails loudly. There is no ENOMEM when a huge page cannot be produced, no log line when khugepaged gives up, no warning when a 2 MiB page is holding four kilobytes of live data. Every failure mode below is silent by construction, which is why the diagnostic counters in the previous section are not optional decoration — they are the only instrumentation there is.

Failure mode 1: internal fragmentation — the 2 MiB page holding one byte

This is the original sin of huge pages and the reason THP is still argued about fifteen years after it merged. The madvise(2) man page states it in one sentence: MADV_HUGEPAGE “can very easily waste memory (e.g., a 2 MB mapping that only ever accesses 1 byte will result in 2 MB of wired memory instead of one 4 KB page)” (madvise(2), man-pages master). With enabled=always, every eligible sparse mapping in the system is subject to that rounding-up, and the waste is invisible in the usual places: RSS counts the whole 2 MiB, so a process looks like it is using memory it never touched.

The important empirical finding is that the waste is bimodal, not uniform. Alexander Zhu’s 2022 THP-utilization patch set added a /sys/kernel/debug/thp_utilization histogram bucketing anonymous THPs by how many of their 512 base pages hold non-zero data, and Jonathan Corbet’s write-up of the cover-letter data draws the conclusion plainly: “nearly all pages fall into one of the two extremes. As a general rule, a huge page is either fully utilized or almost entirely unused” (Corbet, The transparent huge page shrinker, LWN, 2022-09-08, read via the Internet Archive).

xychart-beta
  title "Anonymous THPs by utilization bucket (base pages in use, out of 512)"
  x-axis ["0-50", "51-101", "102-152", "153-203", "204-255", "256-306", "307-357", "358-408", "409-459", "460-512"]
  y-axis "Number of huge pages" 0 --> 1400
  bar [1331, 9, 3, 0, 2, 5, 1, 0, 1, 400]

The thp_utilization histogram from Zhu’s cover letter, as reported by LWN. What it shows: the count of anonymous THPs whose number of in-use (non-zero-filled) base pages falls in each bucket — 1,331 huge pages had at most 50 of their 512 base pages in use, 400 had 460 or more, and the eight buckets in between hold 21 pages between them. The insight: THP waste is not a smooth tax you can reason about as “on average 30% overhead”. It is a population of nearly-empty huge pages sitting next to a population of nearly-full ones, and the nearly-empty ones in that sample alone were pinning 680,884 unused base pages — about 2.6 GiB. That bimodality is what makes splitting profitable: you are not shaving a little off many pages, you are reclaiming almost all of a few. It is also what makes averages misleading — a “20% wasted” figure is really “80% of your THPs are fine and 20% are almost entirely garbage”.

The kernel’s mitigation is the underused-THP shrinker, merged for Linux 6.12 — a documentation existence check pins it: shrink_underused appears in Documentation/admin-guide/mm/transhuge.rst at v6.12 and in neither v6.11 nor v6.10. The doc describes the mechanism: “All THPs at fault and collapse time will be added to _deferred_list, and will therefore be split under memory pressure if they are considered ‘underused’. A THP is underused if the number of zero-filled pages in the THP is above max_ptes_none” (admin guide, v6.12).

The underused shrinker is a no-op under default settings, and the documentation does not say so.

thp_underused() in mm/huge_memory.c (v6.12) opens with:

if (khugepaged_max_ptes_none == HPAGE_PMD_NR - 1)
        return false;

and khugepaged_max_ptes_none is initialised to exactly HPAGE_PMD_NR - 1 (511) in khugepaged_init() (mm/khugepaged.c, v6.12). So on a stock kernel shrink_underused reads 1, the shrinker is registered, the scan runs — and every folio is reported as used. Lowering max_ptes_none is what actually arms it. Usama Arif’s series cover letter uses max_ptes_none=409 (split any THP with more than 409 of 512 pages zero-filled, i.e. 80% empty) for the Meta production numbers ([PATCH v4 0/6] mm: split underused THPs, LWN mail archive, 2024-08-19, via the Internet Archive).

Note the second-order consequence, which is genuinely surprising: max_ptes_none is the same knob that tells khugepaged how many absent PTEs it may fill in when collapsing. Turning it down to arm the shrinker simultaneously makes khugepaged far more conservative about creating THPs in the first place. One number, two opposed jobs — this is the knob’s central design flaw, and it is why MongoDB’s tuning guidance sets it to 0 outright (MongoDB, Transparent Huge Pages).

flowchart TD
  ALLOC["THP allocated at fault time<br/>or collapsed by khugepaged"] --> LIST["added to _deferred_list<br/>(every PMD THP, unconditionally)"]
  PARTIAL["Process partially unmaps<br/>part of a THP"] --> FLAG["folio marked partially_mapped<br/>nr_anon_partially_mapped++"]
  FLAG --> LIST
  LIST --> PRESSURE{"memory reclaim runs<br/>deferred_split_scan()"}
  PRESSURE --> PM{"folio_test_partially_mapped?"}
  PM -->|"yes"| SPLIT["split_folio()<br/>split_deferred / thp_split_page"]
  PM -->|"no"| SU{"split_underused_thp<br/>(shrink_underused) == 1?"}
  SU -->|"no"| KEEP["leave it alone"]
  SU -->|"yes"| GATE{"khugepaged_max_ptes_none<br/>== HPAGE_PMD_NR - 1 ?"}
  GATE -->|"yes — THE DEFAULT"| KEEP
  GATE -->|"no"| SCAN["thp_underused(): memchr_inv scan<br/>count zero-filled base pages"]
  SCAN -->|"zero pages > max_ptes_none"| SPLIT2["split_folio()<br/>thp_underused_split_page++"]
  SCAN -->|"otherwise"| KEEP
  SPLIT2 --> ZP["zero-filled subpages remapped<br/>to the shared zeropage — memory returned"]

The deferred-split queue and the underused shrinker in v6.12, traced from deferred_split_scan() and thp_underused(). What it shows: two independent reasons a THP lands on _deferred_list (it was partially unmapped, or it simply exists), and the three gates a fully-mapped one must pass before it is split. The insight: the GATE node is the one that matters operationally — with the shipped max_ptes_none = 511 the “yes” branch is always taken and the entire right-hand path is dead code. Also note the payoff on the far right: splitting is only profitable because Yu Zhao’s patches remap zero-filled subpages to the shared zeropage instead of handing back 512 real pages, so the split actually returns memory rather than merely re-labelling it.

Failure mode 2: allocation-latency spikes, and who really causes them

The canonical THP horror story is a process freezing for seconds. Its mechanism was diagnosed by Mel Gorman in 2011 and is worth knowing because the shape recurs: a fault attempts a huge page, the huge page is unavailable, the faulting thread is conscripted into synchronous compaction, and compaction cannot migrate a page that is under writeback — so it sleeps waiting for I/O to a slow device. Corbet’s account: “If the page is headed to a slow device, and it is far back on a queue of many such pages, that sleep can go on for a long time… producing a single huge page can involve migrating hundreds of ordinary pages” (Corbet, Huge pages, slow drives, and long delays, LWN, 2011-11-14, via the Internet Archive).

Two things follow, and both are routinely got wrong:

  1. On a default-configured 6.12 kernel this cannot happen to an un-advised mapping. defrag defaults to madvise, so an un-advised VMA gets bare GFP_TRANSHUGE_LIGHT — no __GFP_DIRECT_RECLAIM, no __GFP_KSWAPD_RECLAIM, therefore no stall is reachable from that allocation. The stall rows in the defrag table above are the always rows and the madvise+advised rows.
  2. Therefore the setting that “makes THP safe” concentrates the risk. Switching enabled to madvise means only applications that explicitly asked for THP get it — and those are precisely the ones whose faults are allowed to enter direct reclaim and compaction. You have not removed the latency; you have aimed it.

Watch compact_stall, compact_fail, and compact_success in /proc/vmstat — the doc names these three specifically as the THP-overhead counters, and suggests using the function tracer “to record how long was spent in __alloc_pages()” to measure the stalls directly.

Failure mode 3: thp_fault_fallback climbing — THP quietly stopped working

Because the fault path degrades to base pages rather than failing, a machine that has fragmented past the point of producing order-9 runs behaves exactly like a machine with THP working, except slower. The only signal is the ratio thp_fault_alloc : thp_fault_fallback in /proc/vmstat (and per-size anon_fault_alloc / anon_fault_fallback under hugepages-<size>kB/stats/).

How fast this happens on a busy system is startling. Barry Song, reporting Android/Oppo experience at LSFMM+BPF 2024, measured that after one hour of operation mTHP allocation attempts succeed about 50% of the time, “which is acceptable. After two hours, though, the failure rate exceeds 90%; memory is completely fragmented, and mTHPs are simply no longer available” (Corbet, Two talks on multi-size transparent huge page performance, LWN, 2024-05-25, via the Internet Archive). A benchmark run on a freshly-booted machine measures a system that does not exist in production.

Counter (/proc/vmstat)Rising meansWhat to do
thp_fault_allocfault-time PMD THPs are being createdhealthy; this is the number you want
thp_fault_fallbackfaults wanted a THP and got base pagesfragmentation, or defrag too weak — check compact_fail
thp_fault_fallback_chargeallocation succeeded but the memcg charge faileda cgroup memory limit, not fragmentation — different fix entirely
thp_collapse_alloc / _failedkhugepaged promotion succeeding / failingfailing collapse alongside rising compact_fail = fragmented zone
thp_split_pmdPMDs demoted to PTE tables (mprotect, partial munmap)usually benign; a high rate means the workload fights THP granularity
thp_split_page / _failedfolios actually taken apart / could not be_failed “can happen if the page was pinned by somebody” — GUP pins
thp_deferred_split_pageTHPs queued for later splittingwith 6.12 this includes every THP; not a problem indicator by itself
thp_underused_split_pagethe underused shrinker reclaimed wastestays at 0 unless you lowered max_ptes_none
thp_swpout_fallbacka THP had to be split before swappingno contiguous swap space; see below
compact_stalla thread blocked doing compactionthis is your latency; correlate with p99

THP counters as a diagnostic table, transcribed from the “Monitoring usage” section of the v6.12 admin guide. The insight: the two counters people watch (thp_fault_alloc, AnonHugePages) tell you what worked, and every interesting failure lives in a counter nobody has graphed. In particular thp_fault_fallback and thp_fault_fallback_charge look like the same problem and have opposite fixes — one is a fragmented physical zone, the other is a cgroup limit.

Failure mode 4: AnonHugePages does not mean what its name says

The v6.12 doc contains an unusually frank admission: “Note that AnonHugePages only applies to traditional PMD-sized THP for historical reasons and should have been called AnonHugePmdMapped.” On a kernel where mTHP is enabled, /proc/meminfo’s AnonHugePages counts none of your 64 KiB folios. Use /sys/kernel/mm/transparent_hugepage/hugepages-<size>kB/stats/nr_anon for those. The doc also warns that per-process attribution via /proc/PID/smaps “is expensive and reading it frequently will incur overhead” — a monitoring agent scraping smaps every few seconds on a large-RSS process is itself a latency source.

Failure mode 5: fork() of a large-RSS process — the Redis case

This is the failure people actually hit in production, and it is a THP problem only in combination with copy-on-write. Redis’s own latency documentation names it directly:

“Unfortunately when a Linux kernel has transparent huge pages enabled, Redis incurs to a big latency penalty after the fork call is used in order to persist on disk. Huge pages are the cause of the following issue: 1. Fork is called, two processes with shared huge pages are created. 2. In a busy instance, a few event loops runs will cause commands to target a few thousand of pages, causing the copy on write of almost the whole process memory. 3. This will result in big latency and big memory usage.” — Redis, Diagnosing latency issues

The arithmetic is the whole story. After fork(), every shared page is write-protected; the first write to a page triggers a COW fault that copies the granularity of the mapping. At 4 KiB granularity, a write touching a thousand scattered keys copies 1,000 × 4 KiB = 4 MiB. At 2 MiB granularity, the same thousand scattered writes copy up to 1,000 × 2 MiB = 2 GiB. The write amplification factor is 512×, and it lands on a single-threaded event loop that also has to memcpy every one of those 2 MiB pages.

sequenceDiagram
  participant P as Parent (Redis, 24 GiB RSS)
  participant K as Kernel
  participant C as Child (BGSAVE)
  P->>K: fork()
  K->>K: copy page tables, write-protect every PTE/PMD
  Note over K: with THP: ~12,288 PMDs<br/>without THP: ~6.3M PTEs + 48 MiB of tables
  K-->>C: child sees the same physical pages
  K-->>P: fork() returns (page-table copy is the fork cost)
  loop each event-loop iteration
    P->>K: SET key -> write to one 8-byte value
    K->>K: write fault on a write-protected mapping
    alt PMD-mapped THP
      K->>K: allocate 2 MiB folio + copy 2 MiB
      Note over K: 8 bytes written, 2 MiB copied<br/>262,144x amplification for this write
    else base page
      K->>K: allocate 4 KiB page + copy 4 KiB
    end
  end
  C->>K: writes RDB, exits
  Note over P,C: peak RSS approaches 2x dataset<br/>if enough distinct THPs were touched

fork() plus THP plus a write-heavy parent. What it shows: the two costs of fork() pulling in opposite directions — THP makes the fork call itself dramatically cheaper (thousands of PMD entries instead of millions of PTEs across 48 MiB of page tables, per Redis’s own page-table arithmetic for a 24 GiB instance) while making every subsequent COW fault 512× more expensive. The insight: whether THP helps or hurts a forking process is decided entirely by the ratio of fork frequency to post-fork write scatter. A process that forks often and writes densely wins; one that forks rarely and writes to scattered keys — exactly Redis with BGSAVE — loses badly, which is why Redis’s checklist item 3 is a blunt echo never > /sys/kernel/mm/transparent_hugepage/enabled. See Copy-on-Write and fork for the fault path that does the copying.

Failure mode 6: swap, migration, and pinning

Three smaller edges worth knowing:

  • Swap. A THP can be swapped out whole only if a contiguous run of swap slots is available; otherwise it is split first and thp_swpout_fallback is incremented (“Usually because failed to allocate some continuous swap space for the huge page”). A swap-heavy machine therefore steadily converts THPs back into base pages, and the conversion is not free.
  • userfaultfd. Any VMA registered with userfaultfd is excluded from mTHP at fault time by the userfaultfd_armed(vma) bail in alloc_anon_folio(), because uffd’s contract is per-base-page fault fidelity. A live-migration or checkpoint/restore system that arms uffd over a region silently gives up huge pages there.
  • Pinning. thp_split_page_failed “can happen if the page was pinned by somebody”. Long-term pins taken by [[get_user_pages and Page Pinning|pin_user_pages()]] — RDMA, io_uring registered buffers, VFIO — make a folio unsplittable and unmigratable, which also makes it un-compactable, which feeds fragmentation. This is the same pin-versus-mm collision that dominates the COW story.

Uncertain

Verify: the claim that a long-term GUP pin renders a THP unsplittable in all cases rather than merely usually. Reason: split_huge_page_to_list_to_order() compares the folio reference count against the expected mapcount-derived value and fails on a mismatch, but the exact set of pin types that produce a mismatch was not traced end to end here — only the documentation’s “can happen if the page was pinned by somebody” and the general folio_ref_count versus folio_mapcount rule were verified. To resolve: read folio_expected_ref_count() and the can_split_folio() path in mm/huge_memory.c at v6.12 and enumerate the callers. uncertain

Alternatives and When to Choose Them

“THP or no THP” is a false binary. There are at least six distinct ways to get — or refuse — huge pages on Linux, and they differ along three axes that are easy to conflate: who decides (kernel, sysadmin, application), when the memory is committed (boot, mmap, fault, later), and whether the mapping can degrade (a THP can always be split; a hugetlbfs page cannot).

The kernel doc frames the relationship in its own opening: THP “is an alternative mean of using huge pages for the backing of virtual memory with huge pages that supports the automatic promotion and demotion of page sizes and without the shortcomings of hugetlbfs” (admin guide, v6.12) — and then, at the very end of the same file, is careful to add that the two coexist: “You can use hugetlbfs on a kernel that has transparent hugepage support enabled just fine as always. No difference can be noted in hugetlbfs other than there will be less overall fragmentation.”

flowchart TD
  START["I want huge pages<br/>for this memory"] --> Q1{"Must the mapping be<br/>guaranteed huge, always,<br/>with no fallback?"}
  Q1 -->|"yes — DB buffer pool,<br/>DPDK, VM guest RAM"| HTLB["hugetlbfs<br/>reserve at boot or via<br/>nr_hugepages; MAP_HUGETLB"]
  Q1 -->|"no, best-effort is fine"| Q2{"Can I modify<br/>the application?"}
  Q2 -->|"no"| Q3{"Is the working set<br/>dense within each 2 MiB?"}
  Q3 -->|"yes"| ALW["enabled=always<br/>+ lower max_ptes_none<br/>to arm the shrinker"]
  Q3 -->|"no / sparse / forks a lot"| MTHPO["enable a small mTHP order<br/>(64K) and leave PMD at never<br/>— or enabled=never"]
  Q2 -->|"yes"| Q4{"Do I know exactly which<br/>regions are hot and dense?"}
  Q4 -->|"yes"| MADV["enabled=madvise<br/>+ MADV_HUGEPAGE on those<br/>regions only; posix_memalign<br/>for PMD alignment"]
  Q4 -->|"partly — I know the<br/>moment they become hot"| COLL["MADV_COLLAPSE at that moment<br/>pay compaction on my own clock"]
  Q4 -->|"no, and I must not<br/>waste memory"| NOHP["MADV_NOHUGEPAGE per region,<br/>or prctl(PR_SET_THP_DISABLE)<br/>for the whole process"]
  HTLB --> WARN["cost: memory is gone from<br/>the page allocator whether<br/>used or not; cannot be reclaimed"]
  ALW --> WARN2["cost: RSS inflation and<br/>fork/COW amplification"]

Choosing a huge-page mechanism. What it shows: the decision is driven first by whether you need a guarantee (which only hugetlbfs gives, at the price of permanently removing memory from the allocator) and second by how much you know about your own access pattern. The insight: the two branches people skip are the interesting ones. MADV_COLLAPSE exists precisely for the “I know when it gets hot, not whether” case — a JIT that has just finished compiling, a database that has just finished loading a table — and it is the only mechanism whose latency cost lands on a thread you chose. And enabling a 64 KiB mTHP order while leaving PMD THP off is a genuinely different operating point from “THP on”, not a weaker version of it: it captures most of the page-fault and RMAP batching savings with 1/32 of the internal-fragmentation exposure.

hugetlbfs — the guarantee, and what it costs

hugetlbfs pre-reserves huge pages into a dedicated pool (vm.nr_hugepages, or per-size nr_hugepages files) which is then handed out via mmap(MAP_HUGETLB) or a hugetlbfs mount. The properties are the mirror image of THP’s:

  • Guaranteed. If the reservation succeeds, the mapping is huge. There is no fallback path and no VM_FAULT_FALLBACK.
  • Unswappable and unsplittable. Pool pages are not on the LRU and are never reclaimed, so they cannot cause the latency or the split churn THP can.
  • Committed whether used or not. The pool is subtracted from MemFree at reservation time. A 64 GiB pool on a 128 GiB machine leaves 64 GiB for everything else, forever, even if the application touches none of it.
  • Best reserved at boot. Reserving after uptime requires compaction to produce the runs, so the boot parameter hugepages= is the reliable route.
  • Application-visible. Not transparent — the program (or its allocator, via libhugetlbfs) must ask.

Choose hugetlbfs when the huge-page benefit is load-bearing and the memory size is known in advance: a database buffer pool, a DPDK mempool, guest RAM for a VM. Choose THP when the benefit is nice-to-have and the footprint is dynamic.

File-backed and tmpfs huge pages

THP for anonymous memory is only part of the picture. tmpfs and shmem have their own policy, and it is not governed by transparent_hugepage/enabled:

# per-mount, at mount or remount time
mount -t tmpfs -o huge=always  tmpfs /dev/shm
mount -o remount,huge=within_size /dev/shm
 
# the internal shmem mount used by SysV SHM, memfd,
# MAP_ANONYMOUS|MAP_SHARED, DRM objects, Ashmem:
echo always >/sys/kernel/mm/transparent_hugepage/shmem_enabled

The huge= values are always, never, within_size (“only allocate huge page if it will be fully within i_size. Also respect fadvise()/madvise() hints”) and advise (“only allocate huge pages if requested with fadvise()/madvise()”). The default policy is never — so a tmpfs-backed workload gets no huge pages at all unless someone mounted it that way, regardless of the anonymous THP setting. shmem_enabled additionally accepts deny (“for use in emergencies, to force the huge option off from all mounts”) and force (“very useful for testing”). Per-size mTHP control for shmem exists as hugepages-<size>kB/shmem_enabled, where the doc notes force and deny “are dropped, which are rather testing artifacts from the old ages”.

within_size deserves attention because it is the sharpest tool in the set: it gives huge pages only where they cannot overshoot the file’s length, which removes the single largest source of tmpfs THP waste — a 4 KiB file rounded up to 2 MiB — without requiring any application change.

Ordinary page-cache THP for real filesystems is a different, less finished story. CONFIG_READ_ONLY_THP_FOR_FS (“Read-only THP for filesystems (EXPERIMENTAL)”) lets khugepaged “put read-only file-backed pages in THP”, and its help text at v6.12 still says “Write support of file THPs will be developed in the next few release cycles” (mm/Kconfig, v6.12) — the executable-text case (collapsing a hot binary’s .text into a 2 MiB mapping) is what it is really for. Broader file-backed large folios arrive through the folio work in the page cache rather than through THP’s sysfs interface; see The Page Cache.

The full comparison

MechanismWho decidesGuaranteed?Can degrade to base pages?Memory committedTypical use
Base pages (enabled=never)nobodyn/an/aexactsparse, fork-heavy, latency-critical (Redis)
PMD THP, enabled=alwayskernelnoyes, alwaysrounds up to 2 MiBdense large heaps, no app changes possible
PMD THP, enabled=madviseapplicationnoyesrounds up in advised regionsapp knows its hot regions
mTHP (hugepages-64kB/enabled)kernel/appnoyesrounds up to 64 KiBArm64 (contiguous PTE), memory-tight, Android
MADV_COLLAPSEapplicationno (best effort)yesrounds up, at your chosen instantpost-load / post-JIT promotion
hugetlbfs / MAP_HUGETLBadmin + appyesnowhole pool, at reservationDB buffer pools, DPDK, VM guest RAM
tmpfs huge=within_sizeadminnoyesrounds up only within i_sizeshared-memory caches, /dev/shm
MADV_NOHUGEPAGEapplicationn/a (opt-out)n/aexact, in that rangea known-sparse arena inside a THP-friendly process
prctl(PR_SET_THP_DISABLE)applicationn/a (opt-out)n/aexact, whole processMariaDB; the only true off switch

The huge-page mechanism matrix. The insight: only one row in the “Guaranteed?” column says yes, and it is the same row that says “no” to degradation and commits the memory up front — those three properties are the same property seen from three sides. Everything else is a hint. This is why “we enabled THP and it didn’t help” and “we enabled THP and it OOMed us” are both common: a hint that is silently declined and a hint that is silently over-honoured look identical from outside the kernel.

Production Notes

The gap between “THP is a performance feature” and what large operators actually do with it is the most useful thing in this note. Two of the most widely deployed databases in the world tell you to turn it off; the company that funded most of the recent THP work runs it on; and both positions are defensible for the same underlying reason.

The database consensus: turn it off (and why they are right)

Redis puts “Transparent huge pages must be disabled from your kernel. Use echo never > /sys/kernel/mm/transparent_hugepage/enabled to disable them, and restart your Redis process” as item 3 of a five-item latency checklist, above swap and virtualization (Redis, Diagnosing latency issues). The reason is entirely the fork() COW amplification traced above, not TLB behaviour: Redis’s steady-state access pattern would benefit from huge pages; its BGSAVE/BGREWRITEAOF pattern is destroyed by them. Note the “and restart your Redis process” — this is not an afterthought. The v6.12 doc says so generally: the enabled values “only affect future behavior. So to make them effective you need to restart any application that could have been using hugepages.” Writing never to sysfs does not un-huge the THPs a running process already holds.

MongoDB is the interesting counter-example, because it recently changed its mind. Its current tutorial does not say “disable”; it prescribes a tuned configuration (MongoDB, Transparent Huge Pages):

echo always        | tee /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise | tee /sys/kernel/mm/transparent_hugepage/defrag
echo 0             | tee /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none

Read against the mechanics established earlier, every line of that is deliberate and none of it is obvious:

  • enabled=always — take huge pages wherever they are free, without requiring madvise() calls in the server.
  • defrag=defer+madvise — from the vma_thp_gfp_mask() table above, this is GFP_TRANSHUGE_LIGHT | __GFP_KSWAPD_RECLAIM for un-advised regions: wake kswapd/kcompactd and fail this fault immediately. It is the row that buys huge pages over time without ever stalling a faulting thread. This is the setting that makes enabled=always survivable.
  • max_ptes_none=0 — two effects at once, as established above: khugepaged will only collapse a region where every PTE is already populated (never inventing memory), and it arms the underused shrinker, since thp_underused() short-circuits only at the default 511. With 0, a THP containing even one zero-filled base page is a split candidate under pressure.

That triple is, in effect, “give me huge pages only where they are demonstrably free and demonstrably used, and never make me wait for one” — a much more sophisticated position than either always or never.

Uncertain

Verify: whether MongoDB’s guidance is version-conditional (e.g. tied to a particular MongoDB release or to kernels new enough to have the underused shrinker, i.e. ≥ 6.12). Reason: the page consulted prescribes the three commands without stating a rationale or a kernel-version requirement, and MongoDB also still publishes a disable-THP tutorial at a separate URL. Setting max_ptes_none=0 on a pre-6.12 kernel gets the conservative-khugepaged half of the effect but not the shrinker half, since shrink_underused does not exist there. To resolve: check the MongoDB production-notes page for a version banner and diff it against the archived earlier revision. uncertain

Meta: always costs 7.7% of memory, and buys 1.8% of performance

The most precisely quantified production data point comes from Usama Arif’s shrinker series cover letter, measuring Meta workloads that were CPU-bound at over 99% utilization, after two hours of running ([PATCH v4 0/6] mm: split underused THPs, 2024-08-19, via the Internet Archive):

ConfigurationPerformance vs madviseMemory usage
THP=madvise (Meta’s then-current production setting)baseline54.6 G
THP=always+1.8%58.8 G (+7.7%)
THP=always + shrinker + max_ptes_none=409+1.7%55.9 G (+2.4%)

Meta production measurements from the underused-THP-shrinker cover letter. max_ptes_none=409 means “split any THP with more than 409 of its 512 base pages zero-filled” — i.e. more than 80% empty. The insight: this table is the entire THP argument in nine numbers. always is worth 1.8% throughput and costs 7.7% memory — a trade most fleet operators would decline outright, which is exactly why the cover letter opens by stating that “the current upstream default policy for THP is always. However, Meta uses madvise in production as the current THP=always policy vastly overprovisions THPs in sparsely accessed memory areas, resulting in excessive memory pressure and premature OOM killing.” The third row is the point of the whole patch series: the shrinker recovers 2.9 G of the 4.2 G of waste while giving up 0.1 percentage points of the gain. Note also that the cover letter’s reproducer OOM-kills stress immediately without the shrinker and survives with it — the failure mode being fixed is an OOM kill, not a slowdown.

The same LWN record contains a blunter version of the fleet verdict. At LSFMM+BPF 2024, “Johannes Weiner agreed, saying that his group (at Meta) had enabled 2MB huge pages for servers, but then immediately disabled them again. Huge pages can be good for performance, but they can’t be used everywhere” (Corbet, LWN, 2024-05-25, via the Internet Archive).

The mTHP performance record is genuinely mixed

If you are considering mTHP because “smaller huge pages should have all the upside and less of the downside”, the measured record is more ambiguous than that intuition. Yang Shi’s benchmarking on an 80-core Ampere Altra, on a 6.9-rc kernel with arm64 contiguous-PTE support enabled, found Memcached improved “about 20% in the number of operations completed per second, along with a 10-30% decrease in latency, but only for larger base-page sizes”; the 64 KiB-mTHP-on-4 KiB-base case showed no benefit at all, which Shi attributed to “the extra overhead of maintaining the page tables at a 4KB page size” overwhelming the gain. Kernel compilation in the same 64 KiB/4 KiB configuration did show about 5%, attributed to reduced page faults. Shi’s conclusion — allocations “should start by attempting to get the largest possible mTHP size; if that fails, the allocator should just fall back immediately to the base-page size”, because the intermediate sizes do not justify the work — was contested in the room, and Jason Gunthorpe pushed back on removing size control from user space entirely (same LWN report).

Roberts’ own v9 cover letter is equally careful about attribution rather than claiming: John Hubbard at Nvidia reported “dramatic 10x performance improvements for some workloads”, while Kefeng Wang at Huawei saw improvements “although there are some latency regressions also” (LWN, patch posting, via the Internet Archive). “10× for some workloads” and “latency regressions also” are both true statements about the same feature.

A runbook

# 1. What policy is actually in effect? (brackets mark the active value)
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
cat /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none
grep -H . /sys/kernel/mm/transparent_hugepage/hugepages-*/enabled
 
# 2. Is THP being used, and is it succeeding?
grep -E 'AnonHugePages|ShmemHugePages|ShmemPmdMapped' /proc/meminfo
grep -E 'thp_fault_alloc|thp_fault_fallback|thp_collapse|compact_stall|compact_fail' /proc/vmstat
# mTHP is invisible in AnonHugePages -- read per-size stats instead:
grep -H . /sys/kernel/mm/transparent_hugepage/hugepages-*/stats/anon_fault_{alloc,fallback}
 
# 3. Which process is holding the huge pages? (expensive -- do not poll this)
grep -H AnonHugePages /proc/*/smaps_rollup 2>/dev/null | awk '$2!=0'
 
# 4. Is the machine capable of producing order-9 runs at all?
cat /proc/buddyinfo          # counts of free blocks per order, per zone
cat /proc/pagetypeinfo       # unmovable/movable/reclaimable breakdown
 
# 5. Turn it off for one process without touching the system policy:
#    (there is no shell equivalent -- PR_SET_THP_DISABLE is a prctl)
#    ...or for one region: madvise(addr, len, MADV_NOHUGEPAGE)

Step 4 is the one people omit. thp_fault_fallback tells you THP failed; /proc/buddyinfo tells you why — if the order-9 column is zero across every zone, no amount of policy tuning will help and the problem is fragmentation, addressed (if at all) by Memory Compaction and by keeping unmovable allocations out of movable pageblocks.

Where this is heading

THP’s automatic policy is an actively unsettled area of kernel design, and it is worth knowing that so you do not mistake today’s knobs for a finished interface. At LSFMM+BPF 2026, Nico Pache argued that “the only way to have the system truly allocate huge pages transparently is to set the appropriate option to always in sysfs, but the implementation of that mode is not optimal. If a process touches a single byte it will get a 2MB huge page that may never be utilized to any great extent.” His proposal is an auto mode that “should behave like always when memory usage is below a threshold, and like defer otherwise”, combining khugepaged for promotion with the underused shrinker for demotion (Corbet, Better automatic management of transparent huge pages, LWN, 2026-05-26, via the Internet Archive).

The same report records the known weaknesses of the current shrinker from David Hildenbrand’s session: all PMD THPs go on the deferred-split list unconditionally, “the kernel makes no distinction between THP sizes, and no distinction between underutilized and partially mapped THPs, when considering a split. There are no LRU semantics either” — a design that is tolerable only because there are relatively few PMD THPs on a system, and which Hildenbrand expects to break down “in a world where mTHPs are more heavily used”. Matthew Wilcox named the heuristic’s blind spot precisely: the shrinker “works by looking for base pages filled with zeroes, which is an indication that the memory was never used. It will miss memory that was used once and never touched thereafter.”

timeline
  title THP's evolution, by the kernel release each change is verified present in
  v2.6.38 : THP merged for anonymous memory : MADV_HUGEPAGE and MADV_NOHUGEPAGE added
  2011 debate : Mel Gorman proposes dropping synchronous compaction from THP faults after the slow-device stall reports : Morton and Rientjes object that some workloads want to wait
  v6.1 : MADV_COLLAPSE - user-space-triggered synchronous collapse that ignores sysfs policy
  v6.8 : multi-size THP (mTHP) - PTE-mapped orders below PMD size, with per-size sysfs directories
  v6.12 LTS : underused-THP shrinker (shrink_underused) : thp_anon= boot parameter : per-size stats counters
  v6.18 : PR_THP_DISABLE_EXCEPT_ADVISED : docs finally state that MADV_COLLAPSE overrides a global never
  v7.2 : khugepaged collapses to mTHP sizes, with restrictions
  proposed : auto mode : BPF get_suggested_order hook : priority-aware shrinker - none merged as of v7.2

THP’s timeline, with every date pinned by an existence check against the kernel tree or by a dated LWN report rather than by recollection. What it shows: the feature has had exactly two structural changes in fifteen years — mTHP in 6.8 and the underused shrinker in 6.12 — and everything since is policy plumbing around the same core mechanism. The insight: the entire bottom half of this timeline is the kernel community conceding that “transparent” was over-promised, and building the controls (per-size policy, per-process prctl, a shrinker to take huge pages back) that a truly automatic mechanism would not have needed. If your mental model of THP predates 2024, it predates both of the changes that matter.

See Also

The huge-page family

What a huge page is made of

Where the pages come from

Interactions and failure surfaces

Parent MOC