Memory Systems · Profiling Memory

Splitting Footprint by Mapping Type: Anonymous, File-Backed, and Special

How a process address space divides into anonymous, file-backed, and special mappings, what each contributes to footprint, and how to read the permission and label fields that identify them.

A single resident-size number cannot be acted on. The same 800 MiB of RSS can mean a program with a large working set, a program that read a large file, a program whose allocator is holding a peak, or a program with a leak. The address space breakdown distinguishes them, and the distinction is available from the kernel at the granularity of individual mappings.

What a mapping record contains

The per-process mapping table lists every virtual memory area the process has. Each record carries the address range, the permission bits, an offset, a device, an inode, and a pathname when there is one. The permissions are read, write, execute, and shared-or-private. The pathname is empty for anonymous memory, [heap] and [stack] for the two conventional regions, and a file path or [vdso]-style bracket label for the rest.

The permission bits are the first filter, because they identify the mapping’s role:

  • r-xp on a file is executable text, shared with every other process running the same binary, and reclaimable at any time since the file is the backing store.
  • rw-p on a file is a private writable mapping, which starts as page cache and becomes private anonymous memory on the first write through copy-on-write.
  • rw-s on a file is a shared writable mapping, which stays page cache: writes are visible to other mappers and to reads of the file, and the pages are reclaimable by writing back.
  • rw-p with an empty pathname or a [heap]/[stack]-style label is anonymous memory: the heap, thread stacks, and anything obtained from mmap with the anonymous flag.

The pathname is not always present or informative. A mapping created by memfd_create or by an anonymous shared mapping has no filesystem path, and appears with a label the kernel synthesises. A deleted file still mapped shows its old path with a deletion marker appended, which matters because the pages are still backed by the inode until the last mapping goes away: deleting a file that is mapped does not free its pages.

The four contributions to resident size

Classifying each mapping by backing store and writability yields four groups whose behaviour under pressure differs completely.

Private anonymous. The heap, thread stacks, and explicit anonymous mappings. Nothing backs these pages, so they can only be reclaimed by writing them to swap or by discarding them. They are the memory a program is genuinely holding, and the group that a leak grows. Within it, the allocator’s break and the thread stacks are distinguishable by label, and everything else is a mapping the program created deliberately.

Shared anonymous. Anonymous mappings created with the shared flag, typically used for inter-process communication and inherited across fork. They are reclaimable only through swap, and they are shared, so RSS charges them to every process that maps them while PSS divides them. A footprint investigation that sees a large shared anonymous region should look for a parent process that created it, because the creating process is often not the one being profiled.

File-backed, clean. Text, read-only data, and file mappings that have not been written. These pages are page cache and are reclaimable without any write-back. Their presence in RSS means the process has read them recently, not that it needs them. Eviction costs a future page fault and a re-read, nothing more.

File-backed, dirty. Pages written through a shared file mapping, or private pages copied from a file mapping on write. The private copies have become anonymous in behaviour even though the mapping still has a pathname, which is why the Anonymous counter in the per-mapping summary is the field to trust rather than the pathname. A private writable file mapping with a large anonymous count is anonymous memory that happens to have a file name attached.

The per-mapping summary reports each of these separately: resident total, shared clean and dirty, private clean and dirty, and the anonymous and swap portions. Summing by category produces an attribution that a single RSS number cannot give.

Reading the summary in aggregate

The mapping table is verbose; the per-mapping summary adds a dozen counters per area, and a process with thousands of mappings produces output measured in megabytes. The aggregation that matters is by category, and the categories are derivable from the permission and pathname fields without consulting the counters at all, which is useful when the counters are unavailable or too slow to collect.

# Aggregate mapped address space by mapping category from the mapping table.
import collections, re, sys

CATS = collections.Counter()
pat = re.compile(r"^([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)$")

pid = sys.argv[1] if len(sys.argv) > 1 else "self"
for line in open(f"/proc/{pid}/maps"):
    m = pat.match(line.rstrip("\n"))
    if not m:
        continue
    start, end, perms, path = int(m[1], 16), int(m[2], 16), m[3], m[4]
    size_kib = (end - start) // 1024
    special = path.startswith("[") and path.endswith("]")
    if not path or special:                  # [heap], [stack], [vvar] are anonymous
        cat = "anon-shared" if perms[3] == "s" else "anon-private"
    elif perms[3] == "s":
        cat = "file-shared"
    else:
        cat = "file-private"
    CATS[cat] += size_kib

for cat, kib in CATS.most_common():
    print(f"{cat:14s} {kib / 1024:8.1f} MiB of address space")

The script measures address space, not residency: it reports what is mapped, and a mapping is not resident until it is touched. Adding the resident counters from the per-mapping summary turns the same grouping into a footprint breakdown, and the ratio between the two columns is itself informative. A large mapping with no resident pages is a reservation that has not been used, while a small mapping fully resident is memory in active use.

The distinction between address space and footprint is the one that makes [heap] misleading. The heap’s mapping size is the allocator’s break, which reflects the high-water mark of the program’s allocation, not its current live set. A program that allocated a gigabyte, freed it, and kept the break in place shows a gigabyte of heap address space and whatever fraction of it is still resident. Neither number is the live set.

Special mappings and what they hide

Several mappings are neither anonymous nor ordinary files, and each has a characteristic footprint behaviour.

Thread stacks. Each thread gets a stack mapping, sized by the thread’s requested stack size or the process default, with a guard page at the low end. The mapping is reserved in full but only the pages actually used are resident. A process with many threads therefore shows a large stack total in the address space column and a much smaller resident figure, and the resident figure grows with call depth rather than with the number of threads.

The kernel’s own page in the process. The vDSO is a small executable mapping the kernel provides so that certain syscalls can be performed without a trap. It is a few pages, shared by every process, and irrelevant to footprint except as a reminder that not every executable mapping is a program’s own code.

Huge-page-backed regions. With transparent huge pages enabled, anonymous regions can be backed by 2 MiB pages. The mapping’s address range is unchanged, but residency is charged in 2 MiB units, and the per-mapping summary’s counters reflect the huge pages. A single touch on a huge-page-eligible region can make 2 MiB resident. This is a real footprint increase caused by a fault pattern, and it is the most common reason a footprint measurement disagrees with an allocation count by a factor far larger than the per-object overhead.

Guard and reserved regions. A mapping with no permissions, or a reserved range created by mprotect or by an address-space reservation, contributes nothing to residency but inflates the address-space total. A program that reserves a large arena and uses a fraction of it shows this shape.

Attributing growth between two snapshots

The practical use of the breakdown is comparison. Two snapshots of the same process, classified by category, show which category grew. The interpretation differs by category in a way that narrows the search immediately:

Growth in private anonymous memory is the program retaining something. It is the category a leak investigation starts from, and the only one where an allocation trace can explain the whole increase.

Growth in file-backed clean memory is page cache accumulated by reading files. It is reclaimable and usually benign, though it can push a process over a container limit if the limit counts page cache against the container.

Growth in file-backed private memory is copy-on-write from a file mapping: the program is writing to pages it mapped from a file. A database or an index that maps its data file and writes through it accumulates this, and the pages are anonymous in cost even though they have a pathname.

Growth in address space without growth in residency is reservation. It costs page tables, which are not in RSS but are charged to the process by the kernel’s own accounting, and it can exhaust a virtual address limit without consuming physical memory.

The reconciliation of these categories against the counters from RSS and PSS is the step that turns a category breakdown into a claim about physical memory. RSS sums all categories; PSS divides the shared ones; USS keeps only the private ones. Computing all three per category is what makes a statement like “the increase is private anonymous and fully resident, so it is retention” supportable rather than speculative.

References

  1. Linux man-pages: proc(5), the /proc/pid/maps file — Linux man-pages project
  2. Linux man-pages: mmap(2) — Linux man-pages project
  3. Linux kernel documentation: Transparent Hugepage Support — Linux kernel project