Repository navigation
perf: remaining performance ledger after the 2026-07 hotspot round #30
Description
Status ledger for performance work after the #28 round and the cocoon #123 upgrade verification (bare-metal testbed, 2026-07-12). Numbers of record: warm claim 0.2ms, sequential clone claim 32-37ms, burst16 wall 287ms, cold ~320ms, fork(4) 185ms, exec RTT p50 0.27ms, fs_pull ~602 MiB/s, fs_push steady ~343 MiB/s.
Open — worth doing, needs a product decision
1. Wire protocol v2: multiplexed RPCs + binary bulk frames. The one remaining order-of-magnitude lever in this repo. Today every RPC pays a triple connection setup (SDK TCP dial + HTTP upgrade → relay hijack → fresh vsock dial): the fixed cost is the bulk of the 0.27ms RTT. A pre-dialed spare connection was measured and rejected (p50 0.20/0.48/0.36/0.35/0.39ms across 5 rounds vs a stable 0.27-0.31ms baseline; p90/p99 consistently worse — the background dial contends with the foreground RPC), so the real fix is multiplexing over one long-lived conn. Bulk data rides base64-in-JSON (+33% wire, codec CPU); binary framing should lift fs_pull from ~602 toward 800+ MiB/s. Scope: SDK + relay + silkd (Rust) — cross-repo protocol change, staleness/idempotency semantics needed for reconnects. Do both together or not at all.
2. Refill admission re-eval (refillSem=4 / per-golden). Un-blocked by cocoon #123 (sibling clones no longer serialize on the source flock). Claim-miss provisions are unbounded (verified: ClaimProvision calls provision directly), but refills and forks share the node-wide refillSem=4 — with cocoon now parallelizing, that cap may bound pool-drain recovery. One day of A/B (4 vs 8 vs per-golden admission) once a cocoon release carrying #123 is deployed.
Deferred — reopen triggers documented
| item | trigger |
|---|---|
| claimStore full-map snapshot+rewrite per mutation | >120 live claims per node |
| idleOnce/archiveOnce O(n) scans under the manager mutex | same |
| checkpoint TTL sweep re-lists the full store per tick | checkpoint count grows 10x |
| fastBulk decode buffer per chunk (~600MB/s GC garbage at max pull) | GC pressure shows in sustained-transfer profiles; needs a payload-ownership contract on Recv |
| resyncEgress serial EnsureLock at restart | many egress claims + restart-time SLA |
probeReady vsockOf fallback polls vm list (subprocess) at 100ms |
the fallback branch shows up in practice (it is rare by design) |
Rejected with data — do not re-litigate without new evidence
- SDK pre-dialed spare connection (H-4): see above.
- fs_push chunk 256K→1M: steady-state identical (~343 MiB/s) across 3 rounds.
- nft Lock netlink-conn merge: conns are lazy in nftables v0.3.0; the two batch round trips cannot merge safely without kernel destroy-table support.
- silkd conn scanner-buffer pooling: unsafe — Close may race a blocked Recv from another goroutine (pty/port semantics).
- encodeTagged rework: already 243ns / 2 allocs per control frame.
Physical boundaries — not this repo's levers
Cold boot (~320ms, guest kernel/init), fork (~185ms, ~512MB memory snapshot I/O), CH burst restore bandwidth, and the cocoon-side 16-way residual serializer (index flock + journal + per-clone CLI process startup; per-clone p50 85ms vs 27ms serial — see cocoon#123's follow-up notes). The CLI-process model itself (one fork+exec of the cocoon binary per engine operation) is the deepest cross-repo lever here (daemon/library mode).
Related: #29 (correctness, found in the same round).
Activity
CMGS commentedon Jul 13, 2026
Ledger update, 2026-07-13:
Item 1 (wire protocol v2) now has a full design: #34 — framing, per-stream credit flow control, negotiation/compat matrix, reconnect semantics, rejected alternatives, acceptance targets (exec RTT p50 ≤0.15ms, fs_pull ≥800 MiB/s). The fastBulk decode-buffer deferred item folds into it (v2 DATA frames carry the payload-ownership contract).
Item 2 (refill admission re-eval) is un-blocked and has its harness + baseline.
make benchgained a burst stage (BURST_Nconcurrent clone claims, runs last) and a warm refill-recovery stage (6615de0). Post-#33 baseline @88fda81, bare metal, two rounds:Environment note: host cocoon moved master-be8653e → master-acfc815 between the pre-burst and burst rounds (parallel cocoon work on the bench host); all four rounds' sequential tiers agree, so the comparison stands.
CMGS commentedon Jul 13, 2026
Precision note on the refill-recovery number, after reading the mechanism (
pool.go:35refillInterval=2s;refillOncecall sites are startup,SetPools, and the 2s ticker only — a claim draining the pool does not kick refill;refillOneself-chains its semaphore slot,refill.go:63-67):recovery = tick-phase wait (0–2000 ms, dominant) + ~200–400 ms of actual work (6 clones through refillSem=4, self-chained) — and the reported 1638 ms is additionally quantized by the harness's 200 ms poll loop (1638 ≈ 8 × 204.75 ms poll iterations). The round-to-round millisecond identity is the poll quantization plus the deterministic script timeline (same tick phase at drain time), not a system constant. True recovery was in (1433, 1638] both rounds.
Refined implication for item 2: at warm=6, refillSem 4→8 changes only the ~300 ms work term — invisible under the 2 s tick wait, so an A/B at this depth measures nothing. The A/B belongs on a deep pool (target ≳50, work term in seconds); if drain-recovery latency itself ever matters, the lever is an event-driven refill kick (or a shorter tick), not the semaphore. Note the 2 s tick is deliberate: refill is fully off the claim path (a miss provisions directly — the burst row measures exactly that path), so recovery speed only affects warm-hit ratio in the ~2 s after a drain.
CMGS commentedon Jul 14, 2026
fs_push moved: steady-state 702/741/762 MiB/s (pushbench, 128 MiB ×3, bare metal, new-silkd images @205d3f3) vs the ~343 MiB/s of record. The guest-side change is the inbound base64 decode feeding serde's borrowed slice straight to the decoder (was: an intermediate String per data frame, ~341 KB per 256 KB chunk, on musl); a rustc toolchain bump rides in the same image, so the split between the two contributors is unattributed — codec-level A/B on glibc measured only +6.5%, meaning musl allocation was most of the cost. Full round context in docs/benchmarks.md 2026-07-14 entry (e2e smoke pass, all other tiers in band). Item 1 (protocol v2, #34) remains the lever that deletes base64 entirely; its fs_pull ≥800 MiB/s target may deserve an fs_push target next to it now that push cleared 700 without protocol changes.
CMGS commentedon Jul 20, 2026
Perf ledger update (bare-metal testbed, 2026-07-20, cocoon master-24bd46b + CH fork dev, 512M none-lane golden):
B=64 concurrent clone, restore-mode A/B (drop_caches before each round; exec spot-checks all passing):
CopyOnWrite* one slot lost to an instrumentation accident (strace on the CLI wedges Go binaries; don't).
Conclusions for the ledger:
restore-mode ""|copy→ CH default) — switching the clone default to CoW is the single biggest storm lever currently unwired.nr_hugepages>0— the fallback is invisible today (warn in CH stderr only). Needs surfacing as an error or metric.Same round: egress-lane concurrent clones now clean 16/16→64/64 after cocoon#142 (tap-attach race), egress ≈ none +10%; FC lane unblocked by FC v1.16.1 (cocoon#140) and upstream main (1.17.0-dev) also passes the vsock repro.
CMGS commentedon Jul 20, 2026
T1 closed (bare-metal testbed, FC none-lane per lane assignment, cocoon master-24bd46b, FC 1.17.0-dev main build, 512M guests).
Storm scaling (100% success + exec spot-checks at every B):
Density ceiling: 576 × 512M sandboxes on the 60GB host — guard-stopped on MemAvailable (>3GB reserve), zero clone failures, zero OOM, exec verified per wave and at final census. Marginal footprint ~107MB/VM under pressure (84MB/VM at B=64). Nominal RSS sum 80.8GB > physical RAM — MAP_PRIVATE page-cache sharing doing its job. Extrapolation: ~9k+ VMs on a 1TB box → the T2 8-12k/box target is realistic on hardware, and ~110 × 1TB boxes for 1M at 512M guests.
Latency attribution (why p50 grows with B):
/usr/bin/time -vacross a B=64 wave; 64 clones sum 0.76s user+sys) — clones spend ~98% of wall waiting;versionp50 12ms,vm listp50 12ms — process startup/config/DB-read are negligible;--from-dir(bypasses snapshot store) B=64 p50 473ms vs store-path 609ms — store layer ≈ 20-25% of the wait;--root-diris rejected — correct behavior, bad for this experiment).Ledger actions:
vm clone --from-diris both the page-cache-sharing design AND a free −25% storm latency (skips the store layer entirely).restore_mode: mmapin deployment config (guard: cocoon#144).CMGS commentedon Jul 20, 2026
Phase decomposition of the storm superlinearity (B=64 ×3 consecutive, no cleanup between — resident N = 0/64/128; per-clone anchors: exec-stamp → ops.lock ctime → FC /proc starttime → api.sock ctime → vsock.uds ctime → exit-stamp):
Fixed B=64, only resident N grows: wall 1009 → 3653 → 4914ms. The B128/256 superlinearity is the resident-count effect, not concurrency per se.
Round-3 phase split (64/64 complete samples):
Named culprit:
firecracker/db/vms.jsonwhole-file RMW under the polled exclusive flock, plus a full.prevcopy on every write (measured 113KB → 226KB → 339KB at ~1.8KB/record, i.e. 3×339KB of IO per write at N=192), at least twice per clone (insert in P2, finalize in P5). Cost per RMW ∝ N ⇒ storm cost O(N·B).Fix ladder for T2: (1) cocoon#132 — drop the second RMW (halves lock-held IO); (2) delta/segmented index instead of whole-file+.prev rewrites (kills O(N)); (3) daemon/library mode with in-memory index (removes the file RMW entirely). The restore path itself (14ms) and CLI startup (12ms floors) need nothing.
CMGS commentedon Jul 21, 2026
T1 re-run on the sqlite meta backend (bare-metal testbed; cocoon master-5714e9e with meta P0–P3 landed, CH dev-7901a518, FC v1.16.1; same binary A/B, json round then offline
meta convertthen sqlite round; all storms 100% success + exec spot-checks):Also validated on hardware: P0 non-regression (json engine behind the meta API reproduces the legacy ladder within noise: 1128/3533/4032 vs legacy 1009–1189/3533–3874/4016–4914) and the conversion procedure behaved per design (both json generations renamed aside with timestamps across all namespaces; records served intact from sqlite).
Design acceptance anchors, honestly scored: r1 ≤1.5s ✅ (966ms); later rounds ≤1.3×r1 ❌ (2.3×/3.3×). Cause is not a metadata shortfall but a bottleneck relocation: the
restore(load)phase segment (FC api-up → vsock.uds), historically a constant ~15ms, now carries p50 599ms / p90 1622ms under sqlite. The metadata queue that used to stagger arrivals (pre-spawn 1795ms) is gone, so all 64 clones hit the next serializer nearly simultaneously — the drive-redirect window (per-golden clone-locks + bind-mount setup, kernel-global work). Confirmed no meta-store writes exist in that window (redirect uses dir flocks only).Next ledger target therefore shifts: the drive-redirect critical section is now the dominant storm serializer at B≥64. Candidates: per-clone mount-ns cost, clone-locks hold time, symlink-fallback retirement (the TODO already in clone.go). Instrumentation of holdRedirectDirs is the next measurement step before any fix.
CMGS commentedon Jul 21, 2026
Attribution of the post-sqlite restore(load) p90 → it's cocoon, not FC, not a control-plane daemon. Hardware-measured (bare-metal testbed, sqlite backend, FC none-lane 512M hibernate golden):
restore(load) phase (api.sock→vsock.uds) scales purely with concurrency:
Three independent signals isolate the serializer:
Unshare(CLONE_NEWNS)+Mount(MS_REC|MS_PRIVATE,/)completes in 2ms (0.1ms/op), and all 64 FC processes are in the HOST mount namespace — so the lock-free bind fast path never ran.Causal chain, verified in source + on disk: a hibernate golden's baked source COW path (
…/run/firecracker/<srcVM>/cow.raw) is MISSING once the source VM is removed post-hibernate →bindableRedirectsrequires a regular file, returns nil → the clone falls out of the lock-free bind path into the symlink fallback, which wraps the wholeloadSnapshotFCinwithSourceWritableDisksLocked(a flock on the source golden's writable disks). 64 clones of one golden share those paths → one lock → full serialization.This is exactly what the removal of the O(N) metadata serializer (json→sqlite) exposed: pre-spawn dropped −75%, un-staggering arrivals so they all hit this next lock at once.
Fix is already scoped as a TODO in
hypervisor/firecracker/clone.go:150: make dead-source (hibernate golden) clones eligible for the lock-free bind path by binding the clone's fresh disk over a placeholder mountpoint at the missing source path, instead of the locked symlink fallback. That removes the per-golden serializer from the C1M clone-from-hibernate-golden workload — the dominant B≥64 storm cost now that metadata is off the critical path.CMGS commentedon Jul 21, 2026
Corrected T1 baseline: both serializers removed (sqlite metadata + keep-source bind path). Same bin (cocoon master-5714e9e, FC v1.16.1), FC none-lane 512M, 100% success + exec-verified. Keep-source = golden's source VM kept stopped (not rm'd), so its baked COW path exists and clones take the lock-free bind redirect path instead of the symlink+flock fallback (attribution proven in the prior comment).
The two fixes are additive (B256 12.4→9.6→5.6s). restore(load) collapses 22× (p90 1622→74ms) — confirming both the attribution and that keep-source is a valid, zero-code fix for the C1M clone-from-hibernate-golden workload. Per CMGS: keep-source is the interim answer; the placeholder-mountpoint TODO stays parked until FC upstream lands dynamic drive replacement (PR #5774), at which point the redirect dance retires entirely.
Next bottleneck is now CLI-per-op, not storage: round-3 phase split with both serializers gone shows the two largest segments are pre-spawn (p50 509ms: CLI process start + dir/COW prep) and post-uds (p50 461ms: resume + reseed + finalize), while restore(load) is 49ms. These are the per-process cost of 64 independent cocoon CLI invocations — exactly the daemon/library-mode lever (T0, this ledger). CH-egress mmap flat (~760ms, not storage-bound). The layered plan holds: each serializer removed surfaces the next — metadata → drive-redirect → CLI-per-op.
CMGS commentedon Jul 21, 2026
Double-checked the two suspicious T1 numbers — both explained with hardware data.
(1) restore(load) 15ms (json) → 599ms (sqlite) → 49ms (keep-src): confirmed un-staggering of a SERIAL bottleneck chain, not an anomaly. The drive-redirect flock is taken between api.sock and vsock.uds — inside the restore(load) window. sqlite B64 (rm-src) measured: api.sock arrival spread only 279ms (clones bunched) + /proc/locks waiters peak 53 (≈ the full 64-way queue on that lock) → restore(load) p50 175 / p90 663ms. In the json round, pre-spawn was 1795ms (metadata O(N)) which SPREAD arrivals, so each clone met an uncontended drive-redirect lock → 15ms. Removing the upstream metadata serializer (sqlite) just moved the queue onto the downstream drive-redirect lock. Keep-source (bind path) removes that lock → restore(load) 31-49ms. The 40× swing is the signature of two serializers in series, not a measurement error.
(2) B=256 still 5.6s with both serializers gone: it's CPU saturation of the 16-core testbed, NOT a lock/metadata/FC issue. vmstat during the B=256 storm: peak run-queue r=280, min idle=0% on 16 cores = 17.5× oversubscription. Phase split at B=256: restore(load) only 31ms (bind path working), post-uds 285ms — but pre-spawn p50 1503ms / p90 4099ms dominates, and it is now pure CPU contention (256 Go runtimes + 256 FC launches competing for 16 cores). per-clone p50 2070ms is mostly CPU-scheduling wait. This is the box being too small for B=256, not a fixable serializer.
Reframed conclusion: on 16 cores the sane per-box concurrency is ~B=64 (4× oversub, 813ms-2083ms); B=256 (17.5× oversub) is past this box's CPU wall — exactly why T2 rents a larger box. The CLI-per-op model adds real overhead (per-process Go-runtime startup ×N, sqlite cross-process WAL-writer contention on reserve+finalize = 2 writes/clone) that daemon/library mode would cut, but at B=256-on-16-cores raw core count dominates and no daemon manufactures cores. Corrected next steps: (a) daemon mode still worthwhile for per-process overhead + writer batching, measured at a concurrency the box can actually run; (b) T2 on a many-core box to find the real per-box ceiling; (c) the CLI floor is a per-process cost, so C1M's fan-out unit should be one long-lived agent per box, not N CLI forks.
CMGS commentedon Jul 21, 2026
B64 best-case spec (sqlite + keep-src, clean host per iteration, 4 iterations) — plus a measurement correction that reshapes the phase tables.
All 64/64, and the fleet is genuinely usable: all-64 concurrent exec round-trips complete in 199ms wall (per-exec p50 75ms / p90 111ms). So one 16-core box turns 64 cold sandboxes into exec-ready in ~1.0s end-to-end.
Correction: the flat ~250ms 'fc-api-up' segment in every phase table was a measurement artifact, not FC. Probes: raw FC exec→api.sock = 1-2ms (PTY-wrapped identical); iter-4 pre-spawn showed p50 −8ms (negative). Root cause: phase scripts estimated FC start as btime + jiffies/100, but /proc/stat btime has whole-second resolution; the truncated fraction on this boot is 249ms (measured directly: (now−btime)−uptime), a constant that understated pre-spawn and inflated fc-api-up by exactly that amount. All cross-round deltas remain valid (same boot, same constant); only the segment attribution shifts.
Corrected B64 per-clone reality (clean host): pre-spawn ≈ 241ms (CLI start + locks + sqlite reserve + COW prep) · fc-api-up ≈ 1ms · restore(load) 12ms · post-uds 141ms (resume + reseed + finalize) · total p50 428ms. The VMM side of a clone costs ~14ms; ~89% of per-clone latency is cocoon-side per-process work — pre-spawn + post-uds — which is precisely the daemon/library-mode surface. Prior tables' pre-spawn figures (457/509/1503ms) are each ~249ms higher in truth; their fc-api-up rows collapse to ~1ms.
CMGS commentedon Jul 21, 2026
First many-core datapoint: 384-core / 1.5TB box, fully isolated root (own binaries/config/meta), cocoon master-66e4232 (daemon supervisor landed) + CH dev-4b542ac + FC v1.16.1, sqlite backend, keep-src golden, daemon supervising throughout. Single uncontended clone: 77ms.
Corrected storm ladder (unique names per tier, clean between tiers):
Two findings that reshape the C1M model:
writer contended past retry ceiling: store busy— clean failures (no residue, tiers cleaned to zero), but they define the admission limit an orchestrator must enforce.Neither ceiling is hardware: both are the CLI-per-op + shared-writer architecture. The daemon supervisor (P0/P1, verified running and adopting through every tier) is the vehicle — a future clone-serving phase that batches reserve/finalize writes in one process would lift both. C1M arithmetic with today's numbers: 9.6k VMs/box ÷ 135/s ≈ 71s per box fill, fleet fills in parallel → 1M in ~2 min with ~110 boxes — still comfortably inside the plan, now grounded in a many-core measurement instead of a per-core extrapolation.
CMGS commentedon Jul 21, 2026
ROOT CAUSE of the ~135-143/s per-box ceiling — found and independently reproduced. It is NOT sqlite (correcting my earlier comment), NOT CPU, NOT the disk: it is the kernel mount-namespace copy (
unshare(CLONE_NEWNS)+MS_REC|MS_PRIVATE) that the bind-redirect fast path performs per clone, serialized on the globalnamespace_semwith cost O(host mount count).Elimination trail (each independently measured on the 384-core box, B=256 unless noted):
cocoon versionp50 653→59ms as root — harness artifact removed, storm rate unchanged (143/s) → not the wallneverA/B, settings saved+restored): 143/s unchanged → exoneratedThe clincher: this host carries 264 mounts (prod-ish box: 7 CH VMs + sandboxd). 256× concurrent
unshare -m --propagation private /bin/true(no cocoon at all): p50 742ms, wall 872ms (baseline/bin/true: 3ms). Every clone's mount-ns copy walks the whole table under the global write-side of namespace_sem → ~3ms serialized each × 256 ≈ the entire missing chunk of the storm's dominant phase (measured phase C = 906ms ≈ 742 unshare + 115 reflink+fsync + prep). It also explains every signature: rate doesn't scale with cores (global kernel lock), CPU stays idle (sleeping on the semaphore), per-clone latencies cluster near the wall (fair queue → convoy), and the 16-core testbed never showed it (clean mount table ~40 entries → sub-ms per copy; my earlier microbench there measured 0.1ms and wrongly exonerated the kernel path — mount-table size was the hidden variable).Fix levers, in order of leverage:
Corrected B=256 phase ledger on the 384-core box (root, keep-src, sqlite): A cli+open+reserve 492ms (CLI floor 59 + spawn stagger + reads) · C 906ms (unshare ~742 + reflink/fsync 115 + prep) · fc-api 4 · load 8 · post-uds 156 · total p50 1593ms, 143/s, 100% success. The earlier 'sqlite writer ceiling / ≤512 in-flight' framing from the first bd26 comment is superseded: the B=1024 store-busy failures were a symptom of arrival clustering behind the unshare convoy plus BEGIN IMMEDIATE polling unfairness, not of writer throughput.
CMGS commentedon Jul 21, 2026
Overnight T2 on the 384-core/1.5TB box — end-to-end sandboxd warm-ready, the concrete C1M single-box path. Isolated tree, machine's prod VMs untouched, all exec-verified (not scheduler-ack), cross-checked against actual
vm listcounts.Definitive best-config (sandboxd master, FC none-lane small, cocoond off during fill, GOMAXPROCS=12):
Peak is memory-bound (~97MB effective/VM incl page-cache share; guard at 164GB free). Direct-cocoon density earlier hit 5,376. exec-verify 10/10 random VMs at peak.
C1M path (rate decays with pool depth, so box-count trades against fill time):
1-minute-class 1M is reached today with the shipped sandboxd; sub-30s is a box-count decision. This is exec-verified warm-ready, versus Modal's 54s scheduler-ack.
Bottleneck confirmed = kernel mount-namespace copy, NOT sqlite/CPU/disk. Proof: a keep-golden-source branch (stop instead of destroy → clones take the bind fast path) REGRESSED 2.5x on this host (66/s vs 169/s) because its per-clone
unshare(CLONE_NEWNS)copies the 265-mount host table (742ms at 256-way) — costing more than the flock it removes. On a thin-mount host (.79, ~40 mounts, sub-ms unshare) keep-src wins. So golden strategy is mount-table-dependent: fat-mount host → dead-source symlink path; thin-mount/dedicated fleet → keep-source bind path.Real fix (both problems at once): a cocoon daemon clone-launch server — resident workers each hold ONE private mount namespace, so per-clone launches become plain binds inside it, killing both the per-clone unshare AND the CLI spawn cost, regardless of mount count. Prototyped on branch
perf/daemon-launch; currently blocked onsetns EINVALentering the per-VM netns from a persistent pre-unshared worker thread — the remaining integration item. Other secondary levers measured: refill_concurrency default 24 is a choke (≥48 saturates); cocoond running during fill costs ~30% (O(N) reconcile) — keep it off while filling.Fleet-hygiene corollary for C1M: provision dedicated boxes with minimal mount tables so even the current bind path stays sub-ms per clone.
CMGS commentedon Jul 22, 2026
CH none-lane experiment (CMGS's call): new all-time record — the FC redirect problem is simply bypassed, not fixed.
CH clones rewrite disk paths in the snapshot config before restore (patchDisks), so there is NO symlink, NO per-golden flock, NO bind/unshare, and the golden source can be destroyed freely. The one blocker was host hugepage pools forcing
hugepages=trueonto goldens (mmap-restore refused by the #144 preflight); fixed with a per-VM--no-hugepagesopt-out.Direct-cocoon CH mmap storms (dead source): B64 362ms=176/s, B256 1,059ms=241/s — fastest B256 of the whole campaign (FC best 1,234ms).
sandboxd end-to-end (none_backend=ch, restore_mode=mmap, RC=64, cocoond off, GOMAXPROCS=12):
exec-verify 10/10 at peak. Effective memory ~99MB/VM (vs FC-lane 122MB) — mmap page-cache sharing holds at scale.
vs FC none-lane best (same box, same harness): +21% capacity (4,762 vs 3,932), −28% fill time (33.4s vs 46.3s), and the golden-strategy/mount-table dilemma disappears entirely.
C1M with CH none-lane: 1M / 4,762 per box = 210 boxes × ~34s parallel fill — half-minute-class 1M, exec-verified.
Changes (two small experiment branches, one commit each, unpushed):
exp/ch-none-lane: per-VM--no-hugepages(types field + run flag + args gate) so clone-source VMs stay mmap-restorable on hugepage hosts.exp/ch-none-lane:none_backend: fc|chconfig knob + CH cold boots take--no-hugepages.Strategy note for review: this reverses the earlier none-lane=FC lane assignment. With CH none-lane, FC upstream PR #5774, the keep-src branch, and the daemon-launch workaround all become unnecessary for the C1M path (daemon-launch may still matter for FC lanes if kept). Residual open items: the O(resident-N) rate decay (200→122/s) remains in both backends; probe CoW cost ~30MB/VM is the price of exec-verified readiness.
CMGS commentedon Jul 22, 2026
O(resident-N) fill decay: root cause closed. It is the XFS journal, not sandboxd, not sqlite, not CH/cocoon code.
Instrumented direct-cocoon fill (64-worker pipeline, CH none-lane mmap, 4,000 clones, per-clone
--log-level debugtxn timings, bucketed by completion order):xfs_reflink_end_cow_extentjournal transaction. Aggregate journal traffic grows with resident N, so every clone's FS work queues longer. Past a threshold it wedges outright: leaving 4,000 write-active CH guests resident for ~40 min drove 28k+ kernel dio workers into D-state onxlog_grant_head_wait(load avg 78,000) — an XFS log-grant exhaustion incident, drained cleanly after killing the fleet (prod VMs on the same FS unaffected throughout; box self-recovered, no reboot).Headroom answer (CH 还能更高): yes — the ceiling is the journal, and it's ops-addressable. Journal-light early-fill ran at ~250/s; a journal-relieved box should hold that flat: 4k in ~16s, C1M ≈ 210 boxes × <20s. Levers for the fleet checklist: (a) mkfs.xfs with maximum log size on pool volumes; (b) spread pools across multiple filesystems/NVMe namespaces; (c) quiet the guests (tmpfs /var/log, noatime, minimal background writers in the pool image) — fewer end_cow conversions per resident; (d) A/B eager-copy vs reflink for write-heavy pools; (e) A/B ext4 (no reflink CoW conversion path). Hard rule learned: never leave thousands of write-active reflink-backed guests resident without journal headroom — this is the real density wall for write-active fleets, distinct from the memory wall.
CMGS commentedon Jul 22, 2026
XFS tuning without reformat:
--no-direct-ioA/B lands the fix. Same instrumented 4k fill harness, golden built with--no-hugepages --no-direct-io(clones inherit from snapshot):--no-direct-io)Buffered guest writes turn per-write synchronous
end_cowjournal transactions into batched writeback — the D-state/journal-storm risk class disappears outright, and for ephemeral sandboxes page-cache semantics are the right default anyway.No-reformat tuning list (ranked): (1) pool VMs run
--no-direct-io— sandboxd needs one flag in runColdArgs, clones inherit; (2)fs.xfs.xfssyncd_centisecs3000→500, runtime-reversible, keeps the log tail moving; (3) quiet pool guest images (tmpfs /var/log, prune background writers); (4) re-verify long-residency parking under buffered IO before declaring the wall fully gone; (5) new fleet boxes mount withlogbsize=256k(bd26 is at 32k, mount-time-only). Corrected earlier guidance: the bd26 log is ALREADY at the 2GB max — log size was never the lever; transaction-volume reduction is.Residual 217→318ms slope is second-order (writeback batches + allocation txns) — no longer the bottleneck class.
CMGS commentedon Jul 22, 2026
Second-order slope located + concurrency ceiling mapped (buffered-IO CH fills, phase-split first-500 vs last-500 of a 3k fill):
The entire residual growth is segment B. Direct micro rules out refcount long-tail: FICLONE+fsync is 42ms at 0 refs vs 45ms at 3k refs — reflink itself is O(1) in sharers. The grower is the staging fsync's XFS log-force competing with resident guests' writeback end_cow traffic (grows with N). We pay full durability tax on a staging disk that is about to be dirtied by an ephemeral sandbox and is worthless after host loss.
Concurrency sweep kills the more-workers path: W=64→208/s, 96→209/s, 128→195/s (worse, load up). ~210/s is a per-clone serial-section wall, not worker starvation.
Path to 300-400/s: (a) a cocoon knob to skip/relax the COW-staging fsync for ephemeral clones erases B → ~220ms flat per clone → ~300/s; (b) 400/s then requires decomposing F (112ms flat: resume+reseed+finalize). No architectural change anywhere — the running total of code needed for the full champion config remains: cocoon
--no-hugepages(done, exp branch), sandboxnone_backend: ch(done, exp branch), one-line--no-direct-ioin runColdArgs, plus the staging-fsync knob for the 300/s tier.CMGS commentedon Jul 22, 2026
Decay eliminated + ceiling mapped. Final state of the fill-rate investigation.
The XFS journal wall fell in three costumes, each killed by a zero-code lever:
--no-direct-iopool VMsrun_dirconfig key; 10.5MB RAM per VM disk, 42G for 4k VMs)Result: decay is GONE. 4,000 clones in 14.6s = 274/s flat (buckets 218/221/226/225ms — no slope), D=0, load 56, exec-verified. Recipe is entirely config + current master (e9502f6: fsync-skip, mmap-by-default, hugepages-opt-in): CH none-lane, golden
--no-direct-io, run_dir=tmpfs, sqlite, W=64. Note: tmpfs runDir is legitimately ephemeral — runDir state dies with the host anyway; records reconcile.Concurrency sweep on the flat config: W=64→274/s, 96→240, 128→210, 192→166 — monotonically worse. A residual ~3.6ms serial section (prime suspect: per-clone netns creation through the kernel's global mount lock) plus CPU queueing caps the box at 274/s under the no-code-change constraint. C1M at this recipe: ~250 boxes × 4k × ~15s fill.
Beyond 274/s requires code (tomorrow's menu, in leverage order): (1) drop the per-VM netns for none-lane (no NICs — nothing needs it): removes the last serial suspect and likely re-enables W scaling toward 300-400/s; (2) segment H (128ms resume+reseed+finalize) and A (~80ms CLI+reserve) are the ~210ms per-clone floor — daemon/batch surface, theoretical W=64 → 600/s+ if both halve. sandboxd integration unchanged:
none_backendknob + two runCold flags (exp branch ready).CMGS commentedon Jul 22, 2026
sandboxd full-recipe verdict: 155-157/s — 300+ is NOT reachable without code, and the blocker is precisely located.
Full champion env (sandboxd exp bin
none_backend=ch+ CH cold--no-direct-io, tmpfs runDir + empty CNI dir via env, sqlite, cocoond off), warm=4000:Arithmetic closes perfectly against the direct-harness numbers: sandboxd holds the silkd ready-probe inside the refillSem slot, so slot latency = clone (~370ms at RC=64-equivalent load) + guest resume→silkd-responsive ~240ms → 64/0.61s ≈ 157/s. The probe is honest product truth but it is pure vsock waiting — it does not need to occupy a provisioning slot.
Also landed on the way here, all config-only: the netns question is CLOSED —
initNetworkalready runs none-lane in the HOST netns when the CNI conf dir has no conflist (run.go:559); bd26's /etc/cni/net.d had hidden conflists, so pointingcni_conf_dirat an empty dir gives netns-free none-lane (verified netns-delta=0 across 8k clones). Effect: W=96 no longer degrades (240→258/s) though the W=64 plateau (~260-275/s direct) stands — the remaining floor is per-clone A (~80ms cli+reserve) + H (~125ms resume+reseed+finalize). Security note: per-VM netns was also quarantining the VMM process; fleet decision between host-netns (bench), per-VM (today), or a shared empty netns (best of both, future).Roadmap to 300+ (both small, both code):
probeReadyout of the refillSem critical section (separate bounded probe pool) → sandboxd returns to clone-bound ~260/s;Ops notes: pgrep without -f is blind to "cloud-hypervisor" (>15 chars) — false prod-down scare; sandboxd name suffix randHex saw 2 collisions in 4k draws (auto-retried, consider widening). 228 stale day-residue netns swept; box quiesced (D=0, tmpfs unmounted, prod 7 CH + sandboxd intact all day).
CMGS commentedon Jul 22, 2026
Final-form regression on HEAD (cocoon 81efb9e + sandboxd 973bd67/#48 both-lanes-CH, latest image) — recipe confirmed, probe decoupling verified, remaining wall re-characterized.
R1 direct core recipe (CH none-lane, tmpfs runDir, empty CNI, no-direct-io golden): 4,000 clones / 15.6s = 257/s, four buckets flat (233/246/243/240ms, zero decay), D=0, exec 3/3. Same class as yesterday's 274/s — the whole T2 recipe holds on HEAD, no regression.
R2 / RC sweep on HEAD sandboxd (warm=4000, no_direct_io+mmap config):
#48 already implemented the probe/refill decoupling from #47 (refillOne releases refillSem right after the clone; probes run on a separate probeSem). The old pathological RC degradation (64>96>128 monotonically worse) is GONE — rate is now flat-to-up with RC. But the gain is only ~18% because the bottleneck moved to CPU/memory-bandwidth: sandboxd fills run at load 585-906 (severe oversubscription on 384 threads) vs R1-direct's load 40. Root cause: the silkd ready-probe forces the guest to actually become responsive (faults in more pages + runs guest userspace) beyond just reaching vsock.uds; at 4,000 concurrent resume+probe this saturates CPU. This is the physical cost of exec-verified readiness (the 274-vs-155 story), now confirmed CPU-bound, not architecture-bound — #47 item 3 is effectively done.
Path to ~260/s warm-ready is no longer sandboxd-side: it's cutting per-guest wake cost (silkd cold-start slimming, mmap first-touch prefetch) or spreading concurrency across more nodes. Box verified clean afterward (pty restored, prod 7 CH + sandboxd intact throughout).
CMGS commentedon Jul 26, 2026
Ledger entries from the cross-node snapshot placement branch (#52), measured on bare metal (Ryzen 9700X, linux/amd64):
Record-lock acquisition is now globally serialized.
recLockgained a reference count guarded by onerecLocksMuso eviction cannot strand a waiter on a dead mutex (the lock-identity split). Cost, measured head-to-head against the old baresync.Mapshape at 16 threads: 2.2ns → ~155ns per acquisition across distinct ids (~6.4M acquisitions/s ceiling). Accepted because every caller sits on 77ms+ operations (checkpoint branch, template cold provision, delete) and the warm-claim path never touchesrecLock.recLockon the claim path.Ownership probing is O(peers) per distinct cold id. The per-id positive cache (5s) and singleflight kill the measured hot-id amplification (10 misses × 20 peers: 200 → 20 HEADs), but a stream of misses for distinct ids still fans out once each. Inherent query cost of gossip-free ownership; noted so nobody rediscovers it as a surprise.
Closed by the same branch, for the record: the 1s gossip tick no longer lists the checkpoint store (was 15.4ms/tick at 1000 records), and
NodeStateno longer carries an unbounded checkpoint id list at all (was ~881KB wire + 4.9ms marshal/unmarshal per tick at 2000 ids/node × 20 nodes).CMGS commentedon Jul 26, 2026
Static-analysis round over sandboxd + silkd + boot/init (2026-07-27). Fixes shipped in #53 (template-hash sort: epoch churn +19/min → 0; silkd chunk-render zero-fill: micro 1.77x, fs_pull 963-969 vs 843-949 MiB/s A/B). Ledger entries from the same round — noted, not taken:
silkd inbound BufReader (64KB) vs base64-expanded BULK_CHUNK (~349KB): ~6 read syscalls per inbound bulk frame. Enlarging to one-frame capacity was prototyped on paper and rejected for now: every RPC dials a fresh vsock conn (the wire-v1 triple-setup), so a ~349KB per-conn buffer moves allocation/fault cost onto every exec RTT to buy single-digit-% on fs_push. Correct home: wire protocol v2 (item 1) — a long-lived multiplexed conn makes the big inbound buffer free. Fold it in there.
Guest wake ~240ms: no silkd-code lever. Read of the full crate confirms silkd does no per-resume work (no watchers/timers/caches rebuilt; State::new is empty maps; listener accepts immediately). The wake cost is page fault-in + guest kernel, matching the R2 CPU-bound conclusion. "silkd cold-start slimming" as a code item can be closed; remaining levers are image resident-set size and fleet spreading.
Smaller notes, each with its reopen trigger:
randHexCSPRNG draws per claim (stampIdentity) mergeable into one — µs-class; fold into any future claim-path touch.handleClaimcomputeskey.Hash()eagerly; warm-hit success path never consumes it — ~0.3µs/claim.overQuotaprecheck + warm-pop are adjacent separatem.muacquisitions — ns-class merge.refillOncecatch-up rescans all pools once per spawned refill underm.mu(O(deficit × pools)) — µs-class, deliberate round-robin fairness; trigger: many pools + large deficits + high core count showing claim-lock contention.Publishpays an unconditional LIST+diff even on first publish — ~20-100ms on an already-seconds operation; not worth the plumbing.PushPullInterval=1sis the sole state-propagation channel (no broadcasts) — O(peers × catalog) JSON per node per second; trigger: large fleets, then move to delta broadcasts.clear()s all 1024 entries on overflow — trigger: single-sandbox workloads spanning >1024 intercepted hosts.Requestinternally-tagged serde decode double-passes every frame (~tens-hundreds ns) — dies with wire v2.fs.findrecompiles its regex per call — LRU only if the verb shows up in real load.boot/init: no material findings — cold-boot criticality is kernel + guest-ready as already ledgered; init's own path is µs-to-low-ms with correct fault-path degradation (none-lane carries no ip= params and skips the NIC wait entirely).
CMGS commentedon Jul 26, 2026
SDK/protocol/mcp/python round (same static pass, fixes in #53: dial-path allocs 16→2, shared NewFrameScanner, ReadFile concat, TCP_NODELAY, FS_CHUNK 256K). Noted, not taken:
Connection: closeby design) — ~1 TCP handshake per claim/release/etc., compounding in_redirect_fallback(up to 4 chained) andlookupprobes. The SDK is deliberately stdlib-only; a hand-rolled persistenthttp.client.HTTPConnectionwith stale-conn retry is real complexity for a control-plane path. Trigger: Python-client claim latency ever matters at scale, or the stdlib-only constraint is relaxed..read()s the whole stream too) vs Go's io.Reader/Writer streaming. Fix is an API-surface addition (Iterable[bytes]/sink overloads). Trigger: workspace archives in the hundreds of MB.map[string]anyenvelopes, a 5-min timerCtx per tool call incl. metadata-only tools,append(out,'\n')possible realloc per reply. All µs-class on an LLM-cadence path — not worth motion.context.AfterFuncregistered twice per RPC (dial + handshake windows) — load-bearing for cancel-without-deadline; merging risks cancellation semantics for 2 allocs. Rejected.CMGS commentedon Aug 11, 2026
Deferred items from the template-content-identity round:
CMGS commentedon Sep 14, 2026
Rows from the SOCKS5 / locked-NIC batch (#164, #165, #168), measured on cocoon-test1 with both arms interleaved A-B-B-A in one run:
{"host": ...}rule counts) binds a second unix socket per claim (unlink+socket+bind+listen). µs driver, n=96 per arm, 3 rounds: no policy 432 vs 442 µs (A/A drift 14–49 µs, no effect); bare-rule policy 496 vs 532 µs, +36 µs ≈ 7 % (A/A drift 7–18 µs). This is the only measured hot-path cost of the batch and the argument for making the SOCKS5 door opt-in per pool or tenant rather than implied by the rule shape.routes_directly()adds onestat("/etc/silkd-nic-locked")per spawned process on a guest with a NIC and, when the marker exists, the proxy variables in the child's env; the none lane short-circuits and pays nothing. execab driver, 500 execs of/bin/trueper arm, 3 rounds, host under another session's 1000-VM clone fill (load 45–80): egress lane p50 750 vs 745 µs, per-round A/A drift 11–18 µs; none lane 775 vs 767 µs, A/A drift 1–33 µs. Below the noise floor; not a ledger item.CMGS commentedon Sep 14, 2026
Closing after a full re-verification of every item in this thread against
main(ca6d08f, 2026-09-14). Ledger, one line per item:no_direct_io, deploy.md). Staging fsync + tmpfs run dir + empty CNI dir — done (cocoon os-image: complete the desktop flavor's application set and pin what it installs #160, deploy.md recipe). VM-name suffix collisions — done (6ba90d6). Generation-addressed dir store — done (store/dir: generation-addressed records to replace cross-process flocks #63). Promote-path digest emission — done (store-owned v2 chunked export digest: PublishDigested with dir parallel hash and s3 upload tee #64, cocoon desktop: write an xauth entry for :1 once Xvfb is up #193). s3 Publish list+diff — resolved by the generation rework.The headline numbers of record now live in
docs/benchmarks.mdanddocs/performance.md; the C1M campaign measurements stay in this thread and #172 points back here for them.