Virtual memory
Every pointer in your process, including every data_ptr(), is a virtual address. Hardware and the kernel translate each one to a physical location on every access. This module covers how that translation works, what it costs, and why pinned memory, mmap'd checkpoints, expandable_segments, SymmetricMemory and Grace's coherent memory are all versions of the same idea.
1Address spaces
What it is. Each process gets its own virtual address space: a private map from virtual addresses to physical memory. On x86-64 and ARM64 that map is usually 48 bits wide, giving each process 128 TiB of user-space addresses, almost all of them unmapped. The kernel keeps a list of the ranges that are valid, called VMAs (virtual memory areas): the code, heap, stacks, each mmap, each shared library. /proc/PID/maps prints that list.
How it works. Memory is managed in fixed-size pages, 4 KiB by default on x86 and on most ARM64 kernels. Physical memory is cut into page-sized frames. A per-process page table says which virtual page maps to which frame, with permission bits. Two processes can map the same frame: that's how shared libraries, shared memory and the page cache are shared without copying.
cudaPointerGetAttributes). The GPU has its own page tables and MMU. Its VA space is per CUDA context, and the driver keeps it consistent with host addresses for pinned and managed memory.2Page tables and the multi-level walk
What it is. A flat table covering 248 bytes in 4 KiB pages would need 236 entries per process. Instead, the page table is a radix tree. On x86-64 with 4 KiB pages it has 4 levels (PML4 → PDPT → PD → PT). Each level is one 4 KiB page holding 512 eight-byte entries, so each level consumes 9 bits of the virtual address, and the last 12 bits are the offset within the page. Subtrees for unmapped regions simply don't exist.
How it works. A CPU register holds the physical address of the top table: CR3 on x86, TTBR0_EL1 on ARM64. The hardware page walker slices the virtual address into indices, reads one entry per level, and at the bottom finds the PTE: the frame number plus bits such as present, writable, user, accessed, dirty and no-execute. A 4-level walk costs four dependent memory reads before the real access can start. A "huge" entry higher up (2 MiB or 1 GiB) ends the walk early.
mmap, munmap, fault, fork and pin, so page-table work shows up in allocation-heavy code and in multi-GB pin/unpin operations.cudaMalloc memory is mapped in 2 MiB pages (your 4090's cuMemCreate granularity is 2 MiB). That's one reason the CUDA caching allocator rounds large blocks to 2 MiB. On Grace and GB10 the GPU can also walk the CPU's page tables through the system MMU (ATS, §10).3The TLB and what a miss costs
What it is. The translation lookaside buffer is a small cache of recent virtual→physical translations, per core. A modern x86 core has roughly 64–96 entries in its L1 data TLB and about 2,000 in a unified second-level TLB. A hit costs essentially nothing, because it runs in parallel with the L1 cache lookup.
How it works. On a miss, the page walker reads the 4 levels. The upper levels usually hit in dedicated paging-structure caches and in L1/L2, so a typical miss costs tens of cycles. A miss whose entries also miss the caches costs up to four DRAM round trips, several hundred ns, before the data access itself. The useful metric is TLB reach = entries × page size: 2,000 × 4 KiB ≈ 8 MiB, but 2,000 × 2 MiB ≈ 4 GiB. Random access over a working set much larger than the reach misses constantly.
The TLB also has to be kept correct. When the kernel changes or removes a mapping (munmap, migration, COW break), every core that might cache it must drop the entry. On x86 that means inter-processor interrupts, called a TLB shootdown. Context switches between processes change CR3; tagged TLBs (PCID on x86, ASID on ARM) avoid a full flush.
| Event | Rough cost | When it happens |
|---|---|---|
| TLB hit | ~0 | hot working set within reach |
| TLB miss, walk hits cache | ~10–40 cycles | sequential scans, moderate working sets |
| TLB miss, walk misses to DRAM | 100s of ns | random access over many GB (embedding tables, hash maps) |
| TLB shootdown | µs, on every affected core | munmap / free of large blocks, page migration, mprotect |
cudaMalloc memory. Frequent cudaFree / cudaHostUnregister of large blocks triggers unmaps and shootdowns. One of the reasons PyTorch caches allocations is to avoid that churn.4Page faults: minor and major
What it is. A page fault is a CPU exception raised when the walk finds no valid translation, or one whose permissions forbid the access. It's normal and constant. Linux allocates memory lazily: malloc(1 GB) just reserves address space, and each page gets a real frame at its first touch.
- Minor fault: resolved without I/O. Examples: first touch of anonymous memory (allocate and zero a frame), a file page already in the page cache (just map it), a copy-on-write break. Measured on your box: ≈0.8 µs per 4 KiB first-touch fault.
- Major fault: needs I/O. The page must be read from disk (or swap) first, and the thread sleeps until it arrives. Measured on your box: ≈73 µs per random 4 KiB checkpoint page from NVMe, about 100× a minor fault.
- Invalid: no VMA covers the address, or the permissions forbid the access. The kernel delivers
SIGSEGV.
cudaHostAlloc / pin_memory() pre-fault and lock every page up front: slow to allocate, never faults afterwards. That's why PyTorch's pinned-memory allocator caches blocks. /usr/bin/time -v prints major and minor fault counts, perf stat -e page-faults,major-faults counts them, and the fault columns in ps -o min_flt,maj_flt show the running total. GPU page faults in UVM (§10) follow the same pattern, but the "handler" is the CUDA driver migrating pages over the link.5Huge pages and THP
What it is. A huge page maps 2 MiB (or 1 GiB) with one entry higher in the tree: one TLB entry instead of 512, one fault instead of 512, and one level less to walk. Linux offers two mechanisms:
- hugetlbfs: a pool reserved up front (
vm.nr_hugepages), used explicitly viaMAP_HUGETLBor a hugetlbfs mount. Guaranteed, but inflexible. - Transparent Huge Pages (THP): the kernel uses a 2 MiB page automatically when an aligned 2 MiB range of anonymous memory is faulted in, and the background thread
khugepagedlater collapses 4 KiB pages into huge ones. The mode in/sys/kernel/mm/transparent_hugepage/enabledisalways,madvise(only regions marked withmadvise(MADV_HUGEPAGE); the Ubuntu default and your box's setting) ornever.
Costs. A huge page needs 2 MiB of physically contiguous free memory. On a fragmented machine the kernel must compact memory first (the defrag setting decides whether a faulting thread stalls for it), and that shows up as latency spikes. Huge pages also waste memory when only part of each 2 MiB is used. Since kernel 6.8, multi-size THP (mTHP) can also use intermediate sizes such as 16–64 KiB for anonymous memory. Your box exposes them under /sys/kernel/mm/transparent_hugepage/hugepages-*, all disabled except 2 MiB.
ARM64 is different. ARM64 kernels can use a 4 KiB, 16 KiB or 64 KiB base page (the "granule"). With 64 KiB pages a single TLB entry already covers 16× more, and the PMD-level huge page is 512 MiB instead of 2 MiB. ARM also has a "contiguous bit" that lets 16 adjacent 4 KiB PTEs share one TLB entry.
getconf PAGESIZE: 4096 means 2 MiB THP, as on x86; 65536 means 512 MiB THP and very different fault counts.| Measured on your x86 box (1 GiB anonymous, first touch) | faults | first-touch time | random 8 B reads |
|---|---|---|---|
MADV_NOHUGEPAGE (4 KiB pages) | 262,144 | 208 ms | 9.6 ns |
MADV_HUGEPAGE (2 MiB THP) | 6,644 | 130 ms | 8.5 ns |
The faults drop 40× (not the ideal 512×, because the region's unaligned edges and any range the kernel couldn't back with a huge page fall back to 4 KiB). The random-read gain is modest here because numpy issues many independent loads in parallel, which hides walk latency. A dependent pointer chase would show a larger difference.
madvise mode, where the application opts in, is the common default.THP_MEM_ALLOC_ENABLE=1 environment variable (check that your version has it). jemalloc and tcmalloc, often LD_PRELOADed into data pipelines, have their own huge-page options. AnonHugePages in /proc/meminfo and /proc/PID/smaps_rollup shows how much memory THP is actually backing.6mmap and the page cache
What it is. mmap creates a VMA. Either it's anonymous (zero-filled memory, which is how malloc gets big blocks) or it's file-backed: the range of addresses is the file. The kernel keeps file data in the page cache, a system-wide cache of file pages in otherwise-free RAM. A file-backed mapping points your page table straight at page-cache frames.
How it works. Nothing is read at mmap() time. Touching a page faults. If the page is in the page cache, the fault is minor and the kernel just maps it. If not, it's major: the kernel reads it, usually with readahead of the following pages, and maps several neighbours at once (fault-around, 64 KiB by default). MAP_SHARED writes go back to the file. MAP_PRIVATE writes get a private copy (COW). madvise hints change the policy: MADV_SEQUENTIAL / MADV_WILLNEED read ahead aggressively, MADV_RANDOM disables readahead, MADV_DONTNEED drops pages.
| Load a 2 GiB checkpoint (your box, NVMe ext4) | cold cache | warm cache | faults (warm) |
|---|---|---|---|
read() into a new buffer | 1.33 s | 0.80 s | 524k minor |
mmap + touch every page, sequential | 0.87 s | 0.14 s | 33k minor |
mmap + MADV_RANDOM, 64k random pages | 4.80 s (65,536 major) | 0.05 s | 31k minor |
safetensors safe_open().get_tensor() | 1.47 s | 0.73 s | 104k minor |
read() pays twice: the kernel copies every byte out of the page cache, and the destination buffer takes 524k first-touch faults. A warm mmap just maps page-cache frames, 16 pages per fault thanks to fault-around. Disabling readahead turns the same file into 65,536 synchronous 4 KiB reads.
safetensors opens files via mmap. torch.load(path, mmap=True) (PyTorch ≥ 2.1) maps the checkpoint's storages instead of reading them. Combined with torch.device("meta") model init and load_state_dict(assign=True), the CPU never holds a second copy. On the Spark, page-cache pages and GPU allocations share the same 128 GB, which is why cudaMemGetInfo under-reports free memory there.7fork and copy-on-write
What it is. fork() creates a child process with a copy of the parent's address space, without copying memory. Both processes' page tables point at the same frames, and every private writable page is marked read-only in both. When either process writes to such a page, the write faults, and the kernel copies that one page for the writer and makes it writable. That's copy-on-write (COW).
Parent (trainer) page table
Child (DataLoader worker 0) page table
ob_refcnt in the object's header (and the cyclic GC writes its own headers). A DataLoader worker that merely indexes a Python list of a million samples writes to every page those objects live on, so each worker slowly gets a private copy of the dataset. Measured in the Module 3 drill on your box: each of 4 workers went from 60 MiB to 439 MiB of private memory, 1.76 GB in total, while RSS stayed flat. It isn't a leak. The memory is COW copies created by refcount writes.torch tensors (one object header, no per-item refcounts), or in a file you mmap. gc.freeze() before creating workers stops the GC from walking the objects, but not refcount writes. Python 3.12's immortal objects only cover built-ins like None and small ints. Fork also has a CUDA problem: Module 3 covers why fork() after CUDA init is unsafe.8Pinning, DMA, and the IOMMU
What it is. A device doing DMA needs addresses that stay valid for the whole transfer. Virtual memory works against that: the kernel may not have allocated a page yet (lazy), may move it (compaction for huge pages, NUMA balancing, memory tiering), may swap it out or drop it if it's a clean file page, or may replace it on a COW break. Any of these silently changes the physical frame behind a virtual address, and the device would read or write the wrong memory.
Pinning (pin_user_pages in the kernel) faults every page in and takes a reference that forbids migration, swap and reclaim until it's released. Then the driver can hand physical (bus) addresses to the device. cudaHostAlloc allocates pinned memory and also maps it into the GPU's address space. cudaHostRegister pins memory you already have. RDMA's ibv_reg_mr pins and gives the NIC a translation table (Module 4).
The IOMMU (Intel VT-d, AMD-Vi, ARM SMMU) is an MMU for devices. Devices issue I/O virtual addresses, and the IOMMU translates them through its own page tables and IOTLB. That confines each device to the memory it was granted, and it's what makes passing a GPU or NIC into a VM or container safe. Pinning is still required: IOMMU page tables are set up per mapping, and ordinary PCIe devices can't take a page fault mid-DMA. On GPU servers the IOMMU is often set to passthrough (iommu=pt) or tuned. NCCL's troubleshooting guide covers IOMMU and PCIe ACS settings, which can force peer-to-peer traffic up through the root complex.
ulimit -l (RLIMIT_MEMLOCK) limits mlock and RDMA registration for non-root processes, and it's a classic container outage (Module 4). In practice CUDA's own pinning goes through the NVIDIA kernel driver and isn't bounded by it. PyTorch's CachingHostAllocator keeps pinned blocks around because cudaHostAlloc is expensive: it faults and pins every page and updates GPU mappings. Mlocked in /proc/meminfo doesn't include CUDA-pinned memory, so look at the process's RSS instead.9GPU virtual memory: VMM APIs, expandable segments, SymmetricMemory
What it is. cudaMalloc hands you virtual addresses with physical memory already attached. The CUDA Virtual Memory Management (VMM) driver API splits that into the same steps the OS uses:
| VMM call | OS analogue | What it does |
|---|---|---|
cuMemAddressReserve | mmap(PROT_NONE) | reserve a range of GPU virtual addresses: no memory yet |
cuMemCreate | allocating frames | create a physical allocation (a handle), in multiples of the granularity (2 MiB on your 4090) |
cuMemMap / cuMemUnmap | writing PTEs | map a handle (or a slice of one) at a VA in the reserved range |
cuMemSetAccess | mprotect | grant read/write to specific devices: this is also how peers get access |
cuMemExportToShareableHandle | passing an fd over a socket | export the physical allocation as a POSIX fd (or a fabric handle across nodes) so another process can import and map it |
PYTORCH_ALLOC_CONF=expandable_segments:True (PYTORCH_CUDA_ALLOC_CONF is the legacy alias) uses the VMM path, per the PyTorch docs: "one segment (per stream) that grows as necessary". torch.distributed._symmetric_memory (alpha) gives each rank a handle with "pointers to symmetric buffer on peer ranks, multicast pointer (if supported), and signal pads". NCCL's own cuMem mode, NVLS and user-buffer registration (ncclMemAlloc) build on the same calls. Your 4090 reports MULTICAST_SUPPORTED = 0: multicast needs NVSwitch.10UVM, HMM and ATS: one pointer for CPU and GPU
What it is. Three ways to let a GPU kernel dereference "host" pointers. Each one is a bigger step towards the GPU sharing the CPU's page tables:
| Mechanism | Which memory | How a GPU access is resolved | Where you have it |
|---|---|---|---|
UVM / managed memorycudaMallocManaged | only managed allocations | GPU page fault → the CUDA driver migrates the page (in chunks up to ~2 MiB) between host and device and updates both page tables. Hints: cudaMemPrefetchAsync, cudaMemAdvise | both machines |
| HMM (Heterogeneous Memory Management) | any malloc / mmap memory | a Linux kernel feature: the driver mirrors CPU page tables into the GPU's in software (and migrates pages), staying in sync via MMU notifiers. Needs CUDA 12.2+, open kernel modules, Linux 6.1.24+ / 6.2.11+ | x86 + 4090 if using the open modules (your 4090 reports PAGEABLE_MEMORY_ACCESS = 0 today) |
| ATS (Address Translation Services) | any system memory | hardware coherence over NVLink-C2C: the GPU's MMU asks the system MMU, which walks the CPU's own page tables. HMM is disabled when ATS is present | Grace Hopper, Grace Blackwell, and GB10 (Hot Chips lists ATS via GMMU + SMMU) |
CUDA reports which one applies through device attributes. The drill prints them all. pageableMemoryAccess = 1 means kernels may use pageable memory directly (HMM or ATS). pageableMemoryAccessUsesHostPageTables = 1 means it's hardware (ATS), and 0 means software (HMM).
On the Spark specifically. There's no separate GPU memory. malloc, cudaMalloc, the page cache and pinned buffers are all LPDDR5x, and the GPU's L2 caches system physical addresses coherently. With ATS a kernel can read a tensor that came from np.load or an mmap'd checkpoint in place, with no copy and no migration. Whether that beats copying first depends on access pattern and TLB behaviour, so measure it.
PAGEABLE_MEMORY_ACCESS = 1 and USES_HOST_PAGE_TABLES = 1 on the Spark (ATS per the GB10 Hot Chips deck), and have not confirmed it on hardware. How a GPU access to a never-touched malloc page gets serviced on GB10, and at what cost, is also not documented in what I found. Run python3 drills/m2_vm.py info on the Spark.cudaMalloc or the VMM path. PyTorch can't pass a raw malloc pointer into a CUDA kernel today without a copy, but custom kernels, Triton and CuPy can on ATS/HMM systems. Watch nvidia-smi -q for the addressing mode (HMM / ATS / none), and treat cudaMemGetInfo on the Spark as a lower bound.DDrill: faults, THP, and page size on both machines
# everything at once (creates a 2 GiB checkpoint in $HOME unless you pass a path)
bash drills/m2_vm.sh [CKPT_PATH] [GB]
# or piece by piece
python3 drills/m2_vm.py info # page size, THP, CUDA unified-memory attributes
python3 drills/m2_vm.py make ~/osf_ckpt.safetensors 2
python3 drills/m2_vm.py evict ~/osf_ckpt.safetensors # cold (no root needed)
/usr/bin/time -v python3 drills/m2_vm.py load ~/osf_ckpt.safetensors mmap
/usr/bin/time -v python3 drills/m2_vm.py load ~/osf_ckpt.safetensors mmap # warm
python3 drills/m2_vm.py load ~/osf_ckpt.safetensors mmap-rand # readahead off
python3 drills/m2_vm.py thp 1 # MADV_NOHUGEPAGE vs MADV_HUGEPAGE
# whole-system cold cache instead of one file
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
Expected output
x86 + RTX 4090 measured on your box
base page size: 4096 bytes
THP enabled: always [madvise] never
THP PMD size: 2 MiB
PAGEABLE_MEMORY_ACCESS 0
..._USES_HOST_PAGE_TABLES 0
CONCURRENT_MANAGED_ACCESS 1
VIRTUAL_MEMORY_MANAGEMENT 1
MULTICAST_SUPPORTED 0
cuMemCreate granularity: 2 MiB
read cold 1.33s warm 0.80s 524k minor
mmap cold 0.87s warm 0.14s 33k minor
mmap-rand cold 4.8s: 65536 major faults
NOHUGEPAGE 262144 faults 208 ms
HUGEPAGE 6644 faults 130 msDGX Spark expected, not yet measured
arch aarch64, page size 4096 or 65536 (check!)
4 KiB -> THP PMD 2 MiB, like x86
64 KiB -> THP PMD 512 MiB, and ~16x
fewer faults for the same file
INTEGRATED 1
PAGEABLE_MEMORY_ACCESS 1 (expected)
..._USES_HOST_PAGE_TABLES 1 (expected: ATS)
HOST_NATIVE_ATOMIC_SUPPORTED 1 (expected)
GPU_DIRECT_RDMA_SUPPORTED 0 (per NVIDIA docs)
mmap warm: faster than x86 per GB if the
page size is 64 KiB (fewer faults)
What to notice: (1) warm vs cold is the page cache at work; (2) read's fault count equals the file's page count because it's faulting in a private copy, while mmap shares the cache; (3) random access with readahead off turns every page into a major fault; (4) THP divides first-touch faults by up to 512.
?Self-check
Walk through, step by step, what happens when a thread first writes to a page of a 1 GB buffer it just got from malloc. criterion #2
What does a TLB miss cost, and how does it differ from a page fault? criterion #2
Your colleague says "mmap'ing the checkpoint is pointless, it still has to read the file". What does mmap actually buy you? criterion #5
read() copies every byte from page cache into a private buffer (and first-touch faults that buffer). mmap maps the page-cache frames directly, so a warm load was 0.14 s vs 0.80 s on your box. (2) Laziness: only touched pages are read, so loading a subset of tensors or a single shard costs only that. (3) Sharing: N processes on a node share one copy in the page cache, and it survives process restarts. (4) The kernel handles readahead and eviction. It doesn't make cold I/O faster, and a cold page touched from a hot thread is a major fault that stalls it.Why must memory be pinned before a GPU copy engine or NIC can DMA from it, even with an IOMMU? criterion #1 / #6
Four DataLoader workers each show RSS 800 MB, but the node's free memory drops by 1.7 GB over the first epoch. Explain with PSS/USS. criterion #4
What problem does expandable_segments solve, and which CUDA APIs make it possible?
cuMemAddressReserve) and maps more physical chunks at its end as needed (cuMemCreate + cuMemMap), so one segment per stream grows and shrinks instead.On a GH200 or a Spark, a CUDA kernel reads a NumPy array allocated with plain malloc. What makes that legal, and why is it a bad idea on your 4090 box?
pageableMemoryAccessUsesHostPageTables = 1). On the 4090 box, PAGEABLE_MEMORY_ACCESS = 0, so it would just fault. Even with HMM enabled (open modules), each GPU touch of a host page means a software-handled fault, mirroring or migration over PCIe at ≈20 GB/s with high per-page latency. For hot data, copy it to the GPU explicitly.§Sources
- Linux kernel docs: Transparent Hugepage Support (modes, defrag, khugepaged, multi-size THP), HugeTLB pages, x86-64 memory map (4- and 5-level paging), ARM64 memory layout (4K/16K/64K granules).
- Linux kernel docs: pin_user_pages() and related calls (why DMA needs pinned pages, FOLL_LONGTERM).
- LWN: The maple tree (VMA tree, 6.1); Per-VMA locks (6.4).
- NVIDIA, CUDA Programming Guide: Unified and System Memory:
pageableMemoryAccess,pageableMemoryAccessUsesHostPageTables, ATS on Grace Hopper/Blackwell disables HMM. - NVIDIA, CUDA Driver API: Virtual Memory Management and Multicast Object Management.
- NVIDIA blog / Phoronix: CUDA 12.2 HMM support (open kernel modules, Linux 6.1.24+ / 6.2.11+).
- GB10 at Hot Chips 2025: ATS through GMMU + SMMU, hardware CPU–GPU coherency, physically-tagged GPU L2.
- NVIDIA, DGX Spark optimization (UMA,
cudaMemGetInfo) and porting: CUDA (no GPUDirect RDMA). - PyTorch, CUDA semantics:
expandable_segments,PYTORCH_ALLOC_CONF, pinned memory; SymmetricMemory (alpha); torch.load(mmap=True). - PyTorch dev-discuss: SymmetricMemory: harnessing NVLink programmability.
- NCCL troubleshooting: IOMMU / PCI ACS and P2P.
- Your measurements:
drills/m2_vm.pyon i9-13900K + RTX 4090, kernel 6.8, driver 580.178;drills/m3_cow_leak.pyfor the COW numbers.