A Linux snapshot of where memory actually lives: RAM (plain + zswap) and
disk swap. It is a Python 3 script that reads /proc and, optionally,
zswap's root-only debugfs counters. Stdlib only.
memstat.py one-shot snapshot
memstat.py -w [N] refresh every N seconds (default 2), with rates
memstat.py -i annotate derived figures with footnotes
memstat.py --install grant passwordless reads of the zswap pool counters
memstat.py --uninstall revoke that grant
memstat.py --selftest run fixture-driven checks of the bucket maths
memstat.py -h this help
Run it from this directory (./memstat.py) or put it on your PATH.
The installation mostly involves copying memstat.py to your path, but some
of the zswap instrumentation needs additional permissions.
The pool size lives in root-only debugfs
(/sys/kernel/debug/zswap/{pool_total_size,stored_pages,written_back_pages}).
--install writes a visudo-checked sudoers rule for exactly those three
cat invocations, then verifies a passwordless read. Without it the zswap
line is blank, SWAP on disk is overstated, and zswap_freed renders -.
--uninstall removes the rule. Run both as your normal user, not root.
Python 3 (stdlib only) and Linux with /proc/meminfo, /proc/vmstat, and
/proc/pressure. zswap is optional; if it is disabled the tree says so.
Colour needs a terminal that understands ANSI SGR and the █ / ░ glyphs.
The top panel is a hierarchy, not a list of independent meters.
MEMORY IN USE used / (MemTotal + SwapTotal)
├─ RAM used / MemTotal (MemTotal − MemAvailable)
│ ├─ non-zswap RAM minus the zswap pool
│ └─ zswap compressed anon sitting in RAM
└─ SWAP on disk swap slots that actually reached disk
MEMORY IN USE is RAM plus disk swap. Pages held in zswap still occupy a
swap slot, but they have not hit the disk, so they are counted under RAM (the
pool) rather than under SWAP on disk. Without zswap stats, that disk figure
is overstated.
The stacked bars are categories, not locations:
- magenta / anon — No file behind it. Includes shmem (
/dev/shm). Anything in zswap or on disk swap is anon — file pages are written back and dropped, never swapped. - cyan / file — Page cache + buffers, with shmem removed so it is not counted twice.
- yellow / kernel — The remainder of RAM after free, anon, file, and the zswap pool.
- dim / free — Unused capacity of that row.
Percentages go green / yellow / red at 70% and 90%. zswap's compression ratio is coloured separately: green at ≥3×, yellow at 2–3×, red below 2× (zstd on ordinary application memory usually lands around 3–4×; below 2× the CPU cost is mostly wasted).
committed is Committed_AS / CommitLimit. A multiple well above 1× means
the kernel has promised more anonymous memory than it can back, which is how
a machine enters reclaim livelock rather than a clean OOM kill.
zswap and non-zswap compete for the same RAM. Their denominators are live:
- non-zswap capacity =
MemTotal −current pool size, so it shrinks as the pool grows. - zswap capacity =
min(max_pool_percentofMemTotal,SwapTotal / compression ratio). Every page in the pool also consumes a swap slot, so at good compression the slot bound bites beforemax_pool_percentdoes.
Values are pages (4 KB) per interval, except pgmajfault, which counts
faults (~4 pages each). - means history has not reached that far yet.
Rates need sample history. Every run appends a line to
$XDG_RUNTIME_DIR/memstat-py.$UID.hist (tmpfs; discarded at reboot, matching
the counters themselves). A cold one-shot shows totals only; windows fill in
as you run it again or leave -w going. History is kept for 150 seconds.
Four groups:
cost of reclaim — pgsteal_direct is the kernel stalling to free RAM
under the allocating process. Any non-zero rate here is a stall.
OUT of RAM — evicted — anon leaving RAM: into zswap, from zswap onto disk, or written to swap by any path.
INTO RAM — faulted in — file pages returning, anon coming back from
zswap (fast) or from disk swap (slow), and major faults. pgmajfault also
covers first-ever reads of file pages that were never resident; the three
rows above it are genuine returns.
FREED — anon claim dropped, not evicted — a process actually gave the page up: it exited, unmapped, or freed. That is the opposite of eviction (the OUT rows), where the process still owns the page and the kernel only pushed it into zswap or onto disk.
The three FREED rows watch that same anonymous page (heap, stack,
/dev/shm) in each place it can sit:
anon_freed— uncompressed in RAMzswap_freed— compressed in the zswap poolswap_freed— a slot on disk swap
There is no page-cache row. File cache is leftover RAM the kernel filled on its own. Nobody asked to keep it, so a falling cache number is not a release — it is housekeeping. On an idle machine that is ~10k pages/s, and it would bury the FREED signal.
FREED is derived from occupancy gauges, not counted. Eviction (OUT rows) is subtracted so a page leaving RAM because it was swapped out does not read as a release. Within one sample interval (~2s) a concurrent allocation still cancels a release; across intervals they do not.
Colour on the rate table is pages/sec, so buckets of different widths are judged on the same scale. The FREED rows invert that: they stay dim below a deadband (ordinary idle churn) and go green when a real release is happening.
/proc/pressure for cpu, memory, and io, as %time stalled over 10s / 60s.
some means at least one task stalled. Memory full means every non-idle
task stalled at once — the signature of a reclaim livelock rather than mere
slowness. That figure is highlighted when it is non-trivial.