Documentation
The reasoning, the numbers, and what is still unmeasured.
How to read this page
nixram's core has NixOS and system-manager modules. You declare how much RAM the machine has. From that one declaration it configures a compressed swap device that lives in RAM (or a compressed cache in front of real disk swap), an out-of-memory watchdog, and a set of kernel memory settings.
Everything that varies by RAM size comes from the same row of the same table, so those values cannot contradict each other. The few settings that are properties of the swap medium rather than of the machine are constants, listed under Shared settings.
This page assumes you are comfortable with systemd and NixOS, and assumes nothing about kernel memory management. The words you need are defined as you meet them, and every kernel term used anywhere on this page has a plain definition in the glossary at the end.
Everything here is sourced. Each value carries a mark saying where it came from: someone else's published work, our own reasoning, or the kernel's own default left alone on purpose.
Why nixram?
The friction. Getting a Linux box to behave under memory pressure means picking a compressed swap size, then a real memory cap, then a swap priority, then a watchdog threshold, then four or five kernel settings. Each has its own blog post. None of them checks the others. A value that is right on a 512 MiB VPS is wrong on a 64 GiB server, and nothing tells you which one you are copying.
The fix. One declaration — how much RAM the machine has — indexes a table that holds every one of those values, tiered across fourteen RAM sizes, with the source of each value written down next to it.
| Instead of | What it gives you | Where it stops |
|---|---|---|
| Hand-tuned sysctls | Total control, and exactly the values you chose. | Nothing relates the swap size to the memory cap to the watchdog. You maintain the coherence by hand, per machine, forever. |
| zram-generator alone | The mechanism nixram itself uses. It creates and sizes the device properly. | A mechanism, not a policy. No per-RAM-size tiering, and no opinion on the watchdog or the kernel settings that have to agree with it. |
The built-in NixOS zramSwap |
One option away, already in nixpkgs. | Only ever sets the ceiling, through memoryPercent and memoryMax. It has no concept of a real memory budget, which is the mechanism nixram's whole model depends on. nixpkgs itself documents zram-generator as the intended successor. |
| Fedora SwapOnZRAM | One good, well-argued default, and the source of several of nixram's cited values. | One default for one distribution. No tiering by RAM size, and no memory budget. |
| Pop!_OS default-settings | The richest cited set nixram draws on, covering the kernel settings too. | A fixed set of values for one hardware class. Nothing scales with RAM. |
| RHEL tuned | Mature, workload-aware profiles. | Organises around workload shape rather than RAM size, and models no relationship between a ceiling and a budget. |
| earlyoom, nohang | A good out-of-memory watchdog. | Overlaps only the watchdog layer. No opinion on zram sizing, zswap, or the kernel settings. |
| srvos, nixos-hardware | Plenty of other sensible NixOS defaults. | No memory-pressure story at all. |
| Doing nothing | The kernel's own defaults, which are reasonable and safe. | No compressed memory, and a machine that degrades quietly under pressure until the kernel kills something you needed. |
User-session memory policy
The core module manages host-wide memory pressure. A separate Home Manager output owns the parts that belong to a logged-in account: Profile Sync Daemon configuration and user units, plus the user half of an optional device-memory foreground-session booster.
Profile Sync Daemon only runs with an explicit browser list, so it never silently adopts every supported browser it finds. On a distro-managed host, the host backend supplies PSD's command and package; the Home Manager module supplies the configuration and the user service.
The device-memory booster is deliberately narrow. It is not a general container or GPU-sharing manager. Before the system service can start, nixram checks that the current cgroup root exposes /sys/fs/cgroup/dmem.capacity; without it, the upstream daemon cannot calculate the device-memory limits it needs to apply.
The words you need first
Four definitions carry the rest of this page. Everything else is in the glossary at the end.
The two mechanisms
- zram
- A block of your own RAM set up as a swap device. Cold pages get compressed and stay in memory, so you fit more into the same RAM. No disk, no file and no partition is involved anywhere in the zram path.
- zswap
- A compressed cache that sits in front of a real disk swap partition. Pages are compressed on their way out, and cold ones are written through to the disk in the background. Once the cache is full it stops accepting, and pages go straight to disk until it drains again. It is not a swap device itself and cannot work without one behind it.
The two limits
zram-size— the ceiling- This is not disk space of any kind. It is a ceiling on how much compressed data the zram device may hold, and all of that data lives in RAM. The name is misleading, and the kernel's own name for the attribute underneath (
disksize) is worse. There is no disk, no file and no partition anywhere in this path. Treat it as a number, not as storage. zram-resident-limit— the budget- The real cap on how much actual RAM the compressed pool may ever occupy. This is the limit that protects the machine. It maps directly onto the kernel's
/sys/block/zramN/mem_limit.
The model: a ceiling and a budget
Everything else on this page follows from one idea. zram is given two separate limits that do two different jobs.
The first is a ceiling: how much compressed data the device may hold. It is generous. It is also just a number — nothing is allocated because of it, and no storage of any kind is reserved.
The second is a budget: how much actual RAM the compressed pool may ever occupy. It is tight, and it is real. This is the one that protects the machine.
Because the budget does the safety work, the ceiling costs nothing. Setting it generously gives compression room to stretch on a machine whose pages happen to compress well, without exposing the machine to any risk if they do not.
level = "4G" · a machine with 4 GiB of RAM
┌───────────────────────────────────────────────────────────┐
│ CEILING zram-size = ram * 75 / 100 3 GiB │
│ │
│ How much compressed data the device may hold. │
│ A number only. No disk, no file, no partition. │
│ Reaching it means: the device is full, swap is full. │
└───────────────────────────────────────────────────────────┘
│
│ all of it actually living in
▼
┌───────────────────────────────────────────────────────────┐
│ BUDGET zram-resident-limit = 25% of RAM 1 GiB │
│ │
│ How much real RAM the compressed pool may occupy. │
│ Enforced by the kernel. This is the real protection. │
│ Reaching it means: no more pages accepted, same as full. │
└───────────────────────────────────────────────────────────┘
Whichever limit is reached first is the one that stops you.
Pages that compress well hit the ceiling. Pages that compress
badly hit the budget. Both outcomes are bounded and safe.
What each one protects against
- The ceiling protects against unbounded swap. Without it, the kernel would keep handing pages to a device with no stated capacity. A bounded absorber followed by a timely out-of-memory decision is better than an unbounded one followed by a slow death.
- The budget protects against the compressed pool eating the machine. Compressed pages are still RAM. Without a cap, badly-compressing data could grow the pool until the thing meant to relieve memory pressure becomes the cause of it.
Is swapping to zram bad?
No. Parking cold pages in compressed RAM is the design working as intended. The instinct that swap-in-use is a red flag comes from disk swap, where every touched page costs a real disk read. zram swap is still RAM. It is closer to "this data got compressed" than to "this data left memory".
Watch stall time and the rate of out-of-memory kills. Do not watch swap-used percentage.
Quick start
1. Find your level. Run this on the machine you are configuring. It reads the real RAM total from /proc/meminfo and prints a line to paste.
nix run github:julian-corbet/nixram-corbet-ch#detect-level
2. Add the flake input.
inputs.nixram.url = "github:julian-corbet/nixram-corbet-ch";
3. Import the module and declare the level.
imports = [ inputs.nixram.nixosModules.nixram ];
nixram = {
enable = true;
level = "4G";
};
That is the whole configuration. There is no build-time detection and no default level: Nix builds cannot read the target machine's live memory, so the level is a fact you detect once and commit, like a disk UUID. Enabling the module without a level is a hard build error with a message pointing at the fix.
On a laptop with a swap partition you keep, or one that hibernates to it, add the zswap mode instead. It needs at least one entry in swapDevices, and it takes effect on the next boot rather than on rebuild.
nixram = {
enable = true;
level = "16G";
mode = "zswap";
};
To check it came up, on a zram machine:
zramctl # the device, its algorithm, and what it holds
swapon --show # zram at priority 100, above any disk swap
systemctl status systemd-oomd # the watchdog, where the level arms it
The full level table
Fourteen anchor sizes. Every value below is an expression, not a baked-in number: ram is zram-generator's own variable for the machine's total detected RAM in MiB, evaluated against the real /proc/meminfo at boot. A machine that rounds up to a larger anchor still computes from its own true RAM.
What the marks mean
- C Cited. Taken from someone else's published work — a distribution's shipped default, upstream documentation, or the tool's own worked example — and applied as it stands. Somebody outside this project already made the case for it.
- D Stated directly. A literal figure the maintainer gave from firsthand production experience or a worked example — not derived by argument, and not this project's own inference. Applied as given.
- E Our own reasoning, not yet measured. nixram picked this value by argument, not by experiment. The argument is written down. The measurement has not been done. These are tracked as open questions and get upgraded to cited or corrected as results come in.
- K The kernel's own default, untouched. nixram deliberately sets nothing here and lets the kernel's computed or shipped value stand.
| Level | Ceilingzram-size |
Budgetzram-resident-limit |
Compression | Recompression | Reclaim startwatermark_scale_factor |
Watchdog |
|---|---|---|---|---|---|---|
| 256M | ram (100% of RAM) E |
30% of RAM D | zstd(level=3) D |
off D | 200 E | off E |
| 512M | ram (100% of RAM) E |
30% of RAM D | zstd(level=3) D |
off D | 200 E | on E |
| 1G | ram (100% of RAM) E |
30% of RAM D | zstd(level=3) D |
off D | 200 E | on E |
| 2G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 150 E | on E |
| 4G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 150 E | on E |
| 6G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 150 E | on E |
| 8G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 150 E | on E |
| 10G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 125 C | on E |
| 12G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 125 C | on E |
| 16G | ram * 75 / 100 (75% of RAM) E |
25% of RAM E | lz4 E |
on E | 125 C | on E |
| 24G | ram * 75 / 100 (75% of RAM) E |
20% of RAM E | lz4 E |
on E | 125 C | on E |
| 32G | ram * 75 / 100 (75% of RAM) E |
20% of RAM E | lz4 E |
on E | 125 C | on E |
| 64G | ram * 75 / 100 (75% of RAM) E |
20% of RAM E | lz4 E |
on E | 100 E | on E |
| 128G | ram * 75 / 100 (75% of RAM) E |
20% of RAM D | lz4 D |
on D | 100 E | on E |
The ceiling column is now just two flat fractions, not a per-tier formula: plain ram (100% of RAM) at 256M/512M/1G, ram * 75 / 100 (75% of RAM) at every tier from 2G through 128G. It is derived, not guessed: the maintainer's own formula for the ceiling is "take the physical budget, multiply by pi, and take the nearest base-2-ish value" — meaning the nearest 3-smooth number (only 2 and 3 as prime factors: 256M, 384M, 512M, 768M, 1G, 1.5G, 2G, 3G… the sizes RAM and VPS tiers actually ship in). Because the resident-limit budget behind it ([2] below) is a fixed percentage within each tier group (30%, 25%, or 20%), and the 3-smooth grid is geometrically spaced, "nearest 3-smooth number to budget × pi" reduces to the same flat fraction for every tier in a group: 30% × pi ≈ 0.94, which rounds to exactly 1.0 (plain ram); 25% × pi ≈ 0.79 and 20% × pi ≈ 0.63 both round to 0.75. No pi()/round()/log() is evaluated live at boot — it is just the two flat percentages above. The mechanism itself is the maintainer's own, and it is checked directly against his own worked examples: 256M/512M/1G resolve to 256 MiB/512 MiB/1 GiB ("almost a GB" at 1G, his own words), and ~128G resolves to 96 GiB — his own direct correction ("96GB is better") over an earlier version of this formula that rounded down to a plain 64 GiB. The curve connecting those two endpoints, and exactly which tiers fall into which resident-limit group, is this project's own extrapolation.
Note that the watchdog being on above 256M is our own judgement, not a stated one. What is cited is the threshold it fires at (60% for 30 s), listed under Shared settings.
The values above are transcribed by hand from the module's own level table in levels.nix. Both this page and the copy in the repository documentation can drift away from it. Where they disagree, levels.nix is the source of truth.
Rounding up is safe
The detector rounds up to the next anchor, never down. That is safe because every value is an expression evaluated against the machine's real RAM at boot. A 20 GiB machine assigned to the 24G level still computes from its real 20 GiB. A machine larger than 128 GiB is assigned the 128G level, because there is nothing above it, and the expressions still compute from its real RAM.
Rounding into the 64G tier is a good example. Any machine above 32 GiB and up to 64 GiB lands there. On a 33 GiB machine that means a ceiling of about 24.75 GiB (75% of its real 33 GiB, not of the 64G anchor) and a budget of about 6.6 GiB (20% of the same real 33 GiB) — both self-correct against the machine's own true size, so there is no fixed cap to distort the picture the way an older, capped version of this formula once did.
Every anchor boundary in the current table shifts only continuous percentages — the ceiling, the budget, the reclaim threshold — which self-correct against the machine's real RAM regardless of which anchor picked them, and the same architecture pairing (zstd(level=3) alone at 256M-1G; lz4 plus zstd(level=3) recompression at 2G-128G, see why each tier is shaped this way) now applies uniformly across all three of the 256M/512M/1G tiers. A 768M anchor briefly existed earlier in this project's history, specifically to close a rounding gap between 512M and 1G while their zram architectures still differed from each other. Once 256M-1G were unified onto one architecture and one swappiness value, 512M and 1G became identical in every formula, and the anchor stopped changing anything a rounded-up box would experience — it was removed as part of the same correction, not kept around as an inert historical marker.
Why each tier is shaped this way
Read the 4G block first. Every other tier is that one with a scarcity turned up or down.
The survival tier
Two things are scarce at once: RAM, and the CPU needed to compress it. The ceiling is plain ram, all of it, because compression is the only headroom this machine will ever get. The budget at 30% of RAM caps how much of that reach can ever become real spend — a CPU-tax bound, not a memory-safety one, which is why it's the same fraction across all three small tiers.
Compression is zstd(level=3) primary with no recompression pass at all — the maintainer's own direct instruction ("everything up to a GB goes to zstd primary and done"). Swappiness is eager, at 120: with this little true RAM behind the budget, the right move is to grab whatever density is available immediately rather than wait for an idle window to prove a page cold. The watchdog is off here specifically: the memory the daemon itself occupies is an unmeasured fraction of a very small total, so the kernel's own killer plus the protected-unit layer stands guard alone.
Same shape, watchdog armed
Identical to 256M in every formula — ram ceiling, 30% budget, zstd(level=3) primary with no recompression, swappiness 120. The one thing that changes: the watchdog is armed, because its own footprint is a far smaller fraction of the machine here.
The small-box shape, last time
Identical again to 256M/512M in every formula: ram ceiling, 30% budget, zstd(level=3) primary with no recompression, swappiness 120. This wasn't always so — an earlier version of this table put 1G with the reluctant tiers on the reasoning that it had "enough true RAM to wait." That reasoning was corrected directly: "with 1GB RAM, you need to get whatever you can" describes urgency, the same light-usage, RAM-desperate story that applies to 256M/512M, not the "enough RAM to comfortably wait" story the reluctant tiers earn instead.
Reclaim starts early across all three small tiers, at 200. Any fixed percentage of a tiny memory zone is a tiny absolute number of free pages, so background reclaim has to begin sooner to leave real headroom.
The architecture flips, the reluctant tiers begin
Switches to a fast lz4 primary paired with an idle-gated zstd(level=3) recompression pass, for a reason that is now settled directly rather than inferred: this class of machine increasingly runs actively compute-bound workloads (LLMs, genAI, many concurrent apps) that compete hard for the same CPU a dense synchronous primary would consume, so the cheap primary protects that live demand and the expensive pass is deferred to whenever the box is genuinely idle instead — which, at this scale, it reliably has. (Exactly where the 1G/2G line itself falls is still this project's own placement, not something specified tier by tier.) The ceiling also drops to ram * 75 / 100, 75% of RAM — the same flat fraction that now holds all the way to 128G.
The budget tapers from 30% to 25%. This is a CPU-tax bound, not a memory-safety one, and with multiple GB present the pool doesn't need quite as generous a share to keep that cost bounded. Swappiness drops to 10 at rest — the maintainer's own real historical data point — with a new PSI-gated relief valve that temporarily raises it to 60 during genuine, sustained memory pressure and lowers it back once the pressure resolves (see Shared settings). Reclaim relaxes to 150.
The reference tier
The most ordinary row in the table. The ceiling is 75% of RAM, the budget is the standard 25%, and every shared constant sits at its normal value.
If you want to understand any other tier, understand this one and then note what changed.
The same row, with more slack
Nothing changes but the numbers the same expressions resolve to.
Reclaim eases to the cited value
Ceiling (75%) and budget (25%) hold steady through this band. The one thing that moves is watermark_scale_factor, which drops to 125 — the one flat value Pop!_OS itself validated, rather than the small tiers' own extrapolated taper.
The budget bumps down early
The ceiling stays at 75% of RAM, but the resident-limit budget drops to 20% starting at 24G — earlier than where an older version of this design used to drop the budget at all (64G). The 20% figure itself is the maintainer's own stated value (given for the ~128G tier); exactly where the step down begins (24G, not 32G or 64G) is this project's own placement connecting that figure back to the 25% anchor, not independently confirmed.
Where the budget keeps applying
The budget stays at 20% here — unchanged since 24G, not a new taper step. An earlier version of this design left it unset above 32G, reasoning that a small enough ceiling made a second cap redundant — that reasoning conflated a memory-safety argument with what this budget actually is: a CPU-tax bound on how much RAM may be mid-compression-cycle at once, which doesn't stop mattering just because the ceiling is generous. The ceiling itself is still 75% of RAM, 48 GiB of reach here, not a fixed cap.
Reclaim eases to 100. For a machine running one huge workload that does not swap usefully, the honest alternative is mode = "none": watchdog and kernel settings, no swap medium at all.
The maintainer's own correction, checked directly
Ceiling: 96 GiB, 75% of RAM — the maintainer's own direct correction ("96GB is better") over an earlier version of the formula that rounded down to a plain 64 GiB. Budget: 20% of RAM, about 25.6 GiB, matching his own figure almost exactly ("taking a 20% slice of system RAM here is about 25GB"). Compression is lz4 plus zstd(level=3) recompression, his own instruction ("we should use lz4 and then zstd") for a machine he describes as reluctant but genuinely compute-bound — LLMs, genAI, and many concurrent apps competing for the same CPU a dense primary would consume.
Everything else is as 64G.
Shared settings
The zram profile
Most of these hold at every level when mode = "zram", because they are properties of the swap medium, not of the machine. vm.swappiness is the one exception: it genuinely varies by level, because how much true RAM remains and how much file cache there is to sacrifice before ever touching anonymous memory changes with RAM size, on top of the medium-cost question that doesn't. Under mode = "zswap" the swap-related settings differ, and under mode = "none" they are left alone entirely.
| Setting | Value | Where it comes from |
|---|---|---|
vm.swappiness |
120 at 256M/512M/1G, 10 at rest from 2G through 128G | D E The range used to stop at 100. The kernel extended it to 200, with a documented cost model: values above 100 are explicitly sanctioned where swapping a page in is cheaper than re-reading it from disk — that part is scale-invariant. But a second, separate axis genuinely does change with RAM size: how much true RAM remains behind the physical budget, and how much file cache there is to sacrifice before ever touching anonymous memory. The eager tiers (256M/512M/1G) have little of either and lean on zram willingly, at 120 — the maintainer's own further revision down from an adversarially-reduced 130, itself pulled back from an initial 180 (Pop!_OS's own zram default): once file cache is genuinely near-empty, the anon:file scan-target ratio barely changes which pool reclaim picks, so the extra ceiling mostly bought earlier, more frequent reclaim triggering, i.e. pure compress/decompress cost on the class least able to spare it. The reluctant tiers (2G-128G) rest at a flat 10 — the maintainer's own real historical data point (the operator's previous server ran swappiness=10), replacing an earlier, never-validated flat 60 that turned out to be nothing more than the kernel's own untuned default. Which tiers count as eager versus reluctant, and the specific number each group rests at, are directed; where the group boundary itself falls (1G/2G) is this project's own placement. |
| Swappiness relief valve | reluctant tiers only (2G–128G): raises to 60 while avg10 ≥ 10%, lowers back to 10 once avg60 < 1%, checked every 30s |
D E A resting swappiness of 10 makes routine swap-touching rare, but on its own it can't tell "ordinary fullness" apart from "a genuine overflow event" where leaning on swap is actually the right call — the maintainer's own framing: "swap is for overflow when upgrades run or whatever, or for icecold pages," not for routine fluctuation. A new systemd timer (nixram-swappiness-relief) reads /proc/pressure/memory's "some" line: a fast 10-second average entering relief catches a real spike quickly, while a slower 60-second average is required to leave it again, so a brief lull mid-event doesn't bounce swappiness back down before the pressure has actually resolved. State resets to the low baseline on every reboot. On by default only on the reluctant tiers; the eager tiers are already leaning on zram willingly and have no low baseline to relieve from. The direction, the 10 baseline, and the mechanism's purpose are the maintainer's own; the specific thresholds and check interval (zram.swappinessRelief.*) are this project's own unvalidated starting point. |
vm.page-cluster |
0 | C Read-ahead exists to amortise a disk seek. zram has no seek. Reading extra pages only adds latency and wasted decompression, so nixram turns read-ahead off. |
vm.watermark_boost_factor |
0 | C Pop!_OS. |
vm.watermark_scale_factor |
200 / 150 / 125 / 100 by tier | C for 125, the one flat value Pop!_OS validated. E for the other three, which are nixram's own taper connecting to that anchor. Small machines need reclaim to start earlier, because any fixed percentage of a small zone is a small absolute number of pages. Very large machines need the opposite, and 100 suffices. |
vm.min_free_kbytes |
untouched | K The kernel's own computed value stands. No distribution default or kernel document reviewed offers a universal per-GB formula that holds across a 256M to 128G range, so nixram declines to invent one. minFreeKbytesOverride exists as a manual hatch; no level uses it. |
MGLRU min_ttl_ms |
1000 | C but flagged. The kernel documentation offers 1000 ms as its example value, and frames the setting as guidance for people who do not have an out-of-memory watchdog running. nixram runs it alongside one instead, as a complementary layer. That interaction over months of uptime is unmeasured. Applied through a systemd-tmpfiles rule, not a sysctl. It does nothing if MGLRU is not compiled into your kernel: it will not fail your boot, but it does leave a log line if you go looking. |
| Swap priority | 100 | C zram-generator's own upstream default, set deliberately above typical disk-swap priorities so the kernel always prefers zram where both exist. |
| Watchdog trigger | 60% pressure for 30 s | C systemd-oomd's own upstream defaults, unmodified. Set explicitly rather than through the built-in enableSystemSlice / enableUserSlices helpers, which hardcode 80% with no control over duration — purely so per-level tuning stays possible later. |
| Compression | zstd(level=3) primary alone, no recompression, at 256M/512M/1G; lz4 primary + zstd(level=3) recompression at 2G–128G |
D Two architectures, not one formula, split at the 1G/2G boundary — the maintainer's own explicit instruction: "everything up to a GB goes to zstd primary and done." An earlier version of this table wrongly gave 256M/512M the lz4+recompression shape instead, over-applying a separate, much narrower exception for the weakest possible hardware to the whole small-tier band — a real implementation mistake, caught and reverted; all three small tiers share one shape now. 256M/512M/1G pay zstd(level=3)'s cost directly and synchronously, with nothing behind it — never a lower level, backed by our own measurement that decompression cost is flat across levels. 2G and up use a cheap lz4 primary (backed by our own measured comparison against lzo-rle) with an idle-gated zstd(level=3) recompression pass behind it, for workload compute-boundedness rather than necessity — per the maintainer directly, larger machines increasingly run compute-bound workloads (LLMs, genAI, many concurrent apps) that compete for the same CPU a dense primary would consume, so the cheap primary protects that live demand and the expensive pass is deferred to idle time instead. See why each tier is shaped this way for the full explanation and its limits. Whenever the primary is already zstd(level=3), recompression is off; never both at once, and never a recompression level below 3. |
Where the watchdog is armed
The pressure thresholds are applied to -.slice, which covers the whole system, and user.slice, the parent of every logged-in user's session. nixram also points systemd.oomd.enable at its own oomd.enable with mkDefault, in both directions. That matters at 256M, where nixpkgs would otherwise leave the daemon running — its memory cost is the entire reason that tier disarms it.
Deliberately absent: SwapUsedLimit and ManagedOOMSwap, the triggers that fire on a swap-used percentage. Such a percentage would be measured against the generous ceiling, which is larger than the real budget on purpose. It would read "plenty of headroom" right up to the moment the real wall is hit. They are not configured at all, not even as a decorative backstop.
Protected units
oomd.protectedUnits defaults to [ "sshd.service" ] and gets two independent layers, both applied at every level regardless of whether the watchdog is enabled:
ManagedOOMPreference = "omit"tells the userspace watchdog to leave the unit alone.OOMScoreAdjust = -900tells the kernel's own last-resort killer to pick this process last. This is the layer that still protects the unit when the watchdog is off, absent, or too slow to matter.
Name only services that actually exist. A name matching nothing will materialise a skeleton unit. The .service suffix is accepted and normalised away.
The zswap profile
Two things to know before choosing this mode. It requires a real swap device — mode = "zswap" asserts that config.swapDevices is not empty, because zswap is a cache in front of disk swap, not a swap device itself. And it takes effect on the next boot only. These settings are kernel command-line parameters, off by default upstream, so nixos-rebuild switch alone does not retroactively enable zswap on a running kernel. That is a limitation of the mechanism, not a shortcut nixram took.
| Setting | Value | Where it comes from |
|---|---|---|
max_pool_percent |
30 | D The kernel's own default is 20, and nixram used to leave it there untouched. It is now adapted to match the one real machine that actually runs zswap in production, which runs 30 — treating the pool as a hot cache that should churn on bursty activity rather than a conservative reservation. Directed: the real deployment's own value, not the untested upstream default. |
accept_threshold_percent |
90 | C Upstream default. Once the pool fills to its ceiling it stops accepting pages, and must drain back to 90% of that ceiling before it resumes. This is what stops it flapping on and off right at the boundary. |
zpool |
zsmalloc |
Hardcoded, no selector offered. z3fold and zbud have been removed from current kernels, so a selector would only offer dead configuration. |
| Compressor | zstd |
Hardcoded. Independent choice from zram's primary (which is lz4 at most levels): zswap has no recompression pass to hand density off to — each pool is bound to one compressor for its whole lifetime — so its one compressor has to do that job alone, the same reason 256M/512M/1G's zram primary is zstd rather than lz4. |
| Shrinker | on | E Upstream ships it off. nixram turns it on. It proactively writes cold zswap pages back to the real disk swap under pressure, rather than waiting for the pool to fill and block. A reasoned deviation, not a cited recommendation. Requires Linux 6.8 or newer. Unrecognised kernel parameters are ignored with a log warning on older kernels, so passing it is always safe. |
vm.swappiness |
25 | D No longer a reasoned midpoint — the maintainer's own stated figure, sourced from the one machine that actually runs zswap in production (a mixed workload that needs anonymous memory to stay resident rather than get evicted to a disk-backed cache). An earlier version of this profile used 120, a reasoned midpoint between the plain-disk kernel default (60) and zram's eager tiers' value at the time (130), never checked against any real deployment. Naming the real box directly — "the only zswap box is the reference laptop" — replaced it with 25, this project's own real, sourced data point. The original argument for that number was that a zswap cache miss is a real disk read, worse than anything zram faces, so zswap should be more reluctant than zram's reluctant tiers — which at the time rested at a flat 60. That comparison point is gone: zram's reluctant tiers now rest at 10 (see above), so 25 sits numerically above zram's resting floor, not below it. This is stated as an honest, unresolved tension rather than patched by inventing a lower number: 25 remains this project's own real, measured production value regardless of what zram's resting value does. |
vm.page-cluster |
2 on SSD; untouched on HDD | C Pop!_OS makes this distinction. Note that nixram does not set 3 for a spinning disk — it sets nothing at all and leaves the kernel's own default standing. |
vm.watermark_scale_factor |
50, flat at every RAM size | D Not the zram table's taper, and no longer Pop!_OS's own flat 125 either — that was a plausible-sounding substitute for a laptop/desktop's shorter-lived pressure pattern, reasoned but never actually checked against this project's own real zswap box. What the reference laptop actually ran in production was 100, and a real incident showed that number amplifying a reclaim feedback loop under CPU contention; asked directly, the corrected production value is half of that, 50. 50 is this project's own real, incident-tested data point. |
| Watchdog pressure duration | 3 s (limit stays the shared 60%) | D The zram table's watchdog uses the shared, sourced 60%/30s systemd-oomd defaults ([Shared settings] above) at every level. mode = "zswap" overrides the duration only, to 3 seconds, matching the real production oomd config on the one machine that runs zswap — tied to a heavy, bursty compute workload where waiting the full 30 seconds is too slow. The pressure limit percentage (60%) is unchanged; only the duration differs for zswap. |
mode = "none"
Leaves swappiness and read-ahead untouched entirely, because there is no managed swap medium to have an opinion about. Still applies the reclaim watermarks, the MGLRU setting, the watchdog, and the protected-unit layers.
Overriding any of it
Every sysctl assignment uses mkDefault, so your own configuration can override any single one by simply assigning it — no lib.mkForce needed. sysctls.enable = false turns the whole kernel-settings layer off while keeping the swap device and watchdog wiring intact.
Option reference
All under nixram.
| Option | Type | Default | Notes |
|---|---|---|---|
enable | bool | false |
Turns the module on. All configuration is gated on it. |
level | null or enum | null |
One of 256M, 512M, 1G, 2G, 4G, 6G, 8G, 10G, 12G, 16G, 24G, 32G, 64G, 128G — fourteen anchors. No default by design. Leaving it unset while enabled is a hard build error with a message pointing at the fix, not a silent fallback. |
mode | enum | "zram" |
"zram", "zswap" or "none". Exactly three values, so zram and zswap cannot be combined by accident. "zswap" asserts that config.swapDevices is not empty. |
zram.sizing | enum | "both" |
"virtual" sets only the ceiling. "physical" sets only the budget, and the ceiling falls back to zram-generator's own default of min(ram / 2, 4096). "both" sets both keys. |
zram.diskSizeOverride | null or string | null |
Overrides the level's ceiling expression. Uses zram-generator's expression syntax, for example "ram" or "min(ram / 2, 8192)". Still not disk space — the name follows the upstream key. |
zram.residentLimitOverride | null or string | null |
Overrides the level's budget expression. "0" means unlimited. |
zram.priorityOverride | null or int (-1 to 32767) | null |
When null, the effective value is the level's priority, which is 100 at every level. |
zram.compressionAlgorithmOverride | null or string | null |
Escape hatch: override the level's primary (synchronous, write-path) compression algorithm. Level defaults: zstd(level=3) with no recompression at 256M/512M/1G; lz4 paired with zstd(level=3) recompression from 2G up. Rarely needed — mainly for a box too CPU-starved even for zstd(level=3), flipped to lz4 with recompressionTimer.enable turned on to recover the density later. |
zram.recompressionAlgorithmOverride | null or string | null |
The second, slower compression setting. Takes a zram algorithm expression. |
zram.recompressionTimer.enable | bool | from the level: false at 256M/512M/1G, true everywhere else |
Ships the two-phase systemd timer and its oneshot service. |
zram.recompressionTimer.onCalendar | string | "*:0/15" (every 15 minutes) |
A systemd OnCalendar expression for how often the timer checks whether to act, not how often it actually recompresses — each firing reads CPU PSI first and does nothing unless the box is genuinely idle right now. The 15-minute check frequency itself is an explicitly unvalidated starting point, not a measured optimum. |
zram.swappinessRelief.enable | bool | from the level: false at 256M/512M/1G, true everywhere else |
Ships the PSI-gated timer and oneshot service that temporarily raises vm.swappiness above the reluctant tiers' low resting value during genuine, sustained memory pressure. See Shared settings. |
zram.swappinessRelief.reliefValue | int 0–200 | 60 |
vm.swappiness applied while relief is active — the plain kernel default, and this project's own former reluctant-tier resting value, reused as a deliberate anchor for "behave like an ordinary, untuned system under real pressure." |
zram.swappinessRelief.pressureHighThreshold | int 1–100 | 10 |
Memory PSI "some" line's avg10 (percent). At or above this, the box enters relief on the next check. |
zram.swappinessRelief.pressureLowThreshold | int 0–100 | 1 |
Memory PSI "some" line's avg60 (percent). Once in relief, the box only returns to baseline once this slower average drops below the threshold — deliberately harder to satisfy than entry, so a brief lull doesn't end relief early. |
zram.swappinessRelief.checkIntervalSec | positive int | 30 |
How often the relief-valve timer checks memory PSI. Faster than the 15-minute recompression check — this needs to react to real pressure within seconds, not wait for a slow poll. |
zswap.maxPoolPercent | int 1–100 | 30 |
The kernel's own default is 20; nixram raises it to match the one real machine that runs zswap in production. Directed, not the untested upstream value. |
zswap.acceptThresholdPercent | int 1–100 | 90 |
Upstream default, as a percentage of the pool ceiling. |
zswap.shrinkerEnabled | bool | true |
Upstream default is off. Requires Linux 6.8 or newer. It is harmlessly ignored on older kernels. |
zswap.diskMedium | enum | "ssd" |
"ssd" sets vm.page-cluster = 2. "hdd" sets nothing and leaves the kernel default standing. |
oomd.enable | bool | from the level: false at 256M, true everywhere else |
Arms the pressure thresholds on -.slice and user.slice. Also sets systemd.oomd.enable through mkDefault, in both directions. |
oomd.protectedUnits | list of string | [ "sshd.service" ] |
Applied at every level regardless of oomd.enable. Sets both ManagedOOMPreference = "omit" and OOMScoreAdjust = -900. The .service suffix is normalised away. Naming a service that does not exist materialises a skeleton unit. |
sysctls.enable | bool | true |
false disables the whole kernel-settings and MGLRU layer while keeping the zram/zswap and watchdog wiring. |
minFreeKbytesOverride | null or positive int | null |
An escape hatch only. No level sets vm.min_free_kbytes. |
Also part of the surface
- Flake outputs:
nixosModules.nixramandnixosModules.default, thedetect-levelapp, andchecks. - Supported systems:
x86_64-linuxandaarch64-linux.
Not exposed as options
Some values are level data or hardcoded rather than user-facing: zswap.zpool and zswap.compressor (hardcoded), and the watchdog's pressure limit and duration (level data for mode = "zram": 60% for 30 s everywhere; mode = "zswap" keeps the 60% limit but overrides the duration to 3 s, matching the real production oomd config on the one machine that runs zswap).
The kernel settings themselves — vm.swappiness, vm.page-cluster, the watermark pair and MGLRU's dwell time — have no nixram option either, but all use mkDefault, so you override them directly from your own configuration instead.
One implementation note
Because level has no default, no part of the module ever indexes the level table with it directly. Everything goes through an internal value that falls back to an arbitrary valid level when it is null. A real user never sees that fallback. It exists only so that forcing unrelated parts of your configuration cannot crash evaluation before the friendly assertion gets a chance to speak.
Security
nixram runs no network service and opens no port. What it does affect is which processes survive when the machine runs out of memory, so these are the concrete guarantees:
- Your SSH daemon is protected by two independent layers, at every level, whether or not the watchdog is enabled.
ManagedOOMPreference = "omit"tells the userspace watchdog to skip it;OOMScoreAdjust = -900tells the kernel's own killer to pick it last. The second keeps working when the first is off, absent, or too slow. - The watchdog fires on stall time, not on a percentage. A swap-used percentage measured against the generous ceiling would read "plenty of headroom" right up to the real wall, so those triggers are not configured at all.
- No daemon of nixram's own sits in the memory path. The only unit it ships is a oneshot that runs on a timer at the lowest CPU and I/O priority and then exits.
- Nothing is silently guessed. Enabling the module without a level fails the build with a message. Choosing zswap without a real swap device fails the build with a message.
- The compressed pool is bounded by the kernel, through
zram-resident-limit, at every level with no exceptions — 64G and 128G included.
If you find a problem with any of this, open an issue at github.com/julian-corbet/nixram-corbet-ch/issues. There is no private disclosure channel, because there is no network surface to disclose against.
The judgement calls
What was chosen, what the alternative was, and what would change our mind.
Exceeding upstream's recommended sizing
zram-generator's own documentation recommends sizing zram to somewhere between 0.1 and 0.5 of RAM. nixram's ceiling deliberately exceeds that at every tier: plain ram, all of it, at 256M/512M/1G, and 75% of RAM from 2G all the way through 128G — a flat fraction, not a taper down to something smaller at the large end.
Why we think that is defensible. That guidance is written for setups where the ceiling is the only ceiling. nixram's default pairs it with a real memory budget that does the actual safety work. The catch: if you set zram.sizing = "virtual" you have stepped outside that net, and the 0.1–0.5 caution applies to you again in full.
Where the two flat fractions come from. The ceiling formula is the maintainer's own: "take the physical budget, multiply by pi, and take the nearest base-2-ish value" — the nearest 3-smooth number (only 2 and 3 as prime factors, the sizes RAM/VPS tiers actually ship in). Because the resident-limit budget behind it is a fixed percentage within each tier group (30%, 25%, or 20%), this reduces exactly to two flat fractions: 1.0 at the 30%-budget tiers, 0.75 at the 25%- and 20%-budget tiers. See the level table for the full derivation and the maintainer's own worked examples it was checked against.
What would change our mind: evidence that a generous ceiling causes harm even with a budget enforced behind it.
Reversed: the budget used to be unset at 64G and above
An earlier version of this design left the budget unset above 32G, reasoning that at those anchor sizes the ceiling was already small enough that a second cap looked like decoration. That reasoning was wrong: it conflated a memory-safety argument (is the ceiling already small enough) with what the budget actually bounds (how much RAM may be mid-compression-cycle at once, a CPU-tax question, not a redundant safety net). It also meant a machine that rounded up into 64G had no physical cap at all, regardless of the ceiling's own size.
Current state: every tier sets a real budget, tapering 30% (256M-1G) → 25% (2G-16G) → 20% (24G-128G). Only the two endpoints of that taper (30% at the small end, 20% at roughly 128G) are the maintainer's own stated figures; the 25% band in between, and specifically where the step down to 20% begins (24G rather than 32G or 64G), are this project's own extrapolated placement connecting those two points, not independently confirmed.
The watchdog is off at 256M
The daemon carries its own memory cost, which is a meaningfully larger fraction of a 256 MiB machine than of any larger tier. What this gives up: the early-warning layer. It does not disable the kernel's own out-of-memory killer, and the protected-unit layer still applies.
What would change our mind: a measurement of that cost. It is currently unmeasured, which is precisely why the choice is marked as our own reasoning rather than cited. Note that the reverse choice — arming the watchdog at every larger tier — is equally our own reasoning. What is cited is the threshold, not the on/off decision.
The recompression setting, and why 256M/512M/1G skip it entirely
256M, 512M, and 1G all use a dense zstd(level=3) primary with no recompression pass at all — the maintainer's own direct instruction: "everything up to a GB goes to zstd primary and done." 2G through 128G pair a cheap lz4 primary with a zstd(level=3) recompression pass instead. This is a firm pairing rule, not a size threshold: whenever the primary is already zstd(level=3), recompression is off, full stop; whenever it isn't, recompression runs behind it.
A correction worth stating plainly, not glossing over: an earlier version of this design gave 256M/512M the lz4+recompression architecture instead, reasoning that the small tiers must "lean on zram willingly" and needed a cheap primary. That over-applied a separate, much narrower instruction — a lz4-then-recompress path reserved as an uncertain exception for the weakest possible hardware, not the default for the whole small-tier band. It was a real implementation mistake, caught and reverted: 256M through 1G all share one shape now.
Where a recompression pass does run, the setting is zstd(level=3) — the same setting used as the small tiers' own primary, reused rather than a separate, denser exotic level. An earlier version of this design used zstd(level=12) here, reasoning that idle, off-the-hot-path pages could justify spending far more CPU per page. That reasoning wasn't wrong about level 12 being affordable in isolation, but it treated the recompression level as its own separate design question, when the simpler, equally defensible policy is to reuse the one dense setting already measured correct for the small tiers' primary (experiment 006: real density gain over level 1, well short of the level-6 cost cliff) — one dense reference point in the whole system, not two. Whether zstd(level=3) recovers as much absolute density as level=12 did on genuinely idle pages specifically is still open, tunable via zram.recompressionAlgorithmOverride if a future measurement argues for going denser. Never step down to level=1 as a fallback.
Why 256M-1G differ from a ~128G server, despite both containing "reluctant" tiers: the real distinguishing variable turned out not to be true-RAM headroom at all, but workload compute-boundedness, per the maintainer directly: "with 1GB RAM, you need to get whatever you can. With the big box you can wait for it later. The bigger box is also more compute bound than the smaller one." A 256M-1G-class box sees light, few-user usage with no heavy concurrent demand competing for its CPU, so paying zstd's cost synchronously, on the compress path, costs nothing that's actually needed elsewhere — and because these boxes are genuinely RAM-starved, grabbing whatever density is available immediately beats waiting for an idle window. A ~128G server runs actively compute-bound workloads — LLMs, genAI, many concurrent apps — competing hard for the very CPU a dense primary would consume, so the cheap primary protects that live demand and the expensive recompression pass is deferred to genuine idle time instead, which a machine at that scale reliably has. Placing the actual architecture boundary at 1G/2G specifically, rather than describing only the two ends the maintainer gave worked examples for, is this project's own inference — plausible, not independently confirmed tier by tier.
The two-phase recompression timer
One run repacks whatever the previous run marked as untouched and that has stayed untouched since. Then it marks the current set for the next run. Mechanically: phase one writes type=idle to the recompression control, phase two writes all to the idle control.
The alternative was a single-phase design, which would recompress the entire device every run — everything looks untouched in the instant right after being marked. The kernel clears a page's untouched flag the moment it is written again, which is what makes the dwell period real.
The timer itself fires often — every 15 minutes by default — but that's a check frequency, not a run frequency: each firing reads CPU PSI's "some" line first and only does the mark/recompress work if the last 10 seconds show genuine idleness (avg10 below 10%), otherwise it logs a line and waits for the next tick. Cadence is "whenever there is idle time," not a fixed calendar interval, on purpose: a fixed daily run either forces this work into contention on a box that's busy at exactly that moment, or wastes a box's several real idle windows by only ever taking one of them. A box under sustained, genuine load may simply never get a window to recompress at all — correct behaviour for a job whose entire premise is "only touch this when nothing else needs the CPU," not a bug.
The service runs at the lowest scheduling priority for both CPU and I/O, deliberately kept out of the way of the exact symptom it exists to prevent. The timer is persistent, so a missed run catches up.
Requirements: Linux 6.2 or newer with CONFIG_ZRAM_MULTI_COMP. The script checks for the kernel control file first. If it is absent it logs one explanatory line and exits cleanly — it does not fail the timer or the boot.
Recompression stays on at 64G and 128G
Machines at this scale are the ones the compute-boundedness reasoning above describes directly — LLMs, genAI, and many concurrent apps competing for CPU — so the cheap-primary-plus-deferred-recompression shape is the intended fit here, not just consistency for its own sake. The marginal value is still genuinely modest once the physical resident-limit budget (20% of RAM) is already large in absolute terms, even though the ceiling itself keeps scaling with RAM (75%, same as every other tier from 2G up) rather than sitting at a fixed cap. For a machine running one big workload that does not swap usefully — not actually compute-bound in the sense above — the documented alternative is mode = "none".
Non-goals
- No build-time RAM auto-detection. Nix builds cannot read the target machine's live memory, so the level is a fact you detect once and commit, like a disk UUID.
- No stacking of zram and zswap. That means compressing the same page twice, and nobody has published a measurement showing it helps.
- No swap-percentage watchdog triggers anywhere.
- No universal
vm.min_free_kbytesformula. - Not a container or cgroup memory manager. Host level only.
Open questions
Six experiments are defined in the repository. Four remain open, below. The other two — whether lz4 or lzo-rle belongs in the fast-primary slot, and whether 256M's primary should run zstd(level=1) or zstd(level=3) — have already run and closed, and their results are already folded into the table and the reasoning above: lz4 won as the uniform fast primary on every tier that also runs zstd recompression, and zstd(level=3) won as 256M/512M/1G's shared primary. Results from the remaining four feed back into the table as marker upgrades or corrections.
| # | Question | What it decides |
|---|---|---|
| 001 | How much memory does systemd-oomd itself occupy on a 256 MiB machine? | Whether the watchdog should stay off at the smallest tier. |
| 002 | The recompression timer now checks every 15 minutes and only acts when CPU PSI shows the box genuinely idle, rather than running on a fixed schedule — that mechanism is settled. What's still open is the check-interval number itself: does 15 minutes strike the right balance between catching idle windows and needless polling overhead? | The default onCalendar check frequency, measured as bytes recovered per CPU-second across a range of intervals. |
| 003 | The resting swappiness values themselves are now settled — 120 at the eager tiers (256M/512M/1G), 10 at the reluctant tiers (2G–128G) — both the maintainer's own stated figures, not open questions anymore. What's new and still unvalidated is the PSI-gated relief valve built around that 10: do the default thresholds (raise to 60 once avg10 ≥ 10%, return to baseline only once avg60 < 1%, checked every 30 s) actually track a genuine overflow event well, or would different numbers respond faster or avoid false triggers? | Whether zram.swappinessRelief's default thresholds need retuning, and by how much. |
| 004 | Recompression now defaults to zstd(level=3), reusing the small tiers' own primary setting (down from an earlier zstd(level=12)). Would a denser level recover meaningfully more absolute density specifically on genuinely idle, off-path pages? | Whether the recompression setting should ever step up from level 3, and by how much. |
Being visibly unfinished where the work is unfinished is the point. A value marked as our own reasoning is not a value we are hiding — it is one we have not yet earned the right to call settled.
Glossary
Every term used on this page, in plain words. The two mechanisms and the two limits are defined near the top, where you need them first.
Tools and modules
- zram-generator
- The upstream systemd tool that actually creates and configures the zram device. nixram writes its configuration rather than implementing a device of its own.
zramSwap(the built-in NixOS module)- The older NixOS option set for zram. nixram deliberately does not use it. It can only set the virtual size and has no concept of a real memory budget, which is the mechanism nixram's whole model depends on. nixpkgs itself documents zram-generator as the intended successor.
What the kernel is doing
- page
- The fixed-size chunk the kernel moves memory around in. On the machines nixram targets it is 4 KiB.
- reclaim
- The kernel taking memory back from one thing so it can give it to another. Background reclaim is the kernel doing this ahead of time, quietly, before anything is actually blocked.
- page fault
- What happens when a process touches a page of memory that is not currently in RAM and the kernel has to fetch it back.
- thrashing
- The machine spending so much of its time moving pages in and out that useful work effectively stops.
- OOM / the OOM killer
- Out of memory. The kernel's last-resort response to running out: it picks a process and kills it. It has no idea which process you cared about.
- systemd-oomd
- An out-of-memory watchdog running in userspace. It watches for trouble and acts before the kernel's last-resort killer has to, which means the choice of what dies is made with more information and less panic.
- PSI / pressure stall information
- The kernel's own measurement of how long processes actually sat blocked waiting on memory. Stall time, in other words. It is a direct measure of the symptom you care about, unlike a percentage of some capacity.
- cgroup / slice /
-.slice/user.slice - The systemd hierarchy that resources are accounted against.
-.sliceis the root, covering the whole system.user.sliceis the parent of every logged-in user's session. These are the two places nixram arms the watchdog.
The kernel settings nixram touches
- sysctl
- Kernel settings that can be changed at runtime. The memory ones are all named
vm.something. vm.swappiness- How eagerly the kernel moves pages to swap rather than dropping cached file data instead. Higher means swap sooner. The range now goes to 200, not 100 — the kernel explicitly sanctions values above 100 where swapping beats re-reading from disk.
vm.page-cluster- How many pages the kernel reads ahead when one swapped page is faulted back in. Its entire justification is amortising a disk seek across several pages at once.
vm.watermark_scale_factor- How much earlier the kernel starts reclaiming in the background, measured against a memory zone's free-page thresholds. Higher means start earlier and keep more headroom.
vm.watermark_boost_factor- A related setting that temporarily raises those thresholds after memory fragmentation events. nixram sets it to 0.
vm.min_free_kbytes- How much RAM the kernel keeps permanently in reserve. nixram deliberately does not touch this one.
- MGLRU / multi-generational LRU
- A newer kernel algorithm for deciding which pages are cold. It sorts pages into generations by age rather than one long list.
min_ttl_ms- A minimum dwell time before MGLRU is allowed to act on a generation of pages. It exists to stop the machine thrashing on pages that only just cooled down. It is not a sysctl. It lives in a kernel control file.
- systemd-tmpfiles
wrule - The mechanism nixram uses to write that one value, because it has no sysctl name to be set through.
boot.kernelParams- Kernel command-line settings, fixed at boot. This matters in exactly one place: zswap is configured through them, so zswap changes only take effect after a reboot.
mem_limit- The kernel control file that
zram-resident-limitwrites to. Only relevant if you are reading kernel documentation alongside this page.
Compression
- zstd
- The compression algorithm nixram uses everywhere. The fact that matters here: unpacking is essentially as fast no matter how hard the data was packed.
- compression vs recompression
- Compression happens on the way in, in the kernel path, while a process waits for the memory. It has to be fast. Recompression happens later, on a schedule, over pages nothing has touched, with nothing waiting on it. It can afford to be slow.
CONFIG_ZRAM_MULTI_COMP- The kernel build option that lets zram hold a second, slower compression setting alongside the fast one. Recompression needs it, plus Linux 6.2 or newer.
- long-range matching / optimal parser
- The techniques high zstd settings spend their extra time on. Both need a lot of surrounding data to pay off. A 4 KiB page does not give them much to work with, which is why the gain from a denser setting is modest here.
- sysfs /
/sys/block/zramN/ - The kernel control files for a zram device. The recompression timer writes to two of them: one marks pages as untouched, the other tells the kernel to repack the marked ones.
zswap internals
max_pool_percent- The share of RAM the zswap cache is allowed to occupy.
accept_threshold_percent- Once the cache has filled up and stopped accepting pages, this is how far it must drain before it starts accepting again. It exists so the cache does not flap on and off right at the boundary.
- shrinker
- A zswap feature that pushes cold compressed pages out to the real disk swap early, rather than waiting for the cache to fill up and block.
- zpool / zsmalloc / z3fold / zbud
- The allocator zswap packs compressed pages into. Only zsmalloc still exists in current kernels. The other two have been removed, so there is no choice left to make and nixram offers none.
Nix terms
- eval-time assertion
- A build-time check that fails with a written message instead of producing a broken system.
mkDefault/mkForce- Nix module-system priorities. Values nixram sets with
mkDefaultcan be overridden from your own configuration by simply assigning them, with no ceremony. - anchor level
- nixram's own word for one of the fourteen RAM sizes in its table. Your machine is assigned to the nearest anchor at or above its real RAM.
- the taper
- nixram's own word for how the
zram-resident-limitbudget steps down as RAM grows: 30% at 256M-1G, 25% at 2G-16G, 20% at 24G-128G. The ceiling itself no longer tapers — it is two flat percentages, not a curve.
Contributing
nixram is MIT licensed. Corrections to the sourcing are as welcome as code — if a value here is attributed to the wrong place, that is a bug.
git clone https://github.com/julian-corbet/nixram-corbet-ch
cd nixram-corbet-ch
nix flake check
Experiment results are especially welcome. If you have run any of the four open questions above on real hardware, the numbers are worth more than the reasoning they would replace.