Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions bench/ksm-memfd/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# KSM memfd probe

This probe checks whether `MADV_MERGEABLE` produces KSM sharing for
memfd-backed `MAP_SHARED` regions.

Why this exists: issue #5 asks for directed KSM hints for fork families.
For the v0.4 live-fork path the tempting implementation is to mark the
controller's memfd mapping mergeable. The Linux KSM documentation says
KSM targets private anonymous pages, though, so this benchmark keeps the
evidence next to the code before forkd grows a misleading hint.

## Environment

- Run from Docker with `--privileged`
- Host KSM sysfs writable at `/sys/kernel/mm/ksm`
- Probe command:

```bash
docker run --rm --privileged \
-v "$PWD:/work" -w /work \
python:3.12-slim \
bash -lc 'python bench/ksm-memfd/probe.py --pages 4096 --timeout 30'
```

The probe saves and restores `run`, `pages_to_scan`, and
`sleep_millisecs`. Before each case it writes `run=2` to unmerge any
existing KSM pages, waits for `pages_sharing=0`, then enables KSM and
waits for scans.

## Result

```text
anonymous-private
before={'pages_shared': 0, 'pages_sharing': 0, 'pages_unshared': 0, 'pages_volatile': 0, 'full_scans': 0}
after ={'pages_shared': 2, 'pages_sharing': 510, 'pages_unshared': 0, 'pages_volatile': 5069, 'full_scans': 2}
delta ={'pages_shared': 2, 'pages_sharing': 510, 'pages_unshared': 0, 'pages_volatile': 5069, 'full_scans': 2}
memfd-map-shared-two-fds
before={'pages_shared': 0, 'pages_sharing': 0, 'pages_unshared': 0, 'pages_volatile': 0, 'full_scans': 0}
after ={'pages_shared': 0, 'pages_sharing': 0, 'pages_unshared': 0, 'pages_volatile': 0, 'full_scans': 0}
delta ={'pages_shared': 0, 'pages_sharing': 0, 'pages_unshared': 0, 'pages_volatile': 0, 'full_scans': 0}
```

The control case confirms KSM is active and can merge identical
`MADV_MERGEABLE` anonymous private pages. The memfd `MAP_SHARED` case
does not move `pages_shared` or `pages_sharing`, so forkd should not add a
controller-side memfd `MADV_MERGEABLE` mapping as if it fixed #5.

The useful part of #5 that remains in this PR is the `forkd doctor` KSM
check. A future implementation should target a backing/mapping shape that
KSM actually scans, and should include this probe (or a forkd-specific
variant of it) in the evidence.
191 changes: 191 additions & 0 deletions bench/ksm-memfd/probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
Probe whether KSM merges MADV_MERGEABLE memfd MAP_SHARED mappings.

This is intentionally small and direct: it maps two same-sized regions,
fills both with identical page contents, marks the VMAs mergeable, then
waits for ksmd full scans and reports /sys/kernel/mm/ksm counters.

Run on a Linux host where the caller can write /sys/kernel/mm/ksm:

sudo python3 bench/ksm-memfd/probe.py
"""

import argparse
import ctypes
import multiprocessing as mp
import os
import signal
import time

libc = ctypes.CDLL(None, use_errno=True)

MADV_MERGEABLE = 12
MAP_PRIVATE = 0x02
MAP_ANONYMOUS = 0x20
MAP_SHARED = 0x01
PROT_READ = 0x1
PROT_WRITE = 0x2
MAP_FAILED = ctypes.c_void_p(-1).value

PAGE = os.sysconf("SC_PAGE_SIZE")
PATTERN = b"forkd-ksm-page".ljust(PAGE, b"x")
KSM = "/sys/kernel/mm/ksm"

libc.mmap.restype = ctypes.c_void_p
libc.mmap.argtypes = [
ctypes.c_void_p,
ctypes.c_size_t,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_long,
]
libc.madvise.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int]


def read_ksm():
out = {}
for name in [
"pages_shared",
"pages_sharing",
"pages_unshared",
"pages_volatile",
"full_scans",
]:
with open(f"{KSM}/{name}", "r", encoding="utf-8") as f:
out[name] = int(f.read().strip())
return out


def write_ksm(name, value):
with open(f"{KSM}/{name}", "w", encoding="utf-8") as f:
f.write(str(value))


def configure_ksm(pages):
saved = {}
for name in ["run", "pages_to_scan", "sleep_millisecs"]:
with open(f"{KSM}/{name}", "r", encoding="utf-8") as f:
saved[name] = f.read().strip()
write_ksm("run", 2)
wait_for(lambda counters: counters["pages_sharing"] == 0, 10)
write_ksm("run", 1)
write_ksm("pages_to_scan", max(pages * 4, 10000))
write_ksm("sleep_millisecs", 20)
return saved


def restore_ksm(saved):
for name, value in saved.items():
try:
write_ksm(name, value)
except OSError:
pass


def mmap_region(size, flags, fd=-1):
ptr = libc.mmap(None, size, PROT_READ | PROT_WRITE, flags, fd, 0)
if ptr == MAP_FAILED:
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
rc = libc.madvise(ctypes.c_void_p(ptr), size, MADV_MERGEABLE)
if rc != 0:
err = ctypes.get_errno()
raise OSError(err, f"madvise MADV_MERGEABLE: {os.strerror(err)}")
return ptr


def fill(ptr, pages):
for i in range(pages):
ctypes.memmove(ptr + i * PAGE, PATTERN, PAGE)


def child(kind, pages, ready):
size = pages * PAGE
if kind == "anonymous-private":
ptrs = [
mmap_region(size, MAP_PRIVATE | MAP_ANONYMOUS),
mmap_region(size, MAP_PRIVATE | MAP_ANONYMOUS),
]
elif kind == "memfd-map-shared-two-fds":
fds = [
os.memfd_create("forkd-ksm-a", os.MFD_CLOEXEC),
os.memfd_create("forkd-ksm-b", os.MFD_CLOEXEC),
]
for fd in fds:
os.ftruncate(fd, size)
ptrs = [mmap_region(size, MAP_SHARED, fd) for fd in fds]
else:
raise ValueError(kind)

for ptr in ptrs:
fill(ptr, pages)

ready.send(os.getpid())
signal.pause()


def wait_for(predicate, timeout):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = read_ksm()
if predicate(last):
return last
time.sleep(0.2)
return last or read_ksm()


def run_case(kind, pages, timeout):
write_ksm("run", 2)
wait_for(lambda counters: counters["pages_sharing"] == 0, timeout)
write_ksm("run", 1)
before = read_ksm()
parent, child_conn = mp.Pipe(duplex=False)
proc = mp.Process(target=child, args=(kind, pages, child_conn))
proc.start()
parent.recv()
if not proc.is_alive():
raise RuntimeError(f"{kind} child exited before KSM scan")
after = wait_for(
lambda counters: counters["full_scans"] >= before["full_scans"] + 2
and counters["pages_sharing"] > before["pages_sharing"],
timeout,
)
proc.terminate()
proc.join(timeout=5)
wait_for(
lambda counters: counters["pages_sharing"] <= before["pages_sharing"],
timeout,
)
return before, after


def delta(before, after):
return {k: after[k] - before[k] for k in before}


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--pages", type=int, default=4096)
parser.add_argument("--timeout", type=float, default=20.0)
args = parser.parse_args()

if not os.path.exists(f"{KSM}/run"):
raise SystemExit("KSM sysfs is not available")

saved = configure_ksm(args.pages)
try:
for kind in ["anonymous-private", "memfd-map-shared-two-fds"]:
before, after = run_case(kind, args.pages, args.timeout)
print(kind)
print(f" before={before}")
print(f" after ={after}")
print(f" delta ={delta(before, after)}")
finally:
restore_ksm(saved)


if __name__ == "__main__":
main()
104 changes: 104 additions & 0 deletions crates/forkd-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub fn run(daemon_url: &str, daemon_token: Option<String>) -> anyhow::Result<()>
// unprivileged_userfaultfd / runs an older kernel.
check_uffd_wp(),
check_memfd_create(),
check_ksm(),
check_hugepages(),
];

Expand Down Expand Up @@ -678,6 +679,77 @@ fn check_memfd_create() -> Check {
}
}

fn check_ksm() -> Check {
#[cfg(target_os = "linux")]
{
let run = match std::fs::read_to_string("/sys/kernel/mm/ksm/run") {
Ok(v) => v,
Err(e) => {
return Check::warn(
"ksm",
format!("read /sys/kernel/mm/ksm/run: {e}"),
"kernel samepage merging unavailable; KSM hints are ignored",
)
}
};
let pages_to_scan = match std::fs::read_to_string("/sys/kernel/mm/ksm/pages_to_scan") {
Ok(v) => v,
Err(e) => {
return Check::warn(
"ksm",
format!("read /sys/kernel/mm/ksm/pages_to_scan: {e}"),
"expected when CONFIG_KSM is enabled",
)
}
};
check_ksm_values(run.trim(), pages_to_scan.trim())
}
#[cfg(not(target_os = "linux"))]
{
Check::skip("ksm", "not Linux")
}
}

#[cfg(target_os = "linux")]
fn check_ksm_values(run: &str, pages_to_scan: &str) -> Check {
let run = match run.parse::<u64>() {
Ok(v) => v,
Err(_) => {
return Check::warn(
"ksm",
format!("run={run} (unparseable)"),
"expected /sys/kernel/mm/ksm/run to be 1",
)
}
};
let pages_to_scan = match pages_to_scan.parse::<u64>() {
Ok(v) => v,
Err(_) => {
return Check::warn(
"ksm",
format!("pages_to_scan={pages_to_scan} (unparseable)"),
"expected /sys/kernel/mm/ksm/pages_to_scan to be >0",
)
}
};

if run != 1 {
return Check::warn(
"ksm",
format!("run={run}, pages_to_scan={pages_to_scan}"),
"enable KSM: echo 1 | sudo tee /sys/kernel/mm/ksm/run",
);
}
if pages_to_scan == 0 {
return Check::warn(
"ksm",
"run=1 but pages_to_scan=0",
"raise the scan budget: echo 1000 | sudo tee /sys/kernel/mm/ksm/pages_to_scan",
);
}
Check::pass("ksm", format!("run=1, pages_to_scan={pages_to_scan}"))
}

fn check_hugepages() -> Check {
#[cfg(target_os = "linux")]
{
Expand Down Expand Up @@ -760,3 +832,35 @@ fn check_daemon(daemon_url: &str, token: Option<&str>) -> Check {
),
}
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;

#[test]
fn ksm_passes_when_enabled_and_scanning() {
let check = check_ksm_values("1", "1000");
assert!(check.status == Status::Pass);
}

#[test]
fn ksm_warns_when_disabled() {
let check = check_ksm_values("0", "1000");
assert!(check.status == Status::Warn);
assert!(check.detail.contains("run=0"));
}

#[test]
fn ksm_warns_on_unexpected_run_mode() {
let check = check_ksm_values("2", "1000");
assert!(check.status == Status::Warn);
assert!(check.detail.contains("run=2"));
}

#[test]
fn ksm_warns_when_scan_budget_is_zero() {
let check = check_ksm_values("1", "0");
assert!(check.status == Status::Warn);
assert!(check.detail.contains("pages_to_scan=0"));
}
}
2 changes: 1 addition & 1 deletion crates/forkd-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ enum Cmd {
Snapshot {
/// Name of the snapshot. Becomes `~/.local/share/forkd/snapshots/<tag>/`.
/// With `--from-sandbox`, leave unset to let the daemon generate
/// `branch-<sandbox-id>-<unix-ts>`.
/// `branch-<sandbox-id>-<unix-ts>-<hex-suffix>`.
#[arg(long)]
tag: Option<String>,
/// Branch a running sandbox instead of booting a fresh parent VM.
Expand Down
2 changes: 1 addition & 1 deletion crates/forkd-controller/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ pub enum SnapshotStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchSandboxRequest {
/// Optional tag for the new snapshot. When unset the controller
/// generates `branch-<source-id>-<unix-ts>`.
/// generates `branch-<source-id>-<unix-ts>-<hex-suffix>`.
#[serde(default)]
pub tag: Option<String>,
/// Phase 1a measurement hook: take a Diff snapshot in addition to
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Request:
```

- `tag` is optional. When unset the daemon generates
`branch-<source-id>-<unix-ts>`. Must match
`branch-<source-id>-<unix-ts>-<hex-suffix>`. Must match
`^[A-Za-z0-9_-]{1,64}$` (1–64 chars, ASCII alphanumeric plus `-`/`_`).
- `mode` (v0.4+) is one of `"full"`, `"diff"`, `"live"`. Defaults to
`"full"` when unset. `"live"` requires the source sandbox to have
Expand Down
Loading
Loading