Skip to content

Repository files navigation

memgroups

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 -h reports 33.96 GiB of buff/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. memgroups prints droppable, and means it.
  • Seven VirtualBox guests with loaded operating systems, 4 GiB configured each. ps, htop and ps_mem see 0.78 GiB between them, because a hypervisor maps guest RAM as file pages and RssAnon cannot see it. memgroups sees 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 Unclassified row is one process: memgroups itself. The sixth runs a desktop environment its author wrote, which is what the config file is for.

Example

 #  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:

  • VmSwap over-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, but VmSwap counts 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.

Install

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 memgroups

Anywhere 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 place

The 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.

Usage

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

How grouping works

  1. User rules from the config. Checked first - the only way to pull a container out of its automatic Docker group and name it yourself.

  2. 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 the com.docker.compose.project label;
    • a container started by plain docker run becomes Docker: <image>.

    The data comes from a single docker ps call. Without docker (--no-docker, no permission) the label falls back to the short container id. A host-side docker 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.

  3. 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> --startvm for 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: mate is 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. -name is an ordinary option, and without that gate find /home/u/obs -name *.mkv became a virtual machine called *.mkv.

    Daemons that serve every guest and name none - VBoxSVC, VBoxXPCOMIPCD, libvirtd and its dnsmasq - stay in the plain Virtualization (KVM/VirtualBox) row, exactly as Docker engine stays separate from the containers.

  4. 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.

  5. 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=utility or electron --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 in PPid are guarded against.

  6. 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.

Configuration

~/.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

Metrics and accuracy

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.

Why the default metric counts file-backed memory

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.

Disk cache counts as free memory

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/shm and shared memory. The kernel never reclaims it, so it is genuinely occupied RAM. Chrome and Electron keep hundreds of deleted-but-mapped segments there. Its SWAP cell is an estimate of how much tmpfs content is currently swapped out - see the paragraphs about it in the Columns section above, and the du/df note 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 to VmSwap and to the per-group breakdown.

All three rows sort together with the regular groups and collapse into Other groups just like any other row.

Virtual machines: guest RAM is not disk cache

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.

The FILE column and --file

--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.

Checking the tmpfs figure by hand: du and df disagree

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.

Limitations

  • Linux only, /proc only.
  • 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 tmpfs get one row each.
  • A setuid, non-dumpable process (VirtualBox's VirtualBoxVM is the known case) denies maps, smaps and smaps_rollup even to its own owner. --metric pss reads 0 for it and the accurate smaps-based split is impossible; RssFile from status (--file) is the only file-backed figure available. See "Virtual machines: guest RAM is not disk cache" above.

Development

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.

License

GPL-3.0. See LICENSE.

About

Linux memory grouped by application, not by process - including the VM guest RAM that ps, htop and free cannot see. No root, no dependencies, one file.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages