The Zero Page and Lazy Allocation

Linux has a single, system-wide, read-only page full of zeros — the ZERO_PAGE — and it uses it to make freshly-allocated anonymous memory almost free until you write to it. When a process reads an anonymous page it has never written (the common case right after malloc/calloc/mmap(MAP_ANONYMOUS)), the fault handler does not allocate a real page: it maps the one shared zero page read-only into the faulting PTE, so the read returns zeros at the cost of a page-table entry and nothing more. Only the first write triggers a copy-on-write-style break that allocates a genuine private page and fills it. The kernel exports the global zero_pfn for exactly this purpose (mm/memory.c, v6.12). The same idea scales up to transparent huge pages via the huge zero page (kernel admin-guide, Transparent Hugepage). This is why allocating a multi-gigabyte zeroed buffer returns instantly and consumes no RAM until the bytes are touched — the topic of this note, verified against the Linux 6.12 LTS tree.

Scope, and the boundary readers trip over. This note and Demand Paging are adjacent and are routinely confused, so the split is worth stating outright. Demand Paging owns the policy — “page tables start empty, the kernel fills them on the fault that first touches an address” — and the fault dispatch that implements it, including a full walk of do_anonymous_page and its read/write branch. This note owns the object: the one physical frame called ZERO_PAGE, what makes it different from every other frame in the system, what the rest of mm/ has to do to avoid tripping over it, what happens when a mapping that points at it is written, and the PMD-sized sibling used by transparent huge pages. Put differently: demand paging explains why nothing is allocated at mmap time; the zero page explains why a read after that still costs nothing. Those are separate optimisations, and a kernel could have either without the other — s390 under storage keys genuinely does have demand paging without a shared zero page, as the arch override below shows. The fork-time sharing of ordinary anonymous pages and the general copy-on-write break belong to Copy-on-Write and fork; this note covers only the zero-page-specific corner of that path, where the “copy” turns out not to be a copy at all.

All source citations below are pinned to Linux v6.12, which is the vault’s reference tree; v6.12 is one of several longterm branches (kernel.org currently lists 6.18, 6.12, 6.6, 6.1, 5.15 and 5.10 as longterm, with mainline at 7.3-rc2 and stable at 7.2.5, as of 2026-09-12 per releases.json). Where behaviour has changed since v6.12 — and for the huge zero page it has changed twice — the note says which release changed it and how the date was established.

Mental Model — One Page of Zeros, Shared by Everyone

There is exactly one physical page of zeros in the kernel (per architecture; empty_zero_page on x86-64), and the kernel never lets anyone write to it. Any process that reads never-written anonymous memory has all of those reads satisfied by pointing its page tables at that one shared page. Because the mapping is read-only, no process can corrupt the shared zeros, and because it is shared, a thousand processes reading untouched memory cost the system one physical page total. The first store to such an address is a fault — the hardware refuses the write to a read-only PTE — and only then does the kernel allocate a real, private, writable page. So “allocating memory” splits into two events separated in time: reserving the address range (cheap, happens at mmap) and committing physical RAM (deferred to first write).

flowchart TB
  MAP["mmap(MAP_ANONYMOUS) / brk<br/>creates VMA, empty PTEs<br/>(no RAM committed)"]
  R["First READ of a page"] --> RF["do_anonymous_page:<br/>!FAULT_FLAG_WRITE branch"]
  RF --> ZP["map the shared ZERO_PAGE<br/>read-only (pte_mkspecial,<br/>my_zero_pfn) — NO allocation"]
  W["First WRITE of a page"] --> WF["do_anonymous_page:<br/>FAULT_FLAG_WRITE branch"]
  WF --> AL["alloc_anon_folio:<br/>allocate a real zeroed page,<br/>map it writable + exclusive"]
  ZPW["Later WRITE to a page<br/>currently mapped to ZERO_PAGE"] --> COW["write fault -> do_wp_page<br/>(PTE is RO) -> allocate copy"]
  MAP --> R
  MAP --> W
  ZP -.-> ZPW

The two faces of an untouched anonymous page. What it shows: after mmap, a page can be reached by either a read or a write fault. A read is handled by mapping the global zero page read-only with no allocation; a write (whether the very first access, or a write that follows an earlier zero-page read) ends in allocating a real private page. The insight to take: the zero page makes reads of fresh memory cost nothing, and lazy allocation makes writes the only event that actually consumes RAM. calloc of a huge buffer is fast precisely because it produces a sea of zero-page reads, not allocations.

The Four Outcomes of a First Touch

The zero page’s reputation is broader than its actual reach. “Reading memory you have not written is free” is true of exactly one kind of mapping — a private anonymous one — and almost every neighbouring case allocates a real page. Getting this wrong is the single most common error in reasoning about Linux memory accounting, because all of these cases read as zero and only one of them is free. The table below is the whole point of this note; everything after it explains one column or another.

First touch of an untouched pageFault handler reachedWhat gets installedPhysical page allocated?Counted in RSS?Measured Rss for a 256 MiB region
Read of MAP_PRIVATE|MAP_ANONYMOUSdo_anonymous_page, read branchpte_mkspecial(pfn_pte(my_zero_pfn(addr), prot)) — read-only PTE at the one shared frameNoNo0 kB
Write of MAP_PRIVATE|MAP_ANONYMOUSdo_anonymous_page, write branchnew private writable dirty PTE, RMAP_EXCLUSIVEYesYes (MM_ANONPAGES)262,144 kB
Write to a page already mapped to the zero pagedo_wp_page → wp_page_copynew private page, allocated pre-zeroed, no copy performedYesYes (MM_ANONPAGES)262,144 kB
Read of MAP_SHARED|MAP_ANONYMOUSshmem_fault (SGP_CACHE)a real tmpfs page-cache folioYesYes (MM_SHMEMPAGES)262,144 kB
Read of a hole in a MAP_PRIVATE file mappingdo_fault → filemap_faulta real page-cache folio, zero-filled by the filesystemYesYes (MM_FILEPAGES)262,144 kB
Read of a hole in a MAP_PRIVATE tmpfs mappingshmem_fault (SGP_CACHE)a real tmpfs page-cache folioYesYes262,144 kB
read(2) of a hole in a tmpfs file (no mapping)shmem_file_read_iternothing — copy_page_to_iter(ZERO_PAGE(0), …) copies straight out of the zero pageNon/aRSS unchanged
Read after madvise(MADV_DONTNEED) on private anondo_anonymous_page, read branchback to the shared zero pageNoNo0 kB

The Rss column is not a prediction; it is measured output from a program run on the machine described in the Measuring It section below, on Linux 7.1.13 with 256 MiB per case. The rows are, in order, the do_anonymous_page read branch (mm/memory.c L4755-L4777), its write branch (L4778-L4829), wp_page_copy (L3352-L3377), and shmem_fault, which passes SGP_CACHE and therefore instantiates a folio even for a read (mm/shmem.c L2531).

Two of these deserve a sentence each because they surprise people.

MAP_SHARED|MAP_ANONYMOUS is not anonymous. At mmap time the kernel converts it into a tmpfs file: shmem_zero_setup() calls shmem_kernel_file_setup("dev/zero", size, vm_flags) and installs shmem_anon_vm_ops (mm/shmem.c L5289-L5309). From then on the VMA has vm_ops, so faults never reach do_anonymous_page at all — which is just as well, since that function’s first act is if (vma->vm_flags & VM_SHARED) return VM_FAULT_SIGBUS;. A shared anonymous mapping therefore gets no zero-page treatment: the content must be visible to the other sharers of the mapping, so a real page has to exist the moment anybody looks at it.

tmpfs uses the zero page for read(2) but not for mmap. The only ZERO_PAGE(0) references in mm/shmem.c are in the read(2) and splice paths — copy_page_to_iter(ZERO_PAGE(0), offset, nr, to) (mm/shmem.c L3168) and a pipe buffer whose .page is ZERO_PAGE(0) (L3254) — both reached via shmem_get_folio(..., SGP_READ), whose contract is documented in the source as “SGP_READ: succeed on hole, with NULL folio, letting caller zero” (L2293). A read(2) can copy out of the zero page because the destination is the caller’s buffer and nothing needs to stay mapped; an mmap cannot, because the resulting page must be able to become shared writable memory, and the shared zero page can never be written.

flowchart TD
  T["First touch of an address<br/>whose PTE is not present"] --> K{"What kind of VMA?"}
  K -->|"private anonymous<br/>(no vm_ops)"| DAP["do_anonymous_page()"]
  K -->|"shared anonymous<br/>(shmem_zero_setup rewrote it<br/>into a tmpfs file)"| SHM["shmem_fault(SGP_CACHE)"]
  K -->|"file-backed<br/>(vm_ops-&gt;fault)"| FMAP["do_fault -&gt; filemap_fault<br/>/ shmem_fault"]
  DAP --> WQ{"FAULT_FLAG_WRITE?"}
  WQ -->|"no (read)"| FZ{"mm_forbids_zeropage(mm)?<br/>(s390 storage keys / PV)"}
  FZ -->|"no"| ZP["install my_zero_pfn PTE,<br/>read-only + pte_mkspecial<br/><b>0 pages allocated</b>"]
  FZ -->|"yes"| AL
  WQ -->|"yes (write)"| AL["alloc_anon_folio()<br/>zeroed, writable, dirty,<br/>RMAP_EXCLUSIVE<br/><b>1 page (or mTHP) allocated</b>"]
  SHM --> RP["real tmpfs folio<br/><b>allocated</b>"]
  FMAP --> RP2["real page-cache folio,<br/>zero-filled by the FS<br/><b>allocated</b>"]
  ZP --> LATER["later WRITE to that address"]
  LATER --> WP["do_wp_page -&gt; wp_page_copy<br/>pfn_is_zero == true<br/><b>allocate pre-zeroed, skip the copy</b>"]
  ZP -.->|"madvise(MADV_DONTNEED)<br/>zaps the PTE"| T

The decision tree behind the table above. What it shows: there is exactly one edge in this graph that ends without an allocation — the private-anonymous read fault reaching my_zero_pfn — and one more that ends without a copy, the write to an existing zero-page PTE. Every other first touch instantiates a real frame. The insight to take: “untouched memory is free” is a property of private anonymous mappings specifically, not of unwritten memory in general; and MADV_DONTNEED (the dashed edge) is the only supported way to push a private anonymous page back to the start of this graph without unmapping it.

What the Zero Page Is

The zero page is a statically-allocated, always-zero physical page set up at boot. On x86-64 it is empty_zero_page, and the ZERO_PAGE(vaddr) accessor returns its struct page (arch/x86/include/asm/pgtable.h, v6.12):

/* "ZERO_PAGE is a global shared page that is always zero" */
extern unsigned long empty_zero_page[PAGE_SIZE / sizeof(unsigned long)] ...;
#define ZERO_PAGE(vaddr) ((void)(vaddr), virt_to_page(empty_zero_page))

The generic memory-management code captures this page’s page frame number (PFN) once at boot into a global, zero_pfn, so the fault path can cheaply form a PTE pointing at it (mm/memory.c, v6.12):

unsigned long zero_pfn __read_mostly;
EXPORT_SYMBOL(zero_pfn);
/* "CONFIG_MMU architectures set up ZERO_PAGE in their paging_init()" */
static int __init init_zero_pfn(void)
{
    zero_pfn = page_to_pfn(ZERO_PAGE(0));   /* remember which PFN is the zero page */
    return 0;
}
early_initcall(init_zero_pfn);

Two accessors wrap that global, and the reason there are two — rather than one constant — is cache colouring. my_zero_pfn(addr) returns the PFN to use for a given virtual address, and is_zero_pfn(pfn) tests whether a PFN is a zero page. On most architectures, including x86-64, both are trivial (include/linux/pgtable.h L1570-L1583):

#else                                   /* the common, uncoloured case */
static inline int is_zero_pfn(unsigned long pfn)
{
	extern unsigned long zero_pfn;
	return pfn == zero_pfn;          /* exactly one PFN is "the" zero page */
}
#define my_zero_pfn(addr)	zero_pfn  /* the address is irrelevant */

But some architectures — MIPS is the in-tree example — have virtually-indexed caches where two virtual addresses that differ in the low bits above PAGE_SHIFT map to different cache sets. If every process read zeros from the same physical frame through many different virtual addresses, those reads would all collide in the same cache set. Such architectures define __HAVE_COLOR_ZERO_PAGE and allocate several consecutive zero frames, one per cache colour (arch/mips/include/asm/pgtable.h L52-L57):

extern unsigned long empty_zero_page;
extern unsigned long zero_page_mask;
#define ZERO_PAGE(vaddr) \
	(virt_to_page((void *)(empty_zero_page + (((unsigned long)(vaddr)) & zero_page_mask))))
#define __HAVE_COLOR_ZERO_PAGE

and the generic is_zero_pfn correspondingly accepts a range: pfn - zero_pfn <= (zero_page_mask >> PAGE_SHIFT) (include/linux/pgtable.h L1560-L1568). This is why the fault handler calls my_zero_pfn(vmf->address) and passes the faulting address rather than just using zero_pfn: on a coloured architecture the address picks the colour. On x86-64 the argument is discarded, and ZERO_PAGE(vaddr) even says so explicitly with ((void)(vaddr), …).

What “Special” Means — the Zero Page Is Invisible to the Rest of mm/

The zero page is not just “a page that happens to be shared.” It is deliberately made invisible to the page-lifecycle machinery, and this is the part of the design that most write-ups skip. A normal anonymous page has a refcount, a mapcount, an anon_vma reverse mapping, a position on an LRU list, a memcg charge, and the ability to be swapped, migrated, compacted, or pinned. The zero page has none of that, and must not, because those mechanisms all assume a frame with one owner or a traceable set of owners — whereas the zero page may be mapped by every process on the machine at once and is never freed.

The enforcement mechanism is the special PTE. The fault handler builds the entry with pte_mkspecial(), which sets an architecture-defined software bit in the PTE. The gatekeeper is vm_normal_page(), the function every part of mm/ calls to go from a PTE to the struct page it “owns” (mm/memory.c L595-L658):

if (IS_ENABLED(CONFIG_ARCH_HAS_PTE_SPECIAL)) {
	if (likely(!pte_special(pte)))
		goto check_pfn;              /* normal page: return pfn_to_page(pfn) */
	...
	if (is_zero_pfn(pfn))
		return NULL;                 /* the zero page owns nothing */
	...
}
...
out:
	VM_WARN_ON_ONCE(is_zero_pfn(pfn));   /* must never reach here with a zero pfn */
	return pfn_to_page(pfn);

vm_normal_page() returning NULL is the single decision that makes everything else fall into place. Callers that walk page tables to find pages to age, unmap, migrate, account or free simply see “no page here” and skip the entry. Concretely, in v6.12:

SubsystemBehaviour on a zero-page mappingWhere
RSS accountingMM_ANONPAGES is never incremented on the read path, so the mapping contributes nothing to VmRSS / RssAnondo_anonymous_page jumps straight to setpte, past add_mm_counter (mm/memory.c L4777)
Reverse mapping / LRUno folio_add_new_anon_rmap, no folio_add_lru_vma — the frame is on no list and has no anon_vmasame branch
Reclaim / swapunreachable: reclaim walks LRU lists, and the zero page is on none—
get_user_pages(FOLL_PIN)refuses to take a pin at all: “Don’t take a pin on the zero page - it’s not going anywhere and it is used in a lot of places”try_grab_folio (mm/gup.c L540-L549), gup_put_folio (L105), folio_add_pin (L215)
Long-term pinningfolio_is_longterm_pinnable() returns true with the comment “The zero page can be ‘pinned’ but gets special handling” — i.e. the pin is a no-op, not a refusalinclude/linux/mm.h L1999-L2001
Page migration / move_pages(2)-EFAULT; the zero page and huge zero folio are explicitly rejected__add_folio_for_migration (mm/migrate.c L2197), do_pages_stat (L2414)
Core dumpsFOLL_DUMP makes GUP return -EFAULT so the dump records a hole rather than gigabytes of zeros — “Avoid special (like zero) pages in core dumps”mm/gup.c L880-L890
NUMA balancingchange_huge_pmd skips a huge-zero PMD: “Avoid trapping faults against the zero page. … local/remote hits to the zero page are not interesting”mm/huge_memory.c L2248-L2254
flowchart TD
  PTE["a present PTE, walked by any<br/>page-table-walking code in mm/"] --> VNP["vm_normal_page(vma, addr, pte)"]
  VNP --> SP{"pte_special(pte)?"}
  SP -->|"no — an ordinary page"| RET["return pfn_to_page(pfn)<br/>caller may refcount it, add it to an<br/>LRU list, reverse-map it, migrate it,<br/>swap it, charge it to a memcg, pin it"]
  SP -->|yes| FSP{"vm_ops-&gt;find_special_page?"}
  FSP -->|yes| FIND["ask the driver"]
  FSP -->|no| PFM{"VM_PFNMAP or VM_MIXEDMAP?"}
  PFM -->|yes| NUL
  PFM -->|no| ZPQ{"is_zero_pfn(pfn)?"}
  ZPQ -->|yes| NUL["<b>return NULL</b>"]
  ZPQ -->|no| DM{"pte_devmap(pte)?"}
  DM -->|yes| NUL
  DM -->|"no — nothing legitimate left"| BAD["print_bad_pte() + return NULL<br/>this is a corruption-class bug"]

  NUL --> C1["RSS / MM_ANONPAGES: not counted"]
  NUL --> C2["LRU and rmap: never added"]
  NUL --> C3["reclaim and swap: unreachable"]
  NUL --> C4["migration / move_pages(2): -EFAULT"]
  NUL --> C5["FOLL_PIN: pin is a silent no-op"]
  NUL --> C6["FOLL_DUMP: -EFAULT, core dump records a hole"]
  NUL --> C7["do_wp_page: folio == NULL, so every<br/>reuse heuristic is skipped -&gt; wp_page_copy"]

vm_normal_page() as the single gatekeeper that hides the zero page from the rest of mm/. What it shows: one is_zero_pfn() test, reached only for PTEs already marked special, produces NULL — and seven distinct subsystem behaviours follow from that one NULL without any of them having to know the zero page exists. The insight to take: the design is not “the zero page is handled everywhere”; it is “the zero page is made to look like no page at all, and every caller’s existing null-handling does the rest.” The print_bad_pte() leaf is the reason this is safe to rely on: a special PTE that is neither the zero page, nor a PFN map, nor devmap is treated as memory corruption, not as an unknown case.

This table is also the answer to “why can’t the kernel just use a regular page and share it?” — a regular shared page is refcounted, and a refcount incremented by every process on the system is exactly the contended cache line the design exists to avoid. Making the frame special removes it from the refcounting economy altogether. The price is the long tail of is_zero_pfn() checks above: every subsystem that walks page tables has to know the exception exists. That tail is real, and it has produced bugs — vm_normal_page()’s print_bad_pte()/VM_WARN_ON_ONCE guards are there precisely because a special PTE reaching a caller that assumed a normal page is a corruption-class bug.

The one architecture that refuses: s390

The escape hatch in the read branch is mm_forbids_zeropage(vma->vm_mm). The generic definition is a no-op, and its comment names the exception directly (include/linux/mm.h L123-L132):

/*
 * To prevent common memory management code establishing
 * a zero page mapping on a read fault.
 * This macro should be defined within <asm/pgtable.h>.
 * s390 does this to prevent multiplexing of hardware bits
 * related to the physical page in case of virtualization.
 */
#ifndef mm_forbids_zeropage
#define mm_forbids_zeropage(X)	(0)
#endif

s390 overrides it (arch/s390/include/asm/pgtable.h L588-L601):

/*
 * As soon as the guest uses storage keys or enables PV, we deduplicate all
 * mapped shared zeropages and prevent new shared zeropages from getting
 * mapped.
 */
#define mm_forbids_zeropage mm_forbids_zeropage
static inline int mm_forbids_zeropage(struct mm_struct *mm)
{
#ifdef CONFIG_PGSTE
	if (!mm->context.allow_cow_sharing)
		return 1;
#endif
	return 0;
}

The reason is s390-specific hardware: storage keys are per-physical-frame protection and reference/change bits, and Protected Virtualization (PV) requires each guest page to be distinguishable. Both attach state to the frame, so a frame shared by many mappings cannot carry per-mapping state correctly. When a KVM guest on s390 starts using storage keys or enables PV, the kernel not only stops handing out new zero-page mappings — it “deduplicates” the existing ones, i.e. replaces every already-installed shared zero-page PTE with a private allocated page. On such an mm, a read fault on untouched anonymous memory allocates a real page, exactly like a write. This is the concrete proof that demand paging and the zero page are separable optimisations: s390 under storage keys keeps the first and gives up the second.

Resolved

An earlier revision of this note flagged the s390 attribution as unverified. It is now confirmed against both the generic #define in include/linux/mm.h and the arch override in arch/s390/include/asm/pgtable.h at the v6.12 tag, fetched during this revision.

Lazy Allocation — the Read vs. Write Fork in do_anonymous_page

Both behaviors — zero page on read, real allocation on write — live in a single function, do_anonymous_page, the handler for a fault on an anonymous mapping with no existing page. The very first thing it does after allocating a page-table level is to branch on whether the fault was a write (mm/memory.c, v6.12):

/* Use the zero-page for reads */
if (!(vmf->flags & FAULT_FLAG_WRITE) &&
        !mm_forbids_zeropage(vma->vm_mm)) {
    entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
                                  vma->vm_page_prot));
    ...
    goto setpte;          /* install the zero-page PTE; allocate nothing */
}
 
/* Allocate our own private page. */
ret = vmf_anon_prepare(vmf);
...
folio = alloc_anon_folio(vmf);     /* a real, zeroed page (or large folio) */
...
entry = mk_pte(&folio->page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (vma->vm_flags & VM_WRITE)
    entry = pte_mkwrite(pte_mkdirty(entry), vma);
...
folio_add_new_anon_rmap(folio, vma, addr, RMAP_EXCLUSIVE);   /* owned by exactly one mm */

Reading this carefully:

  • Read branch. !(vmf->flags & FAULT_FLAG_WRITE) is true for a read fault. pfn_pte(my_zero_pfn(...), prot) builds a PTE that points at the zero page; pte_mkspecial marks it special so the rest of the kernel knows this PTE does not own a normal page (it won’t try to free or migrate the zero page). The protection bits keep it read-only. Control jumps to setpte, which installs the entry — no folio is allocated, no MM_ANONPAGES counter is bumped. The read instruction restarts and sees zeros. mm_forbids_zeropage is the one escape hatch: a few configurations (on s390, address spaces using storage keys, where a shared read-only zero page would be incorrect) disable the zero-page optimization, in which case even a read allocates a real page. Note that the userfaultfd_missing(vma) test inside this same branch is a separate check — it lets a registered userfaultfd handler intercept the first access rather than silently mapping the zero page.

  • Write branch. A write fault skips the zero-page block and proceeds to allocate. alloc_anon_folio returns a genuinely zeroed page (kernel-allocated anonymous memory is always zeroed, so no leak of prior contents), the PTE is built writable and dirty (pte_mkwrite/pte_mkdirty), and folio_add_new_anon_rmap(..., RMAP_EXCLUSIVE) records that this page is owned by exactly one process — setting the PageAnonExclusive state that COW later relies on. Now MM_ANONPAGES is incremented; this is the moment RAM is actually committed.

The full walk of this function — including the pte_alloc cost, the __folio_mark_uptodate barrier, and the multi-size THP (mTHP) behaviour of alloc_anon_folio — belongs to Demand Paging and is not repeated here. What is this note’s subject is the third path, below, which do_anonymous_page never sees.

Writing to a Zero-Page PTE — Copy-on-Write Without a Copy

The path that is easy to miss is the one where a page is first read (so it is mapped read-only to the shared zero page) and is later written. That write does not re-enter do_anonymous_page, because the PTE is present — it is simply not writable. The hardware raises a protection fault, handle_pte_fault sees a present non-writable entry on a VM_WRITE VMA, and dispatches to do_wp_page, the ordinary copy-on-write breaker. The zero page then takes two shortcuts that no other COW source gets.

First: there is no “old page” to unshare. do_wp_page begins with vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte) (mm/memory.c L3694), and as established above, vm_normal_page() returns NULL for a zero PFN. So folio stays NULL, every “can I just reuse this page?” heuristic — folio_ref_count(), folio_mapcount(), PageAnonExclusive — is skipped, and control falls straight through to wp_page_copy(). There is nothing to reuse: the zero page is shared by definition and can never be made writable.

Second: the copy is replaced by a zero-fill. wp_page_copy() tests for the zero page explicitly and threads the result into the allocation (mm/memory.c L3352-L3361):

pfn_is_zero = is_zero_pfn(pte_pfn(vmf->orig_pte));
new_folio = folio_prealloc(mm, vma, vmf->address, pfn_is_zero);
if (!new_folio)
	goto oom;
 
if (!pfn_is_zero) {                                   /* ordinary COW: copy the old page */
	int err = __wp_page_copy_user(&new_folio->page, vmf->page, vmf);
	...
}

and folio_prealloc()’s need_zero argument picks the allocator (mm/memory.c L1056-L1066):

if (need_zero)
	new_folio = vma_alloc_zeroed_movable_folio(vma, addr);   /* GFP includes __GFP_ZERO */
else
	new_folio = vma_alloc_folio(GFP_HIGHUSER_MOVABLE, 0, vma, addr, false);

So breaking COW on the zero page costs one allocation of an already-zeroed page and no memcpy at all — the allocator’s own zeroing (which the kernel must do anyway before handing a frame to userspace) is the copy. A “copy-on-write break” that never copies. This is why the read-then-write sequence and the write-first sequence end up costing almost exactly the same, and why the use_zero_page tuning argument (below) is finer-grained than it first looks: the wasted work of the read-then-write pattern is one extra fault and one extra TLB flush, not an extra 4 KiB of copying.

Third: the accounting is fixed up on the old_folio == NULL branch. Because the zero-page mapping was never counted in MM_ANONPAGES, wp_page_copy has to add it now, and it also tells KSM if the mapping it is replacing was one KSM had merged into the zero page (mm/memory.c L3395-L3399):

} else {                                    /* old_folio == NULL: we came from the zero page */
	ksm_might_unmap_zero_page(mm, vmf->orig_pte);
	inc_mm_counter(mm, MM_ANONPAGES);
}
...
ptep_clear_flush(vma, vmf->address, vmf->pte);
folio_add_new_anon_rmap(new_folio, vma, vmf->address, RMAP_EXCLUSIVE);
folio_add_lru_vma(new_folio, vma);
set_pte_at(mm, vmf->address, vmf->pte, entry);

This is the exact moment RSS grows, and it is the moment the process’s memory becomes real.

sequenceDiagram
    autonumber
    participant P as Process
    participant HW as MMU / hardware
    participant K as Kernel fault path
    participant Z as ZERO_PAGE frame
    participant B as Page allocator

    Note over P,B: t0 — mmap(MAP_PRIVATE|MAP_ANONYMOUS): VMA exists, PTEs are pte_none
    P->>HW: LOAD addr
    HW->>K: #PF, no FAULT_FLAG_WRITE
    K->>K: do_anonymous_page: read branch
    K->>Z: my_zero_pfn(addr)
    K->>HW: set_ptes(PTE = zero_pfn, read-only, pte_special)
    Note right of K: no allocation, no rmap,<br/>no LRU, MM_ANONPAGES unchanged
    HW-->>P: 0x00 (RSS still 0)

    Note over P,B: t1 — the same address is now written
    P->>HW: STORE addr
    HW->>K: #PF, FAULT_FLAG_WRITE (PTE present but read-only)
    K->>K: handle_pte_fault -> do_wp_page
    K->>K: vm_normal_page() returns NULL (special PTE)
    K->>K: wp_page_copy: pfn_is_zero = true
    K->>B: folio_prealloc(need_zero = true)
    B-->>K: freshly zeroed order-0 folio
    Note right of K: __wp_page_copy_user() is SKIPPED —<br/>no memcpy is ever performed
    K->>K: inc_mm_counter(MM_ANONPAGES)<br/>folio_add_new_anon_rmap(RMAP_EXCLUSIVE)<br/>folio_add_lru_vma
    K->>HW: ptep_clear_flush + set_pte_at(writable, dirty)
    HW-->>P: store completes (RSS +4 KiB)

The life of one address, from mmap through a read to a write. What it shows: two faults, one allocation, and — crucially — no copy. Step 5 installs a PTE pointing at a frame that already existed; step 15 allocates a frame that the allocator zeroes anyway, so the “copy” half of copy-on-write is elided entirely. The insight to take: the shared zero page turns the first read into a pure page-table operation, and the deferred write into a plain allocation. The only genuine waste in the read-then-write order is the second fault itself — which is exactly what use_zero_page=0 buys you out of for PMD-sized mappings.

The reverse direction: KSM can put a page back. The ksm_might_unmap_zero_page() call above exists because Kernel Samepage Merging can travel this road backwards. With /sys/kernel/mm/ksm/use_zero_pages set to 1, “empty pages are merged with the kernel zero page(s) instead of with each other as it would happen normally. This can improve the performance on architectures with coloured zero pages” (Documentation/admin-guide/mm/ksm.rst L120-L128). So a page that a process wrote — and that therefore cost a real frame — can later be scanned by ksmd, found to be all zeros (try_to_merge_with_zero_page, gated on rmap_item->oldchecksum == zero_checksum, mm/ksm.c L1506-L1520), and collapsed back into a read-only zero-page mapping, freeing the frame. The savings are counted separately in /sys/kernel/mm/ksm/ksm_zero_pages, and the KSM documentation is explicit that they must be added in by hand: “the sum of pages_sharing + ksm_zero_pages represents the actual number of pages saved by KSM” (ksm.rst L219-L221). The knob defaults to off.

The Huge Zero Page (for Transparent Huge Pages)

The 4 KiB zero page does not help a region being mapped with 2 MiB transparent huge pages (THP): a PMD-level fault wants to install one entry covering 2 MiB, and a PMD entry must point at a 2 MiB-aligned, 2 MiB-sized physical block. Pointing it at a 4 KiB frame is not expressible. So before the huge zero page existed, enabling THP silently disabled the zero-page optimisation for any THP-eligible region — and that regression is exactly why the huge zero page was written.

Why it exists — the 200 MiB that became 200 MiB

Jonathan Corbet’s write-up of Kirill Shutemov’s original series states the problem with a program you can run yourself (LWN, Adding a huge zero page, 26 September 2012):

posix_memalign((void **)&p, 2 * MB, 200 * MB);
for (i = 0; i < 200 * MB; i += 4096)
    assert(p[i] == 0);      /* read only -- never writes */
pause();

“will have a surprisingly small resident set at the time of the pause() call. It has just worked through 200MB of memory, but that memory is all represented by a single zero page. The system works as intended. Or, it does until the transparent huge pages feature is enabled; then that process will show the full 200MB of allocated memory. A growth of memory usage by two orders of magnitude is not the sort of result users are typically looking for when they enable a performance-enhancing feature. So, Kirill says, some sites are finding themselves forced to disable transparent huge pages in self defense.” The article names the cause precisely: “Transparent huge page support, in other words, has turned off another important optimization that has been part of the kernel’s memory management subsystem for many years.” The series landed in 3.8, as the article predicted it would. (The original posting is archived at LWN’s copy of the RFC cover letter.)

Lifecycle — lazily allocated, refcounted, shrinkable

The small zero page is statically allocated at boot and never freed; the huge zero page costs 2 MiB, so it is allocated on first need and freed under memory pressure. It lives behind an atomic refcount and a registered shrinker (mm/huge_memory.c L79-L81, L197-L233):

static atomic_t huge_zero_refcount;
struct folio *huge_zero_folio __read_mostly;
unsigned long huge_zero_pfn __read_mostly = ~0UL;
 
static bool get_huge_zero_page(void)
{
retry:
	if (likely(atomic_inc_not_zero(&huge_zero_refcount)))
		return true;                          /* (1) fast path: already allocated */
 
	zero_folio = folio_alloc((GFP_TRANSHUGE | __GFP_ZERO) & ~__GFP_MOVABLE,
			HPAGE_PMD_ORDER);             /* (2) 2 MiB, zeroed, NOT movable */
	if (!zero_folio) {
		count_vm_event(THP_ZERO_PAGE_ALLOC_FAILED);
		return false;                         /* (3) caller falls back to small pages */
	}
	folio_clear_large_rmappable(zero_folio);
	preempt_disable();
	if (cmpxchg(&huge_zero_folio, NULL, zero_folio)) {
		preempt_enable();
		folio_put(zero_folio);                /* (4) lost the race; free ours, retry */
		goto retry;
	}
	WRITE_ONCE(huge_zero_pfn, folio_pfn(zero_folio));
 
	/* We take additional reference here. It will be put back by shrinker */
	atomic_set(&huge_zero_refcount, 2);       /* (5) 1 for us, 1 for the cache itself */
	preempt_enable();
	count_vm_event(THP_ZERO_PAGE_ALLOC);
	return true;
}

Line (2) is worth pausing on: the allocation deliberately clears __GFP_MOVABLE. A movable allocation could be placed in ZONE_MOVABLE or a CMA pageblock and later migrated; the huge zero folio is mapped by PMD entries in arbitrarily many address spaces and, as the table above showed, migration explicitly refuses to touch it — so it must not land anywhere that assumes it can be moved. Line (5) is the refcount trick that makes the shrinker work: the count is set to 2, not 1, so that the cache itself holds a permanent reference which only the shrinker may release.

The per-mm bookkeeping is coarse on purpose (L236-L253):

struct folio *mm_get_huge_zero_folio(struct mm_struct *mm)
{
	if (test_bit(MMF_HUGE_ZERO_PAGE, &mm->flags))
		return READ_ONCE(huge_zero_folio);   /* this mm already holds a reference */
	if (!get_huge_zero_page())
		return NULL;
	if (test_and_set_bit(MMF_HUGE_ZERO_PAGE, &mm->flags))
		put_huge_zero_page();                /* raced: another thread of this mm got it */
	return READ_ONCE(huge_zero_folio);
}

Each mm_struct takes at most one reference on the huge zero folio, recorded by a single flag bit, no matter how many PMDs it maps it into; the reference is dropped in mm_put_huge_zero_folio() at mmput time. That is the design answer to the cost Corbet flagged in 2012 — “that reference counting slows a read-faulting benchmark by 1%” — reduced from once-per-fault to once-per-address-space.

The shrinker completes the cycle (L256-L275). Its count_objects reports HPAGE_PMD_NR (512) reclaimable pages only when atomic_read(&huge_zero_refcount) == 1 — that is, when the cache’s own reference is the last one and no address space is using it. Its scan_objects then does atomic_cmpxchg(&huge_zero_refcount, 1, 0) and, if it wins, swaps out the folio, resets huge_zero_pfn to ~0UL and frees the 2 MiB.

stateDiagram-v2
    [*] --> Absent: boot<br/>huge_zero_folio = NULL<br/>huge_zero_pfn = ~0UL<br/>refcount = 0
    Absent --> Allocating: first PMD read fault on a<br/>THP-eligible anon VMA
    Allocating --> Absent: folio_alloc fails<br/>THP_ZERO_PAGE_ALLOC_FAILED++<br/>fall back to small pages
    Allocating --> Cached: folio_alloc(GFP_TRANSHUGE or __GFP_ZERO)<br/>cmpxchg install, refcount = 2<br/>THP_ZERO_PAGE_ALLOC++
    Cached --> InUse: mm_get_huge_zero_folio()<br/>sets MMF_HUGE_ZERO_PAGE,<br/>refcount++ (once per mm)
    InUse --> InUse: more mms map it<br/>(refcount++ per mm, not per PMD)
    InUse --> Cached: mmput / exit_mmap<br/>mm_put_huge_zero_folio()<br/>refcount-- back to 1
    Cached --> Absent: shrinker under memory pressure<br/>cmpxchg(refcount, 1, 0) wins,<br/>frees 2 MiB
    Cached --> InUse: another read fault
    note right of Cached
      refcount == 1 means "only the cache
      holds it": the shrinker's count_objects
      reports 512 reclaimable pages here
      and zero everywhere else.
    end note

The huge zero folio’s lifecycle in v6.12. What it shows: a single global 2 MiB frame that is created on demand, held by a per-mm flag bit rather than a per-mapping refcount, and reclaimed only when no address space is using it. The insight to take: the small zero page is free because it is permanent; the huge zero page trades permanence for 2 MiB of reclaimability, which is why it has a shrinker, two vmstat counters and a sysfs switch that the small one does not need.

The PMD read fault

do_huge_pmd_anonymous_page() mirrors the small-page branch, with one extra gate (mm/huge_memory.c L1272-L1325):

if (!(vmf->flags & FAULT_FLAG_WRITE) &&            /* a READ fault... */
		!mm_forbids_zeropage(vma->vm_mm) &&
		transparent_hugepage_use_zero_page()) {    /* ...and use_zero_page is on */
	pgtable = pte_alloc_one(vma->vm_mm);           /* deposited for a later split */
	zero_folio = mm_get_huge_zero_folio(vma->vm_mm);
	if (unlikely(!zero_folio)) {
		pte_free(vma->vm_mm, pgtable);
		count_vm_event(THP_FAULT_FALLBACK);
		return VM_FAULT_FALLBACK;                  /* retry at PTE level */
	}
	vmf->ptl = pmd_lock(vma->vm_mm, vmf->pmd);
	...
	set_huge_zero_folio(pgtable, vma->vm_mm, vma, haddr, vmf->pmd, zero_folio);
	return ret;                                    /* allocated nothing real */
}
gfp = vma_thp_gfp_mask(vma);
folio = vma_alloc_folio(gfp, HPAGE_PMD_ORDER, vma, haddr, true);   /* WRITE: real 2 MiB */
return __do_huge_pmd_anonymous_page(vmf, &folio->page, gfp);

set_huge_zero_folio() builds the entry with mk_pmd(&zero_folio->page, vma->vm_page_prot) followed by pmd_mkhuge(entry) — note there is no pmd_mkwrite, so the PMD is read-only (L1258-L1270). It also deposits a preallocated PTE table (pgtable_trans_huge_deposit) which exists solely so that a later split never has to allocate under a page-table lock.

Controlling and observing it

Knob or counterPathDefaultMeaning
use_zero_page/sys/kernel/mm/transparent_hugepage/use_zero_page1“By default kernel tries to use huge, PMD-mappable zero page on read page fault to anonymous mapping”; write 0 to disable (transhuge.rst L192-L197). Backed by TRANSPARENT_HUGEPAGE_USE_ZERO_PAGE_FLAG (mm/huge_memory.c L413-L425)
thp_zero_page_alloc/proc/vmstat—“incremented every time a huge zero page used for thp is successfully allocated. Note, it doesn’t count every map of the huge zero page, only its allocation” (transhuge.rst L496-L499)
thp_zero_page_alloc_failed/proc/vmstat—the 2 MiB allocation failed and the fault fell back to small pages (transhuge.rst L501-L503)
hpage_pmd_size/sys/kernel/mm/transparent_hugepage/hpage_pmd_size2097152 on x86-64the size a huge zero mapping covers per PMD
CONFIG_TRANSPARENT_HUGEPAGEbuild timey on Fedorawithout it there is no huge zero page at all
CONFIG_PERSISTENT_HUGE_ZERO_FOLIObuild timenew in v6.18, n on Fedora 44’s 7.1.13 kernel“the huge zero folio is allocated once and never freed” — removes the refcounting and shrinker entirely (v6.18 mm/Kconfig L798-L812)

The use_zero_page switch is the only one an operator normally touches, and it is a latency-versus-footprint trade, not a correctness one. Turning it off makes the first read of a THP-eligible region allocate a real 2 MiB folio (or fall back to small pages), which is the right choice for a workload that reads then writes nearly all of its memory — the zero-page detour there is pure overhead, paid back later as a fault and a TLB flush. Leaving it on is right for anything with a large sparsely-touched arena.

Version history — this has changed twice since v6.12

Both changes were dated by existence-checking: fetching mm/huge_memory.c and mm/Kconfig at successive release tags from raw.githubusercontent.com and recording whether a symbol was present.

Behaviourv6.12 (this note’s pin)Changed inEvidence
Write fault on a huge-zero PMDdo_huge_pmd_wp_page does if (is_huge_zero_pmd(orig_pmd)) goto fallback; → __split_huge_pmd() replaces the PMD with 512 small zero-page PTEs, returns VM_FAULT_FALLBACK, and the write re-faults at PTE level, ending in one 4 KiB pagev6.13 — do_huge_zero_wp_pmd() allocates a full THP directly, splitting only if that allocation failsdo_huge_zero_wp_pmd absent in v6.12, present in v6.13 through v7.1. Commit 1ced09e0, “mm: allocate THP on hugezeropage wp-fault”, Dev Jain, 2024-10-08
Huge zero folio lifetimealways refcounted + shrinker-freedv6.18 adds optional CONFIG_PERSISTENT_HUGE_ZERO_FOLIO: “allocated once and never freed”PERSISTENT_HUGE_ZERO_FOLIO absent from mm/Kconfig at v6.12/v6.16/v6.17, present at v6.18

The v6.13 change is visible from userspace, and the measurement in the next section shows it: on the v6.12 code a single byte written into a huge-zero-mapped region yields 4 KiB of RSS; on the 7.1.13 kernel this note was measured on, the same byte yields 2 MiB of RSS and one thp_fault_alloc. The v6.18 change is the eventual arrival of an option Corbet floated in 2012 — “in the end, the developers might conclude that it’s better to just keep the zero huge page around once it has been allocated and not pay the reference counting cost” — thirteen years later, as an opt-in.

Measuring It on a Live System

Everything above is observable from an unprivileged shell; no perf, no tracepoints, no root. The measurements below were taken on Fedora 44, kernel 7.1.13-200.fc44.x86_64, an AMD Ryzen AI MAX+ 395 (16 cores / 32 threads, single NUMA node, 125 GiB RAM). The kernel is not v6.12, and where that matters the difference is called out — that gap is itself one of the more instructive results.

RSS stays at zero through two million read faults

Map 8 GiB of private anonymous memory, read every page, then write one byte per page, sampling /proc/self/status (VmSize, VmRSS, RssAnon) and the region’s own entry in /proc/self/smaps (Size, Rss, Pss) at each step:

mapped 8.0 GiB anonymous at 0x7f07b2600000 (page size 4096)

after mmap (untouched)     VmSize=8391208  VmRSS=1856     RssAnon=112      | region Size=8388608  Rss=0        Pss=0
read all 2097152 pages, checksum of bytes = 0 (all zero)
after reading ALL pages    VmSize=8391208  VmRSS=1856     RssAnon=112      | region Size=8388608  Rss=0        Pss=0
wrote 1 byte in each of the first 262144 pages (1 GiB of address range)
after writing 1 GiB worth  VmSize=8391208  VmRSS=1050432  RssAnon=1048688  | region Size=8388608  Rss=1048576  Pss=1048576
after writing 4 GiB worth  VmSize=8391208  VmRSS=4196160  RssAnon=4194416  | region Size=8388608  Rss=4194304  Pss=4194304
after MADV_DONTNEED(all)   VmSize=8391208  VmRSS=1856     RssAnon=112      | region Size=8388608  Rss=0        Pss=0
after re-READ 4 GiB        VmSize=8391208  VmRSS=1856     RssAnon=112      | region Size=8388608  Rss=0        Pss=0

Reading it line by line. VmSize is 8,391,208 kB and never moves — that is the address space, which mmap reserved in full and which nothing in this experiment changes. Rss for the region is 0 kB after 2,097,152 read faults: two million PTEs were installed, every one of them pointing at the same physical frame, and not one page of memory was committed. The Pss (proportional set size) column is 0 too, which is the stronger statement — Pss divides each shared page by its sharer count, and a page that is not counted in Rss at all cannot show up here either. Writing one byte into each of the first 262,144 pages moves Rss to exactly 1,048,576 kB = 1 GiB: one full 4 KiB frame per page touched, no more and no less, because each write took the wp_page_copy path described above and each folio_prealloc(need_zero=true) returned exactly one order-0 folio. madvise(MADV_DONTNEED) drops every PTE and returns Rss to 0, and re-reading puts all of it back on the zero page — the dashed edge in the decision diagram.

The gap between VmRSS (4,196,160 kB) and the region’s Rss (4,194,304 kB) is 1,856 kB: the program’s own text, stack, libc and page tables. It is worth keeping in view because it is the floor under any such measurement.

Which mappings actually get the zero page

The same technique applied to six different mapping kinds, 256 MiB each, produces the Rss column of the table at the top of this note:

1. private anon, READ every page           Size=262144 kB  Rss=0        kB  Pss=0        kB
2. private anon, then WRITE every page     Size=262144 kB  Rss=262144   kB  Pss=262144   kB
3. after MADV_DONTNEED, READ again         Size=262144 kB  Rss=0        kB  Pss=0        kB
4. SHARED anon (shmem), READ every page    Size=262144 kB  Rss=262144   kB  Pss=262144   kB
5. sparse btrfs FILE hole, PRIVATE READ    Size=262144 kB  Rss=262144   kB  Pss=262144   kB
6. sparse TMPFS hole, MAP_PRIVATE READ     Size=262144 kB  Rss=262144   kB  Pss=262144   kB
7. same tmpfs hole via read(2), no mmap    read(2) got 268435456 bytes; VmRSS 1864 -> 2888 kB (delta +1024)

Rows 4, 5 and 6 are the ones that surprise people: a read fault on shared anonymous memory, on a hole in a real file, and on a hole in a tmpfs file each instantiate 256 MiB of real pages, despite every byte reading as zero. Only the private anonymous mapping is free. Row 7 is the counterpoint that proves tmpfs does know about the zero page — reading the identical 256 MiB hole with read(2) instead of mmap grows RSS by 1,024 kB, which is precisely the size of the program’s own 1 MiB destination buffer being faulted in; the file data itself came out of ZERO_PAGE(0) and cost nothing.

The huge zero page, and a behaviour change since v6.12

THP on this machine is in madvise mode, so the region needs MADV_HUGEPAGE and 2 MiB alignment. Reading one byte per 2 MiB across a 512 MiB region, then writing:

$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
$ cat /sys/kernel/mm/transparent_hugepage/use_zero_page
1
$ cat /sys/kernel/mm/transparent_hugepage/hpage_pmd_size
2097152

512 MiB MADV_HUGEPAGE region at 0x7f075e400000 (2 MiB aligned)

after mmap+MADV_HUGEPAGE   Size=524288 Rss=0       AnonHugePages=0       thp_zero_page_alloc=0 thp_fault_alloc=736
READ one byte per 2 MiB (256 PMD-sized reads), sum=0
after READ                 Size=524288 Rss=0       AnonHugePages=0       thp_zero_page_alloc=1 thp_fault_alloc=736
WROTE 1 byte at offset 0
after 1 WRITE              Size=524288 Rss=2048    AnonHugePages=2048    thp_zero_page_alloc=1 thp_fault_alloc=737
WROTE one byte per 2 MiB
after WRITE all PMDs       Size=524288 Rss=524288  AnonHugePages=524288  thp_zero_page_alloc=1 thp_fault_alloc=992

Three things to read out of this. First, 256 PMD read faults produced exactly one thp_zero_page_alloc — the counter went 0 → 1 and stopped, which is the documented semantics (“it doesn’t count every map of the huge zero page, only its allocation”) and the visible proof that all 256 PMDs point at the same 2 MiB frame. Second, Rss and AnonHugePages both stayed at 0 through those reads: the huge zero folio is not charged to the mapping, for exactly the same is_zero_pfn-style reasons the small one is not. Third — and this is the version gap — one byte written produced 2,048 kB of Rss and one thp_fault_alloc, i.e. a whole 2 MiB THP.

On the v6.12 tree this note cites, that write would have produced 4 KiB. do_huge_pmd_wp_page() at v6.12 does if (is_huge_zero_pmd(orig_pmd)) goto fallback;, fallback calls __split_huge_pmd(), __split_huge_pmd_locked() recognises the huge-zero case and calls __split_huge_zero_page_pmd(), which replaces the PMD with 512 PTEs each built as pte_mkspecial(pfn_pte(my_zero_pfn(addr), prot)) (mm/huge_memory.c L2606-L2644) — the huge zero page shattering into small zero pages — and then returns VM_FAULT_FALLBACK so the write re-faults at PTE level and copies one 4 KiB frame out of the small zero page. The 2 MiB result above is v6.13’s do_huge_zero_wp_pmd() doing what the commit says: “Introduce do_huge_zero_wp_pmd() to handle wp-fault on a hugezeropage and replace it with a PMD-mapped THP … In case of failure, fallback to splitting the PMD.” Both behaviours are defensible; the point for a reader is that the RSS jump from a single byte is 4 KiB on v6.12 and 2 MiB on v6.13 and later, and a memory-footprint model built on one will be wrong on the other.

Uncertain

Verify: that a v6.12 kernel really does yield 4 KiB rather than 2 MiB of Rss for the single-byte write above. The v6.12 code path was read directly and is unambiguous, but no v6.12 kernel was available to run the program on — the measured 2 MiB is from 7.1.13. Reason: no v6.12 machine or VM in this environment. To resolve: run the same program under a v6.12 kernel (qemu-system-x86_64 with a 6.12 bzImage would settle it) and compare Rss and thp_fault_alloc after the single byte. uncertain

What this does not measure, and what would

Two things could not be measured here and are flagged rather than guessed. There is no way from userspace to observe how many distinct address spaces currently hold a reference on the huge zero folio — huge_zero_refcount is a static in mm/huge_memory.c with no sysfs or vmstat export; MMF_HUGE_ZERO_PAGE is likewise internal. Watching the shrinker actually free the 2 MiB would require either bpftrace/kprobes on shrink_huge_zero_page_scan or the mm_shrink_slab_start tracepoint under /sys/kernel/debug/tracing — and on this machine perf, bpftool, strace and ltrace are not installed and /sys/kernel/debug is mode 0700 with no passwordless sudo. The command that would resolve it is sudo bpftrace -e 'kretprobe:shrink_huge_zero_page_scan { printf("freed %d\n", retval); }' followed by memory pressure. Second, use_zero_page could not be toggled to measure the alternative, because /sys/kernel/mm/transparent_hugepage/use_zero_page is root-writable only; sudo sh -c 'echo 0 > /sys/kernel/mm/transparent_hugepage/use_zero_page' and a re-run of the THP program would show the read faults allocating real 2 MiB folios.

Why calloc, mmap(MAP_ANONYMOUS), and Big Buffers Are “Cheap”

This mechanism explains a frequently-surprising performance fact: allocating an enormous zeroed buffer is nearly instant and shows almost no resident memory until you use it.

  • mmap(MAP_ANONYMOUS) returns memory whose “contents are initialized to zero” (mmap(2)), but per demand paging it installs no pages at mmap time. Reads hit the zero page; writes allocate. So mmaping 100 GiB anonymous succeeds instantly and costs ~nothing until touched (subject to overcommit accounting).

  • calloc(n, size) allocates and zeroes. For large requests, the C library (e.g. glibc) services them with mmap(MAP_ANONYMOUS) and skips the explicit memset entirely, because the kernel already guarantees fresh anonymous pages read as zero. calloc(3) itself notes the difference from malloc: “The memory is set to zero” (calloc(3), in malloc(3)). The win is that the zeroing is lazy and shared: a calloc of a gigabyte does not write a gigabyte of zeros up front — every untouched page just reads the one zero page.

  • Sparse data structures (large hash tables, bitmap arenas, MAP_NORESERVE regions) exploit the same property: you reserve a huge address range and only the cells you actually write consume RAM.

Confirming the calloc claim in glibc’s source

The “calloc skips the memset” claim is a libc implementation detail, not a kernel guarantee, so it is worth checking rather than repeating. In glibc 2.42 (release/2.42/master, #define VERSION "2.42" in version.h), __libc_calloc2() calls _int_malloc() and then decides whether it has to clear (malloc/malloc.c):

  /* Two optional cases in which clearing not necessary */
  if (chunk_is_mmapped (p))
    {
      if (__glibc_unlikely (perturb_byte))
        return memset (mem, 0, sz);
 
      return mem;                      /* <- the whole allocation, returned unwritten */
    }
 
#if MORECORE_CLEARS
  if (perturb_byte == 0 && (p == oldtop && csz > oldtopsize))
    {
      /* clear only the bytes from non-freshly-sbrked memory */
      csz = oldtopsize;
    }
#endif
 
  clearsize = csz - SIZE_SZ;
  return clear_memory ((INTERNAL_SIZE_T *) mem, clearsize);

So there are two elisions, not one. A chunk that came from mmap — which is what a large calloc gets, above M_MMAP_THRESHOLD — is returned completely unwritten: glibc relies directly on the kernel’s guarantee that fresh anonymous memory reads as zero, which in turn is the zero page doing the work. And when the heap top was just extended by sbrk, MORECORE_CLEARS lets glibc clear “only the bytes from non-freshly-sbrked memory”, because the freshly-sbrked part is likewise kernel-fresh. The two escape hatches are perturb_byte (the M_PERTURB debugging mode, which deliberately writes a byte pattern) and memory tagging (mtag_enabled, which must touch every granule to set tags and so zeroes the block on the way through). Both force real writes and therefore real page allocations — which is a genuine, if rarely-hit, reason a program’s RSS can explode when MALLOC_PERTURB_ is set in the environment.

Resolved

An earlier revision flagged this claim as unverified. It is now confirmed against glibc 2.42’s malloc/malloc.c, fetched from sourceware.org during this revision. The sourceware.org git web interface returned HTTP 200 to plain curl; lore.kernel.org remains 403 and was not used.

Failure Modes and Common Misunderstandings

  • “Touching memory once is enough to commit it.” Only a write commits a private page. A program that allocates a big buffer and only ever reads it (e.g. to check it’s zero) keeps every page mapped to the shared zero page and uses essentially no RAM — Resident Set Size stays tiny. People debugging “where did my memory go?” sometimes write a touch-loop to force commitment; a read-loop will not do it.

  • The write storm after lazy allocation. Because commitment is deferred, a program can mmap more than physical RAM, pass overcommit checks, and then be OOM-killed later when it writes the pages and the kernel must actually find frames. The allocation “succeeded” long before the memory existed.

  • Zero page defeats some measurements. Tools that estimate memory by counting mapped pages can over-count if they treat zero-page-backed PTEs as real pages. The kernel marks these PTEs special (pte_special) precisely so the rest of mm/ does not mistake the shared zero page for an owned page.

  • use_zero_page and latency tuning. Disabling the huge zero page (use_zero_page=0) makes the first read of THP-eligible memory allocate a real 2 MiB page (or fall back to small pages), trading the read-side savings for more deterministic — but heavier — early allocation. This is occasionally done for workloads that read-then-write nearly all their memory, where the zero-page detour is wasted effort that is paid back as a COW break.

  • “My container’s memory limit is fine, so the memory exists.” Cgroup charging happens at the same moment RSS does — when a real page is allocated — so a process inside a tight memory.max can read terabytes of zero-page-backed address space without being charged a byte, and then be OOM-killed by the cgroup on the first write pass. The Rss=0 in the measurement above is memory.current=0 too.

  • Reading smaps and double-counting. A zero-page-backed PTE contributes nothing to Rss, Pss, Private_Clean, Shared_Clean or Referenced, so any tool that estimates footprint by counting PTEs rather than reading Rss will over-count by the full size of the untouched region. Size minus Rss for a private anonymous VMA is, to a first approximation, exactly the zero-page-backed portion.

  • Core dumps look sparse and that is deliberate. FOLL_DUMP makes GUP refuse the zero page, so mmaped regions that were only ever read appear in the dump as holes rather than as gigabytes of zeros (mm/gup.c L880-L890). A debugger reading such an address from the core file sees zeros anyway, because the region is sparse — but a naive tool that compares dump size to VmSize will conclude the dump is truncated. It is not.

  • MADV_DONTNEED is the reset, MADV_FREE is not. madvise(MADV_DONTNEED) on private anonymous memory zaps the PTEs, so the next read goes back to the zero page and RSS drops immediately — the measurement above shows the full 4 GiB → 0 transition. MADV_FREE instead marks the pages lazily reclaimable: RSS does not drop until the kernel actually reclaims them, and if the process touches the range again before that happens it gets its original pages back, contents intact. Reaching for MADV_FREE and expecting the zero page is a common way to get confused by /proc/self/status.

  • Benchmarks that “allocate and check” measure nothing. A microbenchmark that mmaps a buffer, reads it to “warm it up”, and then times something is measuring zero-page reads on the warm-up pass and wp_page_copy faults on the real pass. Warm-up loops for anonymous memory must write.

See Also

  • Demand Paging — the broader lazy-population policy and the full walk of do_anonymous_page; this note is its companion, not its summary: demand paging owns “nothing is allocated until touched”, this note owns the shared frame that makes a read after that still free.
  • The Page Fault Handler — handle_mm_fault/do_anonymous_page, the dispatcher these branches live under.
  • Copy-on-Write and fork — how a write to a zero-page-mapped PTE (and to shared pages after fork) breaks COW and allocates a private page.
  • Anonymous vs File-Backed Memory — the zero page and lazy allocation apply to anonymous mappings; file mappings fill from the page cache instead.
  • Transparent Huge Pages — the huge zero page is the THP analogue of the small zero page.
  • Memory Overcommit and Accounting — why lazily-committed memory can be over-promised and OOM later.
  • KSM and Memory Overcommit for VMs — use_zero_pages runs this note’s mechanism backwards, collapsing already-written all-zero pages back onto the zero page.
  • Page Table Entries and Hardware Flags — where pte_special physically lives, and why vm_normal_page() has to consult it.
  • Memory Zones and Nodes / The Buddy Allocator — where the 2 MiB huge zero folio actually comes from (folio_alloc(GFP_TRANSHUGE, HPAGE_PMD_ORDER)), and why it deliberately clears __GFP_MOVABLE.
  • Per-CPU Page Lists — the layer that serves the order-0 allocation a wp_page_copy from the zero page performs.
  • The Memory Cgroup memcg — memcg charging happens at the same instant RSS does, so zero-page-backed memory is uncharged.
  • UP: Linux Memory Management MOC — §3, the page-fault handler and demand paging.
  • UP: Performance Engineering MOC — the zero page is a shared read-only cache line for the whole machine; its cost model belongs with the other contention-avoidance mechanisms there.