Make thousandbirds-agent-sandbox run as many concurrent agent VMs as a host can
safely hold, with each VM consuming only the resources it actually uses, cheap
and fast scheduling of new VMs, and resource sharing across VMs that preserves
isolation.
This document is the design and implementation plan for the work identified in the density/efficiency review. It is scoped to four goals:
- Per-VM efficiency — a VM's host footprint tracks its live working set, not a high-water mark.
- Max density — the host packs VMs up to a real budget and refuses/queues beyond it instead of swapping or OOMing.
- Low scheduling overhead — starting a new VM is cheap (warm pool / snapshot) and boots faster (no initramfs).
- Cross-VM sharing — read-only base assets and host page cache are shared across VMs while writes stay private per VM.
Changes span two repos. Items tagged [repo] land here; items tagged [krun] land
in ../thousandbirds-libkrun (guest kernel config, examples/os_mode launcher, libkrun
device support) and are surfaced here through launcher flags.
- A general-purpose VM scheduler/orchestrator with multi-host placement. The scheduler here is single-host admission control, not a cluster manager.
- Strong host/guest isolation beyond libkrun's existing model (VMM and guest share a host security context). Sharing features below never weaken the existing isolation; they only share read-only data.
- Live migration between hosts.
- KSM-style cross-VM anonymous-memory dedup — not available on macOS/HVF, so it is out of scope; virtiofs DAX (below) is the supported path to shared read-only memory.
What already serves these goals:
- Disk sharing is free. Each VM root is an APFS copy-on-write clone (
cp -c) of the base image —clone_or_copy_root(src/runtime.rs:681). Blocks are shared with the base until written; create is ~5 ms. - Memory is demand-paged by HVF. A 2048 MiB VM peaks at ~433 MiB RSS (README "Startup Performance Notes"): the host backs only touched pages plus a boot-time zeroing cost.
- Workspaces are zero-copy virtiofs, per-VM and isolated (
src/libkrun.rs:129). - Manual memory resize exists via virtio-balloon + a per-VM control socket
(
SandboxConfig::resize_enabledatsrc/config.rs:229; balloon args atsrc/libkrun.rs:92-101;resize_memoryinsrc/runtime.rs).
The gaps this design closes:
- Per-VM efficiency: RSS is a high-water mark. The balloon is opt-in/off by default
(ceiling defaults equal to initial —
src/config.rs:209-213) and only moves on an explicitresize. Idle long-lived agents never return touched pages. - Max density:
SandboxManager(src/runtime.rs) has no host-level accounting; nothing prevents oversubscribing RAM into swap.list()enumerates state dirs with no notion of "running" or a budget. - Scheduling overhead: every session is a ~1.3 s cold boot; no warm pool or snapshot.
- Cross-VM sharing: only the root disk shares pages, and only at clone time. Read-only toolchains/caches and the host page cache are not shared across running VMs.
Goal: per-VM efficiency, density. [krun] + [repo].
Turn the balloon from a manual lever into continuous reclaim using virtio-balloon free
page reporting (VIRTIO_BALLOON_F_REPORTING): the guest kernel hands freed pages back
and the VMM MADV_FREEs them, so host RSS falls as the working set shrinks.
- [krun] Build the guest kernel with
CONFIG_PAGE_REPORTING=y(it already hasCONFIG_VIRTIO_BALLOON). Inexamples/os_mode+ libkrun, negotiateVIRTIO_BALLOON_F_REPORTINGand madvise-free reported page ranges. Add a launcher flag--balloon-free-page-reporting(default on for our images). - [repo] Decouple "balloon present for reclaim" from "balloon headroom for growth".
Today the balloon device is only attached when
memory_max_mib > memory_mib(src/libkrun.rs:92). Always attach the balloon + control socket so reporting works even when ceiling == initial. Keep the inflate/deflate growth path gated on headroom as before.
Result: per-VM footprint tracks the live working set without operator action, raising density at unchanged configured limits.
Goal: max density. [repo].
Add single-host accounting so the manager packs VMs safely and refuses/queues when full.
- A
ResourceBudget(new, insrc/runtime.rsor a newsrc/scheduler.rs): host memory budget fromTB_SANDBOX_MEM_BUDGET_MIB(default: a fraction of physical RAM, queried viasysctl hw.memsizeon macOS), minus a host reserve. - Track running VMs and their reservations. Reserve on
memory_min_mib/ a working-set estimate (not the ceiling), because HVF is demand-paged and #A returns idle pages. - Admission on
shell/launch_piped(src/runtime.rs:275,:352): admit ifsum(reservations) + new VM <= budget; otherwise return a typedError(e.g.ResourceExhausted) or block on a queue, selectable via flag. - Feedback signal: sample live VMM RSS (the technique in
scripts/profile-sandbox.sh) periodically to correct static reservations. - Surface state: extend
SandboxSummary/tb-sandbox list(src/runtime.rs:516) and the SDK with running-state + live-RSS columns so headroom is visible.
Goal: cross-VM sharing, density. [repo] (pairs with D).
Stop baking heavy, identical assets into every image variant and every COW root. Mount a
shared host directory read-only into all VMs via virtiofs: language toolchains, package
caches (~/.npm, cargo, pip), model assets. Writes stay per-VM.
- Add a first-class "shared asset mount" concept alongside
WorkspaceMount(src/config.rs:14). It reuses the existing--virtiofs-roemission (src/libkrun.rs:137) and the guest mount loop inguest/agent-sandbox-shell(driven bymounts.tsv). - Ship a default set +
--shared-mount TAG=HOST_PATH[:guest=/path]CLI flag and a config default. Provide a writable per-VM overlay dir for caches that need writes (tmpfs or a per-VM dir), so the shared copy stays read-only. - Trim
scripts/build-agent-image.shvariants to stop duplicating these assets into each rootfs once they're shared mounts, shrinking image size and COW divergence.
Goal: cross-VM sharing, density. [krun] + [repo].
DAX maps virtiofs file pages directly from the host page cache instead of copying into guest RAM. N VMs reading the same read-only files then share the host pages once, fully isolated. This is the realistic shared-read-only-memory path on macOS/HVF.
- [krun] Enable the virtiofs DAX window in libkrun/
os_modeand guest (CONFIG_FUSE_DAX); expose a--virtiofs-dax-sizeknob. - [repo] Opt read-only mounts into DAX once supported: the #C shared mounts,
agent-config, and credentials (src/libkrun.rs:137-149). With DAX the marginal memory cost of a shared mount per extra VM approaches zero.
Goal: scheduling, density. [krun] (image-build side [repo]).
The README documents this as the single biggest startup lever. The guest kernel currently
loads virtio_blk from a 25 MB initramfs.
- [krun] Rebuild the guest kernel with
CONFIG_VIRTIO_BLK=y+CONFIG_EXT4_FS=ysoos_modemounts/dev/vdadirectly, removing the initramfs load (pre-kernel) and the ~345 ms in-guest initramfs phase. - [repo] Make the initramfs optional in the image manifest/build
(
src/image.rs,src/libkrun.rs:87,scripts/build-agent-image.sh) so images can ship without one when the kernel supports it. Faster boot = cheaper scheduling and less transient per-boot memory.
Goal: low scheduling overhead. [repo] (+[krun] for the clean version).
Keep a small pool of booted, balloon-deflated VMs; hand one out on create/shell and
attach the real workspace on assignment. Idle cost is ~430 MiB each today (less after #A),
so the pool is sized against the #B budget.
The hard part is late workspace attachment — virtiofs is set at launch (src/libkrun.rs:129).
Two options:
- Guest-side bind [repo-only, near-term]: boot pooled VMs with a generic placeholder
mount, then bind/remount the assigned workspace inside the guest via the control channel
(
guest/agent-sandbox-shell+ the runtime-control mount). - virtiofs hot-plug [krun]: attach the workspace device after boot. Cleaner but needs launcher support.
- Snapshot/restore [krun]: collapses most of boot. This is now code-complete; the remaining repo work is deciding when the scheduler should choose restore-backed startup over a pre-booted warm-pool VM.
Pool management lives in SandboxManagerSdk (src/sdk.rs), which already tracks per-VM
processes and lifecycle.
Goal: efficiency, density. [repo].
- Default
cpusto 1 (src/config.rs:209): vCPUs are threads and oversubscribe fine; 1 CPU only costs ~60 ms boot. Keep--cpusfor parallel-build workloads. - Keep
memory_mibdefault at 2048 (documented startup sweet spot). Once #A lands, set a higher ceiling with a lower initial so VMs grow on demand while idle cost stays small.
Goal: efficiency. [repo]. Interim to / complements #A.
Host-side policy that periodically issues resize downward on VMs idle beyond a threshold
(no active session/process — the SDK already tracks processes per VM in src/sdk.rs).
Reclaims high-water-mark RSS from long-lived idle sandboxes until free-page reporting (#A)
makes it unnecessary. Implement as an opt-in background task in SandboxManagerSdk reusing
resize_memory (src/runtime.rs:478).
Phased so each phase is independently shippable and measurable. Repo-only phases land first; libkrun-coupled phases follow.
Highest value with no libkrun dependency. Landed in src/scheduler.rs (new),
with wiring in src/runtime.rs, src/cli.rs, src/sdk.rs, src/config.rs, and
src/error.rs; documented under "Run Many Sandboxes (Density)" in the README.
Items 1–4 below are done; the idle auto-reclaim (item 4) is a library opt-in
(SandboxManagerSdk::spawn_idle_reclaim).
- Scheduler / admission control (#B).
- New
src/scheduler.rs:ResourceBudget, host-memory detection (sysctl hw.memsize), reservation map keyed bySandboxId. - Wire into
SandboxManager::shell/launch_piped(src/runtime.rs:275,:352): reserve on admit, release on exit (alongside existing proxy/gvproxy cleanup). - New
Error::ResourceExhausted;--admission=reject|queueflag andTB_SANDBOX_MEM_BUDGET_MIB. - Extend
SandboxSummary+list()(src/runtime.rs:516) and CLI output with state + live RSS.
- New
- Density-aware defaults (#G). Change defaults in
src/config.rs:209-213(cpus → 1); document ceiling/initial guidance. - Shared read-only mounts (#C). Add shared-asset mount type in
src/config.rs,--shared-mountparsing insrc/cli.rs, emission insrc/libkrun.rs, default set; adjustscripts/build-agent-image.shto stop duplicating shared assets. - Idle auto-reclaim (#H). Opt-in background task in
src/sdk.rsreusingresize_memory.
Exit criteria: N concurrent VMs are admitted up to the budget and cleanly
refused/queued beyond it; list shows live RSS; a shared toolchain mount is read-only in
the guest and writes never cross VMs.
Discovery during implementation: libkrun's virtio-balloon device already
advertises VIRTIO_BALLOON_F_REPORTING (src/devices/.../balloon/device.rs
AVAIL_FEATURES), its free-page-reporting queue handler already madvise-frees
reported pages (process_frq), and the balloon device is attached to every
VM unconditionally (src/vmm/src/builder.rs:977). So no os_mode or wrapper code
change is needed for reporting — it is gated only by the guest kernel.
- Free-page reporting (#A).
- [krun] Added
CONFIG_VIRTIO_BALLOON=y+CONFIG_PAGE_REPORTING=yto the canonical guest kernel config fragment indesign_docs/os_mode_guest_image.md, with a note explaining the device-side is already wired. Remaining external step: rebuild the guest kernel with these options (cannot be done in this environment). - This is independent of the runtime resize path, which already works and is unchanged.
- [krun] Added
Exit criteria: an idle VM's RSS falls back toward its working set within seconds after
a memory-heavy task; measured via profile-sandbox.sh RSS sampling on a kernel built with
the two options above.
- Drop initramfs (#E).
- [repo]
src/image.rsalready treats the initramfs as optional, andsrc/libkrun.rsonly passes--initramfswhen present. AddedNO_INITRAMFS=1toscripts/build-agent-image.shto emit a direct-boot image (no initramfs file, noinitramfskey inimage.json). - [krun] The canonical config fragment already lists
CONFIG_VIRTIO_BLK=yCONFIG_EXT4_FS=y(built-in root drivers). Remaining external step: build/ship a guest kernel with those built-in so/dev/vdamounts without an initramfs.
- [repo]
Exit criteria: cold-boot drops ~345 ms in-guest + the initramfs pre-kernel load in
profile-sandbox.sh ITERATIONS=N, using a NO_INITRAMFS=1 image on a built-in-driver kernel.
- virtiofs DAX (#D). Discovery:
krun_add_virtiofs3(... shm_size ...)already exposes a DAX window; os_mode just hard-codedshm_size=0.- [krun] Added
--virtiofs-dax-sizetoexamples/os_mode.cand route both RO and RW mounts throughkrun_add_virtiofs3with the window size. - [repo] Added
SandboxConfig::virtiofs_dax_size, the--virtiofs-dax-sizeCLI flag (accepts1Getc.), persisted insandbox.json, emitted as--virtiofs-dax-sizefromsrc/libkrun.rs. AddedCONFIG_FUSE_DAX=y(+ deps) to the kernel config fragment. Remaining external step: guest kernel withCONFIG_FUSE_DAXand an on-VM measurement of shared page cache.
- [krun] Added
- Warm pool (#F). [repo]
WarmPoolinsrc/sdk.rs: pre-boots sandboxes from a template config and hands them out viaacquire(), refilling in the background; admission control still applies per VM. Unit-tested with the fake-launcher harness. Per-session late workspace attach (guest-side bind / virtiofs hot-plug / snapshot) remains the follow-on so the pool can serve arbitrary per-session host directories; today it fits interchangeable sandboxes (shared or no workspace).
Exit criteria: shared-mount memory cost is ~flat across VM count (DAX); create→ready
served from the warm pool is dramatically faster than cold.
Verified by booting a real OS-mode Debian aarch64 guest on this macOS/HVF host with the
prebuilt libkrunfw 6.12.87 kernel — whose .config already has CONFIG_PAGE_REPORTING,
CONFIG_VIRTIO_BALLOON, built-in CONFIG_VIRTIO_BLK/CONFIG_EXT4_FS, and
CONFIG_FUSE_DAX/CONFIG_VIRTIO_FS/CONFIG_DAX all =y. The os_mode launcher
(with the --virtiofs-dax-size change) compiles, links, and runs against the
freshly built libkrun.dylib.
- #E direct boot (no initramfs): PASS. Booting with no
--initramfsreachedKRUN_OSMODE: readyin ~1.7 s withEXT4-fs (vda)mounted directly from/dev/vda. - #D virtio-fs DAX: PASS.
--virtiofs-dax-size $((512*1024*1024))produced a guest dmesg linevirtiofs virtio3: Cache len: 0x20000000(exactly the 512 MiB window), and the read-only mount read back its shared file end-to-end. - #A free-page reporting: pipeline verified; macOS host reclaim needs an HVF unmap.
With libkrun DEBUG logging, the guest kernel reported freed pages and the host
balloon free-page-reporting handler fired (
process_frq, 719 × 4 MiB descriptors), i.e.CONFIG_PAGE_REPORTINGworks and the device handler runs. While verifying, found and fixed a real macOS bug: the handler usedmadvise(MADV_DONTNEED), which is a no-op for anonymous memory on macOS; switched toMADV_FREE_REUSABLE(src/devices/src/virtio/balloon/device.rs, gated#[cfg(target_os = "macos")]), which a standalone test confirms reclaims anon memory (513 → 1 MiB). On Linux/KVM this path reclaims. On macOS/HVF, host RSS still does not drop because all guest RAM is mapped into the guest up front viahv_vm_mapand stays resident; reclaiming it needshv_vm_unmapof the reported regions plus a lazy-remap data-abort handler in the HVF vcpu loop (src/vmm/src/macos/vstate.rs), since the guest reuses reported pages later. That is a hypervisor-core change with real crash risk and is intentionally left as the one remaining macOS-host item rather than rushed.
- macOS physical reclaim for #A:
hv_vm_unmapreported ranges + on-fault remap in the HVF vcpu loop (above). Largest, riskiest item; deferred deliberately. - Warm pool late workspace attach (#F): guest-side bind / virtio-fs hot-plug so a pooled VM can take an arbitrary per-session workspace.
- Snapshot/restore-backed scheduling (#F): libkrun snapshot/restore is now code-complete; the remaining scheduling work is product policy — when to prefer warm-pool reuse vs. restoring from a Full snapshot, and how to measure both paths under real workloads.
Use the existing harness (scripts/profile-sandbox.sh with RESOURCE_CSV= and
ITERATIONS=) for every phase.
- Per-VM efficiency (#A, #H): boot, run a memory-heavy task, idle; sample VMM RSS over time. Expect RSS to fall back toward the working set (today it stays at the high-water mark).
- Density (#B, #G): launch N concurrent VMs until the budget is hit; confirm admission
refuses/queues instead of swapping. Compare
sum(RSS)and max-concurrent before/after. - Sharing (#C, #D): mount a large shared RO toolchain into M VMs; compare host RSS / page-cache footprint for 1 vs M. With DAX the marginal cost per extra VM approaches zero.
- Scheduling (#E, #F):
profile-sandbox.sh ITERATIONS=Nfor boot timing (#E); wall-clockcreate→ready from warm pool vs cold (#F).
Isolation regression check (every phase): shared mounts are read-only from the guest;
per-VM writes never leak across VMs; the COW root behavior in clone_or_copy_root
(src/runtime.rs:681) is unchanged.
- Reservation basis for the scheduler: static
memory_min_mib, a learned working-set estimate, or live-RSS feedback with hysteresis? Start static, refine with feedback. - Default host reserve fraction for
TB_SANDBOX_MEM_BUDGET_MIBon typical dev machines. - Warm-pool late-attach: ship the guest-side bind first, or wait for virtiofs hot-plug in libkrun?
- DAX window sizing vs. host memory pressure.