Shows where your RAM actually went, grouped by application, without root.
Your other tools are probably lying to you about a large number, and they are not telling you which one. Two examples measured on the machine this was written on, both reproducible in a terminal right now:
free -hreports 33.96 GiB ofbuff/cache. The kernel can actually hand back 10.30 GiB. The difference is file-backed memory mapped into running processes: evicting it means paging out a live virtual machine, not reclaiming a cache.memgroupsprintsdroppable, and means it.- Seven VirtualBox guests with loaded operating systems, 4 GiB configured each.
ps,htopandps_memsee 0.78 GiB between them, because a hypervisor maps guest RAM as file pages andRssAnoncannot see it.memgroupssees 20.45 GiB, one row per guest.
The ordinary case matters too, and it is the reason the tool exists at all: ps and htop cannot
answer "where did my RAM go" on a real workstation. Chrome is 180 processes, a docker-compose
stack is 31 containers, the Docker engine is 146 docker-proxy helpers - and not a single line
anywhere shows you an application as a whole.
Read-only, no root, no daemon, no dependencies. One file of stdlib Python, 0.27 s on a machine with 919 processes.
Tested against six machines and five desktop stacks - GNOME on Wayland, KDE on Wayland (Arch and Ubuntu), Cinnamon on X11, Xfce on X11, plus a headless VM guest. On five of the six, the
Unclassifiedrow is one process:memgroupsitself. The sixth runs a desktop environment its author wrote, which is what the config file is for.
# GROUP SIZE % RAM SWAP ZSWAP PROCS
--------------------------------------------------------------------------------------------------------
1 Google Chrome 18.50 GiB 14.7% 8.25 GiB ~2.96 GiB 217 ██···········
2 Other groups (15) 9.31 GiB 7.4% 3.04 GiB ~554.03 MiB 380 █············
3 zswap (compressed pool) 7.23 GiB 5.8% - 393.32 MiB - █············
4 Claude Code CLI 5.03 GiB 4.0% 2.07 GiB ~650.96 MiB 33 █············
5 VM: dev-arch 4.16 GiB 3.3% 60.40 MiB ~22.51 MiB 1 ·············
6 VM: ubuntu-ci 3.87 GiB 3.1% - - 1 ·············
7 Desktop / X 3.69 GiB 2.9% 1.37 GiB ~365.04 MiB 83 ·············
8 Kernel (slab, page tables, stacks) 2.95 GiB 2.4% - - - ·············
9 VM: ubuntu-lts 2.77 GiB 2.2% - - 1 ·············
10 VM: QA: mate 2.19 GiB 1.7% - - 1 ·············
11 VM: suse-kde 2.18 GiB 1.7% - - 1 ·············
12 Windsurf 1.66 GiB 1.3% 1.23 GiB ~357.91 MiB 37 ·············
13 Messengers 1.60 GiB 1.3% 811.94 MiB ~225.98 MiB 31 ·············
14 Devin Desktop 1.46 GiB 1.2% 1.64 GiB ~458.63 MiB 59 ·············
15 tmpfs / shm 1.22 GiB 1.0% ~6.13 GiB - - ·············
16 Tools: npx / uv / pipx 98.23 MiB 0.1% 4.47 GiB ~1.31 GiB 66 ·············
--------------------------------------------------------------------------------------------------------
TOTAL 67.91 GiB 54.1% ~29.05 GiB 7.23 GiB 911
RAM 125.55 GiB | used 72.37 GiB | free 35.43 GiB | droppable 0 KiB
anon 35.62 GiB | swap 26.92 GiB/32.00 GiB | zswap 7.23 GiB holds 22.05 GiB
unaccounted 23.61 GiB (18.8% of RAM) | metric: data
SWAP 29.05 GiB = VmSwap 22.92 GiB + ~6.13 GiB tmpfs, which VmSwap cannot see.
vs meminfo 26.92 GiB: shared pages count per holder, ownerless swap uncounted.
--processes GROUP states one group's whole cost in a single sentence - SIZE, how much of it is
swapped out, how much RAM the zswap pool spends on it - so the three columns do not have to be
assembled by hand:
$ memgroups --processes Chrome
Google Chrome: 17.35 GiB + 6.76 GiB swapped out, ~2.54 GiB of zswap RAM, 206 processes
Columns:
| Column | Meaning |
|---|---|
SIZE |
how much memory the group holds |
FILE |
file-backed resident memory per group (summed RssFile); shown only with --file. Double counts memory mapped by more than one process - see "The FILE column and --file" below |
% RAM |
share of total RAM (with --scope processes the column is % TOTAL - share of the process sum) |
SWAP |
how much of the group is swapped out (sum of VmSwap); shown only when swap is in use anywhere on the machine |
ZSWAP |
this group's share of the compressed zswap pool - RAM the kernel spends because the application exists; never part of SIZE. Shown only when zswap is in use. A leading ~ marks a proportional estimate (see below) |
PROCESSES |
how many processes were folded into the row; a dash means the row comes from /proc/meminfo, not from processes |
Only consumers are listed, so TOTAL tells you how much is taken overall. Free memory and disk
cache never appear as rows - see the reconciliation line at the bottom for those.
Swap is memory the process does not have resident, so it is deliberately kept out of SIZE
and never folded into it: mixing the two would make % RAM lie and could push TOTAL past
MemTotal. It only ever gets its own column, SWAP, summed from VmSwap in /proc/PID/status -
the same file the SIZE numbers come from, readable without root for every process. A large SWAP
figure is a warning sign, not a footnote: it means the app's real footprint is bigger than its
SIZE row suggests. Above, Google Chrome reads as "17.45 GiB resident + 7.08 GiB swapped out" -
roughly 40% more memory again, invisible to ps and htop. The column disappears entirely on a
machine with no swap used; an all-zero column would only be noise.
Which groups survive --top N is decided by SIZE + SWAP, not SIZE alone. An application whose
pages were swapped out does not vanish from the cut just because the kernel evicted it - that is
the exact failure this column exists to prevent. --top always ranks this way, whatever --sort
below is set to: the cut happens first, the sort happens after, on whatever survived it.
--sort size|swap|zswap|name (default size) changes the display order and nothing else - not
SIZE, not % RAM, not which rows survive --top, and not the bars, which stay tied to each row's
share of RAM regardless of how the table is sorted. It exists to bring an anomaly to the top instead
of hunting for it in a SIZE-sorted list. On the machine above, Tools: npx / uv / pipx holds
4.47 GiB of swap against only 107.36 MiB resident - about 42 times its own SIZE, the most
swapped-out group on the machine relative to its footprint - and under the default sort it renders
at row 15, near the bottom, well below Docker: mcp/grafana at row 14 with a mere 236.32 MiB
resident and 47.46 MiB of swap. --sort swap on the same run puts it at row 3, right after
Google Chrome and tmpfs / shm. --sort is ignored by --processes, which lists a single
group's own processes and has nothing to reorder by group.
The tmpfs / shm row's SWAP cell is the odd one out, and worth reading carefully. tmpfs is
swap-backed, but a tmpfs page belongs to the filesystem, not to any process, so per-process
VmSwap excludes it by definition - there is simply no PID to charge it to, no matter how the
kernel accounts memory. The only way to see it at all is total tmpfs occupancy minus the part
still resident: occupancy comes from statvfs() on every tmpfs mount found in /proc/mounts
(the same definition df uses, unprivileged), the resident part is Shmem from
/proc/meminfo, and the difference is what got swapped out. Above, the row reads as "1.25 GiB
resident + ~5.63 GiB swapped out" - well over four times its resident size, and completely
invisible before this subtraction existed.
That figure carries the same leading ~ the ZSWAP column uses, for a related but distinct
reason: it is a LOWER BOUND, not an exact figure. Shmem also covers shared anonymous memory
(Chrome uses a lot of it) and SysV/memfd shm, and neither lives in any tmpfs mount - so the
subtraction can only ever come out too low, never too high. Read a ~ here as "at least this
much tmpfs content is swapped out", not as a measurement. The estimate degrades silently to
nothing (cell reads -, no ~) when /proc/mounts cannot be read, a mount cannot be
statvfs'd (permission, or it vanished mid-scan), or the machine simply has no tmpfs mounts -
never a crash, never a guess. Because it folds into the same SWAP column, it also counts toward
the --top N ranking described above, and TOTAL's SWAP cell carries the ~ too whenever any
row - tmpfs included - is an estimate.
zswap (compressed pool) is a related but different number and gets its own consumer row, next to
tmpfs / shm and Kernel: it is the RAM the compressed swap pool occupies right now, so unlike
VmSwap it genuinely belongs in SIZE-space. The two are not double-counted - one is memory a
process does not have, the other is memory the kernel spends to keep the first one small - but they
do meet in the reconciliation line (zswap X holds Y): X is resident RAM, Y is how much
uncompressed swap that RAM currently represents.
The ZSWAP column decomposes that row by application: it is the share of the compressed pool
attributable to each group - RAM the kernel spends compressing that application's pages, purely
because the application exists. It is not the application's own memory, so it never gets folded
into SIZE, the same way SWAP does not. Column and row are kept in lockstep on purpose: sum(ZSWAP)
over every row equals the zswap (compressed pool) row's SIZE, so nothing is double-counted, only
broken down. On the machine above the column sums to 7.75 GiB, exactly the row above it - both
numbers sit on the same screen, so nobody has to take it on faith.
The kernel exports no per-process zswap figure, only a per-cgroup one, in memory.stat. Where a
cgroup maps onto exactly one group - a container, a systemd app-*.scope - the number is read
straight off memory.stat and shown as-is, no marker: Docker compose: mcp above is exact at
212.86 MiB, and so is Docker engine at 15.23 MiB. Where a cgroup holds several applications at
once, which is the normal case for the desktop session scope (Chrome, IDEs and messengers all
running inside the same session-N.scope), there is no per-application number to read, so that
cgroup's own zswap is split among its groups in proportion to how much each one has swapped out
(VmSwap) inside that same cgroup. Every group touched by such a split is marked with a leading
~: above, Google Chrome reads ~2.64 GiB and Windsurf reads ~321.30 MiB. Say plainly what
that means: it is a proportional estimate, not a measurement, and it can be off in either direction,
because compression ratios differ by content - Chrome's swapped-out V8 heap and a compiler's
swapped-out object files do not compress the same way, and the split has no way to know that. Read
a ~ figure as a good approximation of that group's share, not as an exact accounting.
Some zswap belongs to no group at all - a cgroup with no live process behind it, or shmem - and that
part cannot be charged anywhere. It stays out of every group's cell and is instead carried on the
zswap (compressed pool) row's own ZSWAP cell, so the column still sums to the row. On the machine
above that is 1.34 GiB, visible in that row's own ZSWAP cell in the table.
This makes the word "swap" genuinely slippery on a zswap machine, so read the two together: a page
compressed into the pool still holds a swap entry, so VmSwap counts it, yet it never left RAM. On
the machine above 20.20 GiB of the 24.45 GiB of "swap" is in the pool, compressed into 7.71 GiB;
only about 4.25 GiB actually reached the disk. A big SWAP column there means "evicted from the
working set", not necessarily "on a slow device".
TOTAL's SWAP cell and the swap X/Y figure two lines below it, in the reconciliation block,
measure related but different things and will not match to the byte - on one screen, right next to
each other, which is exactly why the tool now spells out the gap explicitly. On the machine above the
process rows sum to 20.86 GiB of VmSwap, the tmpfs estimate adds ~5.63 GiB, and the column totals
~26.49 GiB against 24.45 GiB actually swapped out per /proc/meminfo - 2.04 GiB higher, not lower.
Two real mechanisms drive that gap and they pull in opposite directions, so they do not simply cancel:
VmSwapover-counts. A swapped page shared by a fork family - a browser's renderer processes, a language runtime's forked workers - is charged in full to every process that maps it, not split between them. And a page compressed into the zswap pool still holds a swap entry: it never reached a disk, butVmSwapcounts it exactly as if it had (zswap held 20.20 GiB of the 24.45 GiB above). Both push the column up.- Ownerless swap is missing. Swap belonging to a shared mapping no live process currently holds, or to a process that has already exited, is attributed to no PID, so it cannot appear in any row. This pushes the column down.
Which effect dominates is a property of the machine and the moment, not a constant to correct for -
which is why the on-screen footer states both directions in one sentence (vs meminfo swap: a shared page counts per holder, other ownerless swap never.) rather than printing a signed delta that would
flip sign on the next run. The tmpfs estimate sits apart from that tug-of-war: it is a genuine floor
on top of both effects, read straight from tmpfs occupancy rather than from any process, which is why
the line above names it separately (+ ~5.64 GiB tmpfs, which VmSwap cannot see) instead of
folding it into the same over/under story. Under --scope processes the tmpfs row does not exist to
hold that estimate, so the same number is reported as "in no row" instead of added to a column total -
the estimate itself does not change, only where it has anywhere to live.
No root required. The tool only reads /proc and optionally calls docker ps. It never kills or
changes anything.
No runtime dependencies, stdlib only, and that is a design constraint rather than an accident.
Python 3.11+ enables the config file; on 3.10 the tool runs on its defaults and says so
on stderr instead of failing. docker is optional and only used to name container groups.
Arch Linux, from the AUR:
paru -S memgroups # or: yay -S memgroupsAnywhere else, from a checkout. pip install . gives you a memgroups command with no
.py on it; running the file directly works too and needs no install step at all:
git clone https://github.com/Softer/memgroups.git
cd memgroups
pip install --user . # installs the `memgroups` command
python3 memgroups.py # or just run it in placeThe version is taken from the git tag at build time, so memgroups --version and the package
you installed can never disagree. Run straight out of a checkout there is no package metadata to
read, and it says that rather than inventing a number.
memgroups # consumers, percentages of total RAM
memgroups --top 0 # every group, nothing collapsed
memgroups --sort swap # reorder the table by SWAP, biggest first
memgroups --scope processes # processes only, 100% = their sum
memgroups --processes Chrome # list the processes of one group
memgroups --file # add a FILE column (file-backed RSS); double counts, see below
memgroups --json # machine-readable output
memgroups --metric pss # PSS instead of anonymous memory
memgroups --init-config # create ~/.config/memgroups.toml
watch -n5 memgroups # live view| Flag | Default | Meaning |
|---|---|---|
--metric data|anon|rss|pss |
data |
which memory number to sum |
--scope ram|processes |
ram |
denominator for the percentages, and whether tmpfs/kernel rows are included |
--top N |
15 |
how many groups to show, 0 for all |
--sort size|swap|zswap|name |
size |
display order of the table and of --json's groups array; never changes SIZE, % RAM, or which rows survive --top; ignored by --processes |
--processes GROUP |
list the processes of a single group | |
--file |
off | add a FILE column: file-backed RSS per group; double counts memory shared between processes |
--json |
dump groups and /proc/meminfo as JSON |
|
--no-docker |
do not call docker ps |
|
--config PATH |
use a specific config file | |
--init-config |
write the example config and exit | |
--version |
print the version and exit |
-
User rules from the config. Checked first - the only way to pull a container out of its automatic Docker group and name it yourself.
-
Container membership. Anything running inside a container goes to a Docker group, whatever it happens to run:
- containers of one docker-compose project are merged into
Docker compose: <project>using thecom.docker.compose.projectlabel; - a container started by plain
docker runbecomesDocker: <image>.
The data comes from a single
docker pscall. Without docker (--no-docker, no permission) the label falls back to the short container id. A host-sidedocker run <image>client is put into the same group as the container it started, so the two never drift apart.Grouping by project matters more than it sounds: a stack of 31 containers otherwise shows up as 31 rows of 40 MiB each, and its real 2 GiB never surfaces in any top-N.
- containers of one docker-compose project are merged into
-
Named virtual machines, the same idea one level up. A hypervisor process running one guest becomes
VM: <name>, so eight running machines are eight rows and not one lump:--comment <name> --startvmfor VirtualBox and VBoxHeadless,-name guest=<name>for QEMU both plain and under libvirt. The name is free text its owner chose, so it is read to the next option and not to the next space -QA: mateis a name with both in it - and it is cut at 48 characters, because it is the process's own command line and not ours to trust with the table's width.The name patterns are only ever applied to a process whose
argv[0]is a hypervisor.-nameis an ordinary option, and without that gatefind /home/u/obs -name *.mkvbecame a virtual machine called*.mkv.Daemons that serve every guest and name none -
VBoxSVC,VBoxXPCOMIPCD,libvirtdand itsdnsmasq- stay in the plainVirtualization (KVM/VirtualBox)row, exactly asDocker enginestays separate from the containers. -
Built-in rules - regexes against the command line and
/proc/PID/comm. First match wins. They only ever see host processes; containerised ones never reach them. -
Inheritance from the nearest ancestor. This is the part without which nothing works: Chrome and Electron helpers carry no application name in their command line - they show up as
/proc/self/exe --type=utilityorelectron --type=zygote. Such a process takes the group of the closest ancestor that did match a rule. The walk stops at PID 1 and at container boundaries, and cycles inPPidare guarded against. -
Everything else is
Unclassified.
dockerd, containerd-shim and docker-proxy run on the host rather than inside containers, so
they land in Docker engine - that is the engine, not a workload.
~/.config/memgroups.toml, or $XDG_CONFIG_HOME/memgroups.toml. Write a commented example with
memgroups --init-config; the same file lives in config.example.toml.
[options]
metric = "data"
scope = "ram"
top = 15
sort = "size"
[[group]]
name = "My project"
match = ["/home/me/work/shop"]User rules are checked before everything else - before the built-in rules and before the
automatic Docker grouping. after_defaults = true moves a rule behind the built-ins, but such a
rule can no longer override container membership.
Besides match (command line) there is match_comm (/proc/PID/comm) and match_container,
which tests the image name, the container name and the compose project - matching any of the
three is enough:
[[group]]
name = "My SonarQube"
match_container = ["^mcp/sonarqube"] # by image
[[group]]
name = "MCP stack"
match_container = ["^mcp$"] # by compose project| Metric | What it is | When to use |
|---|---|---|
data (default) |
RssAnon plus resident file pages that are not program text, i.e. max(0, RssFile - VmExe - VmLib) |
almost always. The file half is where a hypervisor's guest RAM, an mmap'd database and a memfd live |
anon |
RssAnon alone - what data was before the file half |
when you want the strict anonymous-only number, or on a /proc with no VmExe/VmLib, where data falls back to it anyway |
rss |
full resident set | overstates, counts a shared page once per process holding it |
pss |
proportional set size | fairer for fork families, but without root less than half the processes are readable - the tool warns. The SWAP column switches to SwapPss to match, so both columns stay on one accounting model (full-charge VmSwap runs ~25% higher on a fork-heavy machine) |
The sum of RssAnon over processes runs above AnonPages from /proc/meminfo - typically by one
to two gigabytes on a machine with heavy fork users - because COW pages shared after fork are
counted in every process that maps them. That does not matter when comparing groups, but the
absolute total is somewhat high. Cross-check against the anon field in the reconciliation line.
--scope ram works with data and anon. rss and pss cannot be reconciled with
/proc/meminfo, so the tool falls back to --scope processes and says so.
RssAnon alone cannot see a virtual machine. VirtualBox, and QEMU with a file or hugepage
backing, map the guest's RAM as file pages: one guest with 4096 MiB configured reports
RssAnon 109 MiB and RssFile 4033 MiB. On the machine this was written against, seven running
guests with loaded operating systems read as 0.78 GiB instead of 20.45 GiB, and the row
sat eleventh in the table.
VmExe and VmLib are the virtual sizes of a process's text and library mappings. Resident
text can never exceed them, so subtracting them from RssFile always takes away at least as much
as the real text is - which makes data a lower bound and never an overstatement. It needs
nothing but /proc/PID/status, and that matters: smaps is unreadable even by the owner for
VirtualBoxVM, which is not dumpable, so any approach that asks a mapping whether it is private
is unavailable for exactly the processes that motivate this.
Measured over 904 processes on that machine: sum(RssFile) is 51.08 GiB, sum(data_file) is
20.04 GiB against Mapped 20.66 GiB from /proc/meminfo, and the virtual machines hold 19.66 of
those 20.04. No other process on the box exceeds 0.17 GiB. The subtraction is what separates a
guest's RAM from the shared Qt and glibc pages that every desktop process maps.
On a /proc with no VmExe/VmLib the tool falls back to anon, because without them data
would silently become "anon plus every shared library counted once per process holding it". It
only prints a warning if the metric was asked for by name rather than defaulted into.
Page cache, buffers and reclaimable slab (the dentry/inode cache) are handed back by the kernel
under pressure. They are free memory, not consumption, so they are not listed. The number is not
lost - it is in the reconciliation line as cache.
Not all of that cache is equally free, though. Some of it - Mapped in /proc/meminfo - is
page-cache pages currently sitting in some process's page tables, in active use right now, not
merely held in case it is wanted again. The reconciliation line pulls that out of reclaimable
into its own figure, mapped: mapped = max(0, Mapped - Shmem). Shmem is subtracted because
shmem pages are themselves page cache and already sit inside Mapped, and Shmem already has its
own row and its own subtraction (the tmpfs / shm row below) - counting it again here would double
count it. Assuming all of Shmem is mapped is the safe direction: the result is a LOWER BOUND on
the true mapped figure, short of the truth by at most Shmem itself, never an overstatement.
mapped is not a table row and never enters any group's SIZE - there is no single process to
charge it to - it only shows up in the reconciliation line, and the partition it completes stays
exact in integer KiB: MemTotal == free + reclaimable + mapped + model + unaccounted. The next
section is why this split exists.
These are counted as consumers and shown alongside the process groups:
tmpfs / shm-/tmp,/dev/shmand shared memory. The kernel never reclaims it, so it is genuinely occupied RAM. Chrome and Electron keep hundreds of deleted-but-mapped segments there. ItsSWAPcell is an estimate of how much tmpfs content is currently swapped out - see the paragraphs about it in the Columns section above, and thedu/dfnote just below.Kernel-SUnreclaim, page tables, kernel stacks, per-cpu areas.zswap (compressed pool)- RAM spent on the compressed swap pool, when zswap is enabled and in use. See the SWAP and ZSWAP column sections above for how this relates toVmSwapand to the per-group breakdown.
All three rows sort together with the regular groups and collapse into Other groups just like any
other row.
This is the case that motivated the mapped split above, and the clearest one to recognise if you
run a VM yourself. On the machine this was measured on, a Windows 10 guest configured with 4096 MB
of RAM was running under VirtualBox. VirtualBox maps the guest's RAM file-backed rather than
anonymous - that mapping is how it supports snapshots and saved state - so the guest's memory does
not look like ordinary process memory to the kernel. Back when the default metric was anon and
every guest shared one row, memgroups --processes Virtualization showed:
Virtualization (KVM/VirtualBox): 219.82 MiB + 83.66 MiB swapped out, ~32.26 MiB of zswap RAM, 7 processes
PID SIZE SWAP COMMAND
2349394 614.93 MiB - /usr/lib/virtualbox/VirtualBoxVM --comment win10 --star…
1298783 81.07 MiB 85.27 MiB /usr/lib/virtualbox/VirtualBox
1342 392 KiB 140 KiB /usr/bin/dnsmasq --conf-file=/var/lib/libvirt/dnsmasq/…
1343 376 KiB 144 KiB /usr/bin/dnsmasq --conf-file=/var/lib/libvirt/dnsmasq/…
Today that guest has a row of its own and its whole 4.53 GiB is in SIZE:
VM: win10: 4.16 GiB + 60.33 MiB swapped out, ~22.57 MiB of zswap RAM, 1 processes
PID SIZE SWAP COMMAND
3224953 4.16 GiB 60.33 MiB /usr/lib/virtualbox/VirtualBoxVM --comment win10 --startvm 59ad…
/proc/PID/status for the VirtualBoxVM process itself read VmRSS 5391128 kB, RssAnon 627636 kB
(612.93 MiB - the SIZE above) and RssFile 4753836 kB (4.53 GiB - essentially the whole guest). The
anon metric only ever counted RssAnon, so the entire group showed as 696.75 MiB - about a sixth
of what the VM actually held resident. Worse than merely understating it: before the mapped split
existed, that 4.53 GiB sat inside Cached, and the old model handed all of Cached to
reclaimable. The tool was classifying a running Windows guest's memory as free, droppable disk
cache. It is not reclaimable at all - it is pinned guest RAM the VM needs again on its very next
instruction.
This is the case that made data the default metric and put droppable in the footer in place of
cache. Both halves were needed: the metric so the guest's RAM has a row, the footer so the same
bytes are not also promised back as free cache. A later measurement on the same machine with seven
guests running found the footer offering 19.82 GiB of cache when the kernel could hand back exactly
nothing.
A more precise per-process split is not available here. /usr/lib/virtualbox/VirtualBoxVM is
installed setuid (-rwsr-xr-x root), which the kernel treats as non-dumpable: status stays
readable, but maps, smaps and smaps_rollup are denied even to the process's own owner
(confirmed live: cat /proc/PID/maps on this process returns "Permission denied" run as the owning
user, not root). smaps_rollup is exactly what --metric pss needs, so under pss this process is
invisible - it reads 0, the same way any process whose smaps_rollup cannot be read does (see the
metrics table above). RssFile from status is the only file-backed figure this tool can get for
VirtualBox, which is what --file exists to surface - see below for why it is trustworthy for this
particular group and not for most others.
--file adds a FILE column: each group's summed RssFile, all of it, including the program text
that the default data metric subtracts. Under --metric anon it is the file-backed counterpart to
RssAnon; under data it is the unsubtracted version of what SIZE already carries. It is off by default - the fixed columns with FILE on take 75
characters before the group name even starts, which leaves 5 characters for the name at a common
80-column terminal, so turning it on unconditionally would break the one guarantee the default table
makes: fitting 80 columns.
It double counts. RssFile is charged in full to every process that maps a file, so a library or
a block of shared page cache mapped by many processes is counted once per process, not once. On this
machine, with --file on:
# GROUP SIZE FILE % RAM SWAP ZSWAP PROCS
----------------------------------------------------------------------------------------------------
1 Google Chrome 17.31 GiB 21.17 GiB 13.8% 7.05 GiB ~2.63 GiB 204 ······
...
TOTAL 44.54 GiB 29.61 GiB 35.5% ~26.58 GiB 7.75 GiB 869
FILE is all file-backed RSS; SIZE only its non-text part. Read one row.
TOTAL 29.61 GiB double counts shared mappings; meminfo Mapped is 2.11 GiB.
Summing the column across every group gives 29.61 GiB; the true, deduplicated figure - Mapped from
/proc/meminfo, which counts every mapped page-cache page exactly once machine-wide - was 2.11 GiB.
About a 14x inflation, mostly from Google Chrome's ~204 processes each counting the same shared
libraries and shared mappings again and again. --metric rss has the same problem, worse: VmRSS
already includes file-backed memory, so the whole SIZE column is inflated the same way, with no
separate honest figure next to it to catch it against - Chrome read 39.63 GiB under rss against
17.46 GiB under anon, on the same machine, moments apart. The footer under the FILE column always
states the double count in numbers, whenever the column is shown - it is meant to be read as a
statement, not a warning label to skim past.
FILE is exact only when a group's processes do not share file mappings with each other. A
single process, or several processes that each map their own separate files rather than a shared
one, produces no double counting at all - there is nothing to double count. That is precisely the
VirtualBox case above: the guest RAM mapping belongs to the one VirtualBoxVM process, not shared
with the other three processes in the group, so its FILE figure is the real number, not an
inflated one. Read FILE group by group, never as a TOTAL summed across the whole machine.
When --file is off, the tool still checks for a group badly understated by the metric in use: if
some group's file-backed memory is both more than 1 GiB and more than double its SIZE, one footer
line names the worst offender and points at --file. The threshold is deliberately two conditions at
once, so it stays quiet on ordinary desktop noise (a few dozen node or uv processes each holding
tens of megabytes of shared interpreter and library pages) and only fires on something worth a second
look. Under --metric anon, with the VM above running, the plain table ended with:
Virtualization (KVM/VirtualBox): 4.47 GiB file-backed, not in SIZE (--file).
Under the default data metric this line has nothing left to say about a virtual machine: the
guest's memory is in SIZE already, so the ratio that triggers the hint no longer holds. It still
fires for a group whose file-backed memory really is outside SIZE - program text, mostly, which is
what data subtracts.
--json always includes file_kb per group, gated or not, the same way it always includes
swap_kb - a consumer of the JSON should not have to branch on whether --file was passed.
If you go looking for the tmpfs occupancy number yourself, reach for df, not du. The tool's
used_kb comes from statvfs() on each tmpfs mount - the exact definition df uses - precisely
because it is the one built-in way to see tmpfs space still held open after the file behind it was
deleted. du cannot see that: it walks directory entries, and a file with zero links has none left
to walk to, even while a process keeps it mapped or open. This is not an edge case on a desktop -
Chrome and Electron routinely unlink() a tmpfs-backed segment right after mapping it, precisely so
it disappears if the process crashes.
On the machine this was measured on, /tmp alone showed:
| Tool | Reports | Value |
|---|---|---|
du -sh /tmp |
only files still linked | 1.951 GiB |
df /tmp |
actually occupied in the tmpfs | 3.811 GiB |
A 1.86 GiB gap, entirely deleted-but-still-open files - 4631 of them counted as open file
descriptors on /tmp and /dev/shm together on that machine. df counts every one of them
because the space is still allocated; du counts none of them because there is no name left to
find. If your own du -sh /tmp reads lower than what memgroups reports, this is almost certainly
why - it is not a bug in either tool, they are answering different questions.
TOTAL does not track used (MemTotal - MemAvailable) exactly, and should not be expected
to. In the example above they are 44.78 GiB and 48.20 GiB - 3.42 GiB apart. used is not a measure
of what is actually held onto: MemAvailable is the kernel's estimate of what could be freed under
pressure, so used stays inflated by everything the kernel is merely willing to hand back - buffers,
page cache, reclaimable slab (77.30 GiB of cache above). The tool's model counts something narrower
and more honest: what processes and the kernel are actually holding right now - anonymous memory,
tmpfs/shmem, unreclaimable kernel structures, the zswap pool. That is why TOTAL reads lower than
used, not because it is wrong.
The gap is not hidden either - the reconciliation line states it explicitly as unaccounted
(drivers, firmware reservations, and anything else the kernel does not report per-process; 2.84
GiB / 2.3% of RAM above). A negative unaccounted would mean a torn read of /proc/meminfo, not a
sign to trust - it is printed as-is, never clamped to zero.
- Linux only,
/proconly. - Processes are counted, threads are not.
- A snapshot at the moment of the run; no history.
- Kernel memory is broken down coarsely: slab, page tables, stacks and
tmpfsget one row each. - A setuid, non-dumpable process (VirtualBox's
VirtualBoxVMis the known case) deniesmaps,smapsandsmaps_rollupeven to its own owner.--metric pssreads 0 for it and the accurate smaps-based split is impossible;RssFilefromstatus(--file) is the only file-backed figure available. See "Virtual machines: guest RAM is not disk cache" above.
python3 -m pytest # tests run against a fake /proc built in tmp_path
uvx ruff check .
uvx ruff format .All /proc access is parameterised (collect_procs(procfs=...), the --procfs flag), so the
tests do not depend on the state of the machine.
GPL-3.0. See LICENSE.