KSM Memory Deduplication for MicroVMs — And Why It's a Trap
Look at a host running eighty near-identical microVMs and the waste is obvious. Eighty guests, same kernel text, same libc, same warmed-up Node runtime — and the host is dutifully storing eighty physical copies. Linux has had an answer since 2.6.32: KSM, Kernel Samepage Merging. A kernel thread walks memory you volunteered, hashes pages, finds byte-identical ones, and collapses them into a single write-protected page everybody points at. Free RAM, discovered by brute force. It was written for KVM, it ships in every mainstream distro kernel, and it is one sysfs write away from running on your fleet.
It is also, for a multi-tenant Firecracker fleet, mostly a trap — not because it fails, but because of what it charges you, what it hands back the moment a guest writes, and what it leaks across a tenant boundary while it works. This post is the mechanics first: MADV_MERGEABLE, the ksmd scan loop, the unstable and stable red-black trees, the sysfs knobs and the newer per-process accounting and CPU advisor. Then the four reasons a security-first microVM platform should think hard before enabling it. Then the better answer: stop manufacturing duplicate pages, so there is nothing left to scan for.
What KSM actually is
KSM is a memory-deduplication scanner living in the kernel's memory-management layer. It does not scan all of RAM — that would be wasteful and a security disaster. It scans only regions a process explicitly volunteered by calling madvise() with MADV_MERGEABLE. That opt-in is why KSM is safe enough to ship at all: a VMM like QEMU marks its guest RAM allocation mergeable, and from then on ksmd may look at those pages and nothing else.
Two eligibility constraints matter more than people expect. First, KSM only merges anonymous pages — file-backed pages are the page cache's business, and it already shares them by inode and offset for free. Second, opting out is not symmetric with opting in: MADV_UNMERGEABLE must walk the range and break every merge it finds, allocating a private copy per merged page. It can take a long time and it can fail with ENOMEM. On a host you packed because KSM was saving you memory, turning KSM off is a memory-spike event, not a config change.
/* KSM never touches memory you did not volunteer. A VMM opts its guest RAM in: */
if (madvise(guest_ram, guest_ram_size, MADV_MERGEABLE) != 0)
perror("madvise(MADV_MERGEABLE)");
/* EINVAL if the kernel was built without CONFIG_KSM. Note the quieter failure
* mode: unsuitable VMAs (shared, hugetlb, IO/PFN mappings) are simply not
* marked mergeable -- the call can succeed while nothing becomes a candidate.
* Check the counters, not the return value. */
/* Two things to internalize about what is eligible:
* 1. Only ANONYMOUS pages are merge candidates. File-backed pages are the
* page cache's job and are already shared by inode+offset, for free.
* 2. Opting back out is expensive and can fail: */
madvise(guest_ram, guest_ram_size, MADV_UNMERGEABLE);
/* ... this UNMERGES every merged page in the range, allocating a fresh
* private copy for each. Slow, and it can return ENOMEM. "Just disable
* KSM" on a densely packed host is a memory spike, not a toggle. */
/* Newer kernels (6.4+) can flip it process-wide with no VMA bookkeeping, which
* is how a supervisor -- or a recent systemd unit knob -- enables it for a
* whole service without patching the VMM: */
prctl(PR_SET_MEMORY_MERGE, 1, 0, 0, 0);ksmd, the unstable tree, and the stable tree
The work is done by one kernel thread, ksmd. It wakes, examines pages_to_scan pages from the registered areas, sleeps for sleep_millisecs, and repeats — forever, whether or not anything is left to merge. That loop is the entire cost model: KSM's CPU consumption is a function of how much memory you asked it to watch and how fast you told it to walk, not of how much it finds.
For each candidate, ksmd consults two red-black trees ordered by page content. The stable tree holds already-merged pages; they are write-protected and therefore immutable, which is what makes them safe as tree keys. A candidate that matches a stable node has its page-table entry redirected at the existing shared page, the duplicate is freed, and the sharer count goes up.
The unstable tree is the interesting one. It holds candidates that haven't matched yet, and its keys are still-writable pages, so the tree can silently go wrong when a page changes after insertion. KSM copes rather than prevents: it keeps a checksum from the previous pass and skips pages whose checksum moved, then rebuilds the unstable tree from scratch every full scan. Pages that keep mutating land in pages_volatile and quietly waste the scanner's time. The bookkeeping isn't free either — KSM allocates an rmap_item per tracked page, a standing memory overhead incurred before it merges anything.
The knobs: /sys/kernel/mm/ksm and the newer advisor
Everything KSM does is steered from sysfs, and the counters there are the only honest source of truth about whether it's earning its keep on your workload.
# KSM is off by default on most distros. Everything lives in sysfs.
$ ls /sys/kernel/mm/ksm/
full_scans max_page_sharing merge_across_nodes pages_shared pages_sharing
pages_to_scan pages_unshared pages_volatile run sleep_millisecs ...
# The three knobs that decide how hard ksmd works:
$ cat /sys/kernel/mm/ksm/run # 0 = off | 1 = scan+merge | 2 = stop AND unmerge
0
$ cat /sys/kernel/mm/ksm/pages_to_scan # pages examined per wakeup
100
$ cat /sys/kernel/mm/ksm/sleep_millisecs # nap between wakeups
20
# Scan rate ~= pages_to_scan / (sleep_millisecs / 1000) pages per second.
# 100 / 0.02 = 5,000 pages/s ~= 20 MiB/s of scanning. On a host holding hundreds
# of GiB of mergeable guest RAM, a full pass takes a long time. Turn the rate up
# and ksmd's CPU rises with it. There is no free scan.
# What it found (only meaningful with run=1 and some mergeable VMAs):
$ grep -H . /sys/kernel/mm/ksm/pages_*
pages_shared:0 # unique "master" pages held in the stable tree
pages_sharing:0 # duplicates reclaimed by pointing at a master <- the savings
pages_unshared:0 # candidates parked in the unstable tree, no match yet
pages_volatile:0 # pages changing too fast to be worth tracking <- wasted scanning
# NUMA footgun. Default 1 lets ksmd merge a page ACROSS nodes, so a guest pinned
# to node 0 can end up reading its "saved" page from node 1 for the rest of its
# life. The RAM chart improves; the latency chart does not.
$ cat /sys/kernel/mm/ksm/merge_across_nodes
1
# run=2 is not "off". It is "off, and undo everything", which allocates a private
# page for every merge you were relying on. Do not learn this during an incident.
$ echo 2 | sudo tee /sys/kernel/mm/ksm/runNewer kernels sand off some sharp edges. A smart-scan mode remembers pages that repeatedly failed to match and stops re-examining them. A KSM advisor auto-tunes pages_to_scan to hit a target scan time within a CPU budget, so instead of guessing a rate you declare how much CPU ksmd may burn. And per-process accounting under /proc/<pid>/ksm_stat reports a profit figure that subtracts KSM's own rmap_item overhead from the bytes merged. Which knobs and fields exist depends heavily on your kernel version — check what your host exposes rather than trusting a blog post, including this one.
What it actually buys on a fleet of identical guests
Now the fair part. On a host full of guests that cold-booted independently, KSM finds real duplicates and nothing else will: a hundred VMs that each ran their own init and warmed their own caches hold a hundred distinct physical copies of identical memory, and nothing in their mappings expresses that relationship. Only content-based scanning can discover it. How much it recovers depends on how homogeneous your guests are and how read-mostly their RAM is — anyone quoting a fixed percentage is quoting their workload, not yours. Measure both halves: pages_sharing and the profit counter on one side, the p99 of whatever you sell on the other.
Four reasons it's a poor fit for a Firecracker fleet
1. ksmd burns the CPU you're selling
A sandbox platform sells CPU time. ksmd is a kernel thread that consumes it continuously — hashing pages, walking red-black trees, rebuilding the unstable tree each cycle — competing for cores with the vCPU threads running customer code. Worse, cost is decoupled from benefit. A highly homogeneous fleet gives ksmd lots of merges and burns CPU; a heterogeneous one gives it almost nothing and burns the same CPU. You set the scan rate; the workload decides the yield.
It also lands as noise rather than as a clean tax. ksmd doesn't schedule around your latency-sensitive moments, and page-table and reverse-mapping manipulation isn't free of lock contention. Trading tail latency for RAM is a legitimate trade — but make it consciously, and measure the tail rather than the mean.
2. A merge is undone by the first write
Merged pages are write-protected. That is not an implementation detail, it is the safety property that makes merging correct: one physical page now stands in for N logical pages owned by N different processes, so nobody may modify it in place. The instant any owner writes, the CPU faults, the kernel allocates a fresh page, copies 4 KiB into it, re-points that owner's page-table entry at the private copy, and the write proceeds. Standard copy-on-write.
Notice the accounting. You paid CPU to find that merge, CPU to perform it, and memory to track it with an rmap_item — then one write threw the saving away and charged you a fault plus a page copy on top. If the page later returns to the same content, ksmd may merge it again, and around it goes. For guests actively churning a working set, KSM can spend real CPU to achieve approximately nothing, and pages_volatile is the counter quietly telling you so.
3. Cross-VM dedup is a documented side channel
This is the one that should settle it for anyone running untrusted tenants, because the problem is structural rather than a bug someone can patch. A write to a merged page is measurably slower than a write to an unmerged one, because it takes a copy-on-write fault. That timing difference is an oracle: an attacker who writes a page of chosen content, waits for the scanner, and times a write to it learns whether some other process on the host holds a page with exactly that content.
Give that primitive to a hostile tenant and it composes. It is a co-residency detector and a fingerprinting tool: guess-and-check page-sized content to identify which OS, which library versions, which application a neighbour runs. It is a page-granularity disclosure oracle for anything brute-forceable a page at a time. And it is a placement primitive — dedup can make a victim's page and an attacker-controlled page become the same physical page, which academic work has chained with fault-injection techniques like Rowhammer. I'm describing the class rather than quoting figures, because the figures are workload- and hardware-specific and the class is what your threat model cares about.
The industry already voted. Hypervisor vendors that once shared identical pages across guests by default backed away, restricting sharing to within a single VM or switching it off, specifically in response to this research — and gave up the memory savings to do it. If your isolation story is 'each tenant gets a hardware-virtualized microVM with its own kernel', deliberately merging their physical pages to save RAM works against the thing you are selling.
4. It cuts against Firecracker's own grain
Firecracker's design premise is a minimal VMM with a deliberately small attack surface, run on a host hardened against microarchitectural and side-channel leakage — the project's production-host guidance reads as a list of mitigations to enable, not sharing features to turn on. Nothing in that posture wants a host-wide daemon merging memory across VM boundaries. And Firecracker doesn't need one for density: its answer to 'many identical guests' is the snapshot, which shares memory at the source instead of reconstructing the sharing afterwards.
There's a mechanical incompatibility too, and it's sharp. KSM works on 4 KiB anonymous pages. Hugetlbfs-backed guest memory is not a merge candidate at all, and transparent hugepages must be split back into base pages before merging — so per region you choose between the TLB win of 2 MiB pages and the dedup win of merging. On PandaStack the hugepage path is deliberate: one fault covers 2 MiB instead of 4 KiB, 512x fewer faults on a restore path where fault count dominates. Handing that back to a scanner is an odd trade.
The better answer: share at the source
Here's the reframe that makes KSM look less like a missing feature and more like a workaround. KSM exists to solve a problem you created: guests that manufactured identical pages independently, throwing away the fact that they were identical, so that a scanner has to rediscover it by brute force. If the guests never manufacture the duplicates, there's nothing to scan for.
That is what snapshot restore does. PandaStack keeps no warm pool — every create restores a baked Firecracker snapshot whose memory image is mapped MAP_PRIVATE. Private on write, shared on read: every guest restored from that image reads the common pages out of one physical copy and diverges only as it writes. The sharing is structural. It exists from the first guest instruction, no scanner found it, it costs zero ongoing CPU, and it never crosses a tenant boundary — each guest writes to its own private copies over a shared read-only baseline, rather than being merged into its neighbours.
The same idea repeats at two more layers. The rootfs is a shared template file plus reflink copy-on-write clones, so identical blocks are one copy in the page cache across every sandbox reading them. And on the UFFD streaming path, guest memory arrives on demand in 4 MiB chunks through a shared per-host chunk cache — the first restore pays the fetch, every later restore of that seed reads local chunks. One principle, three layers: share what everyone came from.
KSM asks: which of these pages happen to be identical? Snapshot restore asks: why did we make copies of a page nobody has written? The second question is cheaper to answer, and you only have to answer it once.
The sharing mechanisms, side by side
Every one of these reduces host RAM. They differ in how the sharing arises, what it costs continuously, and whether it puts anything across a tenant boundary:
- KSM (kernel samepage merging) — how it shares: a kernel thread scans volunteered anonymous pages and merges byte-identical ones into one write-protected page. CPU cost: continuous, and proportional to how much memory you asked it to watch rather than to what it finds. Side-channel exposure: high — cross-process merging is the primitive behind dedup timing attacks. Caveat: a merge dies on the first write, and hugepages aren't candidates at all.
- MAP_PRIVATE snapshot memory file — how it shares: every guest maps the same baked memory image copy-on-write, so identical pages are one physical copy from the first instruction. CPU cost: zero ongoing; you pay once to bake the snapshot. Side-channel exposure: low — the shared backing is a read-only file, not a neighbour's live memory. Caveat: sharing shrinks exactly as fast as guests dirty their working set.
- Page-cache sharing of a common rootfs — how it shares: one template file read into the page cache once, with reflink clones sharing extents until a write. CPU cost: zero ongoing; the kernel shares by inode and offset. Side-channel exposure: low — the same file-backed sharing every Linux process relies on for libc. Caveat: covers file-backed reads only, and needs a reflink-capable filesystem.
- UFFD shared chunk cache — how it shares: chunks fetched from object storage are stored once per host per seed generation and reused by later restores of that seed. CPU cost: near zero after the first fetch — a local sparse-file read instead of a network round trip. Side-channel exposure: low — read-only snapshot content keyed by object identity. Caveat: the first restore on a cold host pays network latency, and the cache needs a disk budget with eviction.
- Hugepages (2 MiB guest memory) — how it shares: it doesn't dedup; it cuts page-table entries and fault count, so a restore touches memory 512x fewer times. CPU cost: negative in the good sense — it removes fault-handling work. Side-channel exposure: none of its own. Caveat: mutually exclusive with KSM for the same memory, and hugepage-ness is a snapshot property needing host sysctls and a re-bake.
- Balloon / free-page reporting — how it shares: nothing is shared; the guest returns pages it isn't using so the host can reallocate them. CPU cost: low, but it needs guest cooperation and a driver in the loop. Side-channel exposure: minimal, though inflation patterns leak coarse signal about guest memory pressure. Caveat: it reclaims free memory, not duplicate memory, and an aggressive balloon pushes a guest into swap or the OOM killer.
When KSM still makes sense
None of this makes KSM bad technology. It makes it badly matched to multi-tenant microVM hosting specifically. There is a real envelope where it's the right call, and it's worth stating precisely so you can check whether you're in it:
- There is no cross-tenant boundary on the host. One team, one trust domain, workloads that could already reach each other some other way. If every process belongs to the same principal, the dedup oracle tells an attacker nothing new.
- You are memory-bound, not CPU-bound. KSM converts spare CPU into RAM — a good trade only if you have idle cores and a waiting list for memory. If you sell CPU seconds, you are converting revenue into RAM.
- The guests have no common origin. Cold-booted VMs, long-lived heterogeneous workloads, machines never restored from a shared snapshot — these hold real duplicates that structural sharing cannot recover, and only a scanner will find them.
- The memory is read-mostly and long-lived. Merges survive only while nobody writes: big static caches and resident library text are ideal, churning working sets are not.
- You're not using hugepages for that memory, and you've measured the profit counter positive on your own workload. If you run it, keep merge_across_nodes at 0 so ksmd can't merge a page onto a remote NUMA node, use the advisor to cap its CPU, and remember that switching it off has to unmerge everything.
Measuring it honestly
If you evaluate KSM, evaluate both sides of the ledger in one experiment. The failure mode is reading pages_sharing, seeing a big number, and never checking what it cost.
# 1) Per-process accounting (kernel-version dependent; fields vary by release).
# This is the honest number for a single VMM process:
$ cat /proc/$(pgrep -n firecracker)/ksm_stat
ksm_rmap_items 1048576 # tracking structs KSM allocated for this process
ksm_merging_pages 20481 # pages of this process currently merged
ksm_process_profit 61865984 # bytes saved MINUS KSM's own bookkeeping overhead
# ksm_process_profit can go NEGATIVE: a large region that is scanned but never
# merges costs rmap_items and returns nothing. Check profit, not pages_sharing.
# 2) Price the scanner. ksmd is host CPU you are not selling to a vCPU thread.
$ ps -o pid,comm,%cpu,time -C ksmd
$ pidstat -p "$(pgrep -x ksmd)" 5 # watch it across a full scan cycle, under load
# 3) Watch for the merge/split treadmill: busy ksmd + flat pages_sharing +
# climbing pages_volatile means you are paying to merge pages that keep moving.
$ watch -n5 'grep -H . /sys/kernel/mm/ksm/pages_{sharing,shared,volatile} \
/sys/kernel/mm/ksm/full_scans'
# 4) The A/B that matters. Measure guest-visible tail latency, not just MemFree.
$ echo 1 | sudo tee /sys/kernel/mm/ksm/run # on
# ... run the real workload, record p50 AND p99 of the thing you sell ...
$ echo 2 | sudo tee /sys/kernel/mm/ksm/run # off AND unmerge -- expect an RSS spike
# ... same workload, same metrics, compare ...
# 5) For the structural alternative, the equivalent measurement is one grep --
# no daemon, no scan, no knobs:
$ grep -E 'Shared_Clean|Private_Dirty' /proc/$(pgrep -n firecracker)/smaps_rollup
Shared_Clean: 1521664 kB # shared with the template snapshot -- one physical copy
Private_Dirty: 74236 kB # what THIS guest actually dirtied since restoreThat last command is the whole post in two lines of output. Shared_Clean is memory the guest uses that the host pays for exactly once across every sibling from the same template; Private_Dirty is the honest per-guest bill. No scanner produced that split — the mmap flag did. And because a create is a snapshot restore rather than a boot (p50 179ms, p99 ~203ms, with only the first spawn of a fresh template paying the ~3s cold boot), every sandbox lands inside that shared baseline by default. Forks inherit it: 400-750ms same-host, 1.2-3.5s cross-host.
The takeaway
KSM is a genuinely clever answer to a question you should try not to ask. It finds duplicate pages by scanning for them: CPU forever, a merge that any write undoes, and — the disqualifying part for multi-tenant hosting — memory merged across a boundary you deployed a hypervisor to enforce, producing a timing oracle a decade of research has shown how to use. The kernel gives you good tools to bound that cost, down to a CPU-budget advisor, and the existence of those tools is itself a statement about the cost.
The alternative isn't a clever trick, it's a change of question. Don't manufacture duplicate pages and then hunt them: restore every guest from one baked snapshot mapped MAP_PRIVATE, back them with a shared rootfs in the page cache, and serve streamed memory from a shared per-host chunk cache. That is deduplication by construction — zero ongoing CPU, no cross-tenant merging, no dedup oracle. Keep KSM for homogeneous, trusted, memory-bound fleets whose guests have no common origin, and measure the profit counter before trusting it even there. PandaStack's core is open source under Apache-2.0.
Frequently asked questions
What is KSM and how does it merge memory?
KSM (Kernel Samepage Merging) is a Linux kernel feature that deduplicates memory by content. A process volunteers a region with madvise(MADV_MERGEABLE), and a kernel thread called ksmd scans those pages in the background — examining pages_to_scan pages per wakeup, then sleeping for sleep_millisecs. Candidates are compared against two content-ordered red-black trees: a stable tree of already-merged, write-protected pages, and an unstable tree of not-yet-matched candidates that is rebuilt each full scan. When two pages match, KSM points both owners at one write-protected page and frees the duplicate. Only anonymous pages are eligible; file-backed pages are already shared through the page cache.
Why is KSM a security risk for multi-tenant microVMs?
Because merged pages are write-protected, a write to one takes a copy-on-write fault and is measurably slower than a write to an unmerged page. That timing difference is an oracle: an attacker can write chosen content into a page, wait for the scanner, then time a write to learn whether another tenant on the host holds a page with identical content. That primitive supports co-residency detection, software and OS fingerprinting, page-granularity disclosure for brute-forceable content, and — chained with memory-corruption techniques — attacker-controlled physical page placement. It's a well-documented research area, and several hypervisor vendors restricted or disabled cross-guest page sharing in response.
Does KSM work with hugepages?
Not usefully. KSM operates on 4 KiB anonymous pages. Hugetlbfs-backed guest memory is not a merge candidate at all, and transparent hugepages have to be split back into base pages before their contents can be merged — so you're choosing between the TLB and fault-count benefits of 2 MiB pages and the dedup benefit of merging, for any given region. On a snapshot-restore platform that's usually an easy call: hugepages cut the number of page faults on restore by roughly 512× for the same memory, and fault count is what dominates that path. Enabling hugepages is also a snapshot property, so it requires a template re-bake, not just a runtime flag.
How do microVMs share memory without KSM?
By sharing at the source rather than deduplicating afterwards. Every sandbox restores from the same baked Firecracker snapshot, whose memory image is mapped MAP_PRIVATE — private on write, shared on read. Guests restored from one template read the common pages out of a single physical copy from their first instruction and only diverge as they write, one 4 KiB page at a time. The same principle covers the rootfs, shared as a template file in the page cache with reflink copy-on-write clones, and streamed memory, served from a shared per-host chunk cache. No scanner, no ongoing CPU cost, and no merging across tenant boundaries.
When should I actually enable KSM?
When there is no cross-tenant boundary on the host, you are memory-bound rather than CPU-bound, and your guests have no common origin — cold-booted, long-lived, heterogeneous workloads whose duplicate pages structural sharing cannot recover. Read-mostly memory helps, since merges survive only while nobody writes. If you enable it, set merge_across_nodes to 0 so ksmd cannot merge a page onto a remote NUMA node, use the advisor to cap its CPU, watch pages_volatile for a merge/split treadmill, and check the per-process profit counter, which subtracts KSM's own bookkeeping and can go negative. Remember that disabling it later must unmerge everything, which spikes memory.
Keep reading
- Shared pages and copy-on-write: packing microVMs densely — The density story KSM is trying to reach — reached structurally instead.
- How Firecracker restores guest memory: mmap and MAP_PRIVATE — The mapping that makes snapshot pages shared by construction.
- Side-channel attacks on multi-tenant compute, explained — Where memory-deduplication timing attacks sit in the wider class.
- Firecracker hugepages for guest memory — The 2 MiB path that KSM cannot merge — and why that's fine.
49ms p50 cold start. Fork, snapshot, and scale to zero.