Repository navigation
fix: what a bare-metal comparison turned up (gateway waits, Firecracker cold start, port overlap, guest hostname) - #380
Merged
Merged
Conversation
A request for a sandbox that is up but not yet listening is held while the gateway retries the connect, until BOOT_WAIT. The retries backed off from 50 ms to 500 ms, so the first answer came up to 500 ms after the program was listening, and the caller's own request was the one held. Measured on bare metal (2x Xeon E5-2637 v2, runsc), an app64 timestamping its own startup: listening at about 540 ms, answered at about 890 -- 300 to 370 ms later. Docker with the same runsc answered 6 ms after listening. A refused connect to the sandbox's address costs microseconds, so the pauses now start at 5 ms and stop doubling at 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s from Every master defaults to 20000-32000 and allocates without knowing of any other, while the gateways bind on the node's machine. On bare metal a gVisor and a Firecracker master-and-node both handed out 20000, and the second sandbox's gateway logged `Address already in use` every 25 s while its caller waited. A master-and-node now records its range under /run/sandstorm/gateway-ports and refuses to start on one that overlaps a live process's, naming it and the flags to fix it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kernel names a guest after its IP when ip= gives none, and resolving that goes to a resolver a sandbox usually cannot reach. Python's HTTPServer resolves the hostname before it listens, so every Firecracker sandbox's first answer waited out the timeout: 20 s on bm. The init now names it `sandbox` and puts that in /etc/hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Traced on bare metal (2x Xeon E5-2637 v2), a cold Firecracker web sandbox took 1.95 s to its first answer. Four things, each found once the one before was out of the way: - The guest kernel waited for a PS/2 controller Firecracker does not emulate. Firecracker's own i8042 options are now in the boot args: boot to init 0.77 -> 0.29 s on bm, 0.32 -> 0.22 s here. - The agent imported numpy in PID 1, as a demo of forking from warmed state, before starting the template's program. It is imported on first `eval` now, and its version read from the package metadata. - The gateway's connect to the sandbox waited for the kernel to resend a dropped SYN, a second later. It sends a fresh one every 50 ms. - Above all, the netns's first packet to a guest still booting asked ARP for it, got no answer, and asked again only a second later; every SYN queued behind it. tcpdump showed them all leave at +1,101 ms. The guest's MAC is fixed, so its neighbour entry now is too (`nud permanent`). Cold web, create to first answer: 1,953 -> about 700-850 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A restored guest has the tap it was snapshotted beside in its ARP cache, and every sandbox has its own tap with a random MAC. The guest kept sending to the seed's tap until its entry went stale. That was masked while the netns asked ARP for the guest, because the guest updates its cache from the request; once the previous commit pinned the guest's entry and stopped asking, every warm start on bare metal took 43 s and the clock sync timed out. The tap now has a fixed MAC, like the guest. Warm starts are back to 133 ms (web) and 183 ms (app64), and no clock-sync warnings appear since the restart. Cold starts are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e agent Two more steps off a cold start, each measured on its own on bare metal (2x Xeon E5-2637 v2, 1 vCPU guests, 10 starts each, p50): - `quiet` on the kernel command line. Every byte the kernel prints to ttyS0 is a VM exit through Firecracker's emulated serial port, and a boot printed 273 lines. Web 861 -> 652 ms, app64 1,167 -> 1,066 ms. - The agent starts the template's program before it imports anything it does not need for that, with posix_spawn instead of subprocess. Its own start (pty, socket, threading and the rest) used to come first, on the same single vCPU. Web 652 -> 636 ms, app64 1,066 -> 964 ms. Tried and not kept: two vCPUs per guest. Web 636 -> 597 ms, app64 unchanged and its p90 worse, for a second vCPU thread in every VM; `--fc-vcpus` is there for whoever wants it. Warm starts are unchanged (143 / 182 ms), with no warnings on the node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oy before it Traced on bare metal (2x Xeon E5-2637 v2), a warm Firecracker web start one after another took 104-126 ms against CubeSandbox's 72. In order: - Renaming an interface took 6-11 ms holding the RTNL lock, and a claim renamed two. A claimed namespace now keeps its pooled veth's names, recorded under /run/sandstorm/veth for everything that reaches them by name (setup on an existing namespace, teardown); sandboxes from before have no record and keep their derived names. - Naming and addressing the veth runs beside the restore for Firecracker, whose guest is on the tap (`net::Pending`, `Backend::veth_needed_to_start`; runsc takes the veth as it finds it at boot, so it still waits). - Deleting a namespace holds the kernel's networking locks for a long while. A destroyed sandbox's host end is brought down, freeing its address at once, and its namespace renamed out of the way and handed to the pool, which deletes it when nothing is being created (`Pool::retire`). - The node ran Destroy on its command loop, so a create right behind it waited out the whole destroy: 25-35 ms. Destroy now runs on its own thread holding the sandbox's lock, a repeat while it runs is a no-op, and its address is freed (`net::quiesce`) before the loop moves on. - The master hands a deleted sandbox's gateway port straight on, while its listener could still be up: one create in five could not bind and waited a second for the next report. A new sandbox now takes the port over, and the old one, still reported, is skipped. - Back-to-back creates never leave the pool the quiet spell it waits for, and a pool of 32 ran dry: below a quarter it now refills regardless. - A pooled host end's name now counts when a stale veth holds a slot. Warm start p50, 20 one after another: web 129 -> 57 ms, app64 199 -> 98 ms (CubeSandbox 72 and 78). Fork from a tag until all answer: 10 at once 831 -> 487 ms, 20 at once 1,510 -> 924 ms. Tried and not kept: loading without dirty-page tracking. The first request to app64 faulted in its heap 3x faster, but back to back app64 did not start faster, and every branch became a Full snapshot: 52 ms -> 0.5-2.7 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not KVM A restored VM ran with KVM dirty logging so its next checkpoint could be a diff, and every page it touched first faulted in on its own: a warm app64, which reads its 64 MiB heap on the first request, answered in 103 ms on bm against 65 ms without it. Restores now load with enable_diff_snapshots off. The pages a diff needs are tracked by the kernel instead: clear_refs before the guest resumes, and at the checkpoint the soft-dirty pages of the mapped memory file, found by device and inode in /proc/<pid>/maps, are copied from /proc/<pid>/mem into the sparse mem.N. Firecracker writes only the vmstate, to a snapshot whose memory file is /dev/null: it writes the vmstate first and stops at sizing the memory file. CubeSandbox snapshots the same way. A cold-booted VM keeps KVM tracking. On bm: warm app64 103 -> 67 ms back to back, branch 52 -> 25 ms; ten chained branches of an app64 writing 8 MiB each, across a Full rollover, all forked back identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he pool runs dry Two faults under back-to-back starts on bm. The refill's wait for quiet was extended by every take and never looked at how low the pool was, so it ran on until the pool was empty: the 33rd start took 185-236 ms, not 70. Waking it in time then built namespaces under load, which put about 10 ms on every start. The wait now asks on every pass whether the pool is low, behind on deletes, or has a namespace to reuse. A retired namespace is made ready again instead of being deleted and a new one built: its end brought down, which takes its routes, its conntrack table flushed so no late answer to an old connection reaches the next guest, and renamed into the pool. The next claim gives it its address and route as before. On bm, app64 warm through the comparison harness: 124-136 ms p50 (the cliff) -> 70-73 ms; web 57-65 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang
added a commit
that referenced
this pull request
Oct 30, 2026
Cold 308/624 -> 234/545 ms, warm 168/186 -> 107/123 ms (web/app64); clone 20 ~2,760 -> ~2,280 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang
added a commit
that referenced
this pull request
Oct 30, 2026
…gVisor and containers on one KVM machine (#384) * bench(baremetal): Sandstorm against other sandboxes on one KVM machine compare.py measures start to first answer and memory per instance kept running, for the same two programs, on: a plain process, bubblewrap, Docker (runc), Podman, Docker + gVisor, Kata 4.2, microsandbox 0.7.8, CubeSandbox 0.7.3, and Sandstorm on gVisor and on Firecracker, cold and from a warm template. The runtimes from bench/fleet/baselines.py are reused as they are. fork.py, fork_msb.py and fork_cube.py measure snapshotting a running sandbox and cloning it, each the way its own API does it. setup.sh brings up the two Sandstorm nodes, with their own port ranges, and microsandbox's images. Results on 2x Xeon E5-2637 v2 in results/2026-10-30, with the raw rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * bench(baremetal): Sandstorm Firecracker after soft-dirty snapshots and the pool fix Warm start 129/199 -> 62/74 ms (web/app64), ahead of CubeSandbox's 72/78; snapshot 52 -> 27 ms; clone 20 ~1,562 -> ~980 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * bench(baremetal): Sandstorm gVisor re-measured with #380 Cold 308/624 -> 234/545 ms, warm 168/186 -> 107/123 ms (web/app64); clone 20 ~2,760 -> ~2,280 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): bare-metal comparison against CubeSandbox, microsandbox, Kata and Docker, with two charts bench/baremetal/charts.py draws start-and-memory and clone charts from the results directory with the fleet charts' palette and primitives. CubeSandbox's clone row is added to fork.jsonl as fork_cube.py printed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): Firecracker badge and backend in the intro; gVisor-only setup and fleet chart for the 2c2g comparison Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): gVisor and Firecracker logos on their badges Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): gVisor and Firecracker badges without the ISOLATION label Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
WaylandYang
added a commit
that referenced
this pull request
Oct 30, 2026
…-30 results (#388) * bench: start times on the six 2-core, 2 GB nodes with the new build, replacing the one-VM table Every runtime started one at a time on each of six cloud nodes, the median of their p50s, drawn by bench/baremetal/charts.py. charts.py for the fleet now reads a round's layout from its builds.json, so a new round can be drawn without renaming its directories; 2026-10-28 draws unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): badges as files in the repo, and the backend described as each node's own GitHub's image proxy would not show the shields.io badges with an embedded logo; the same SVGs are now in docs/assets/badges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): shorter, one chart, and only numbers from this round The figures and tables measured before #376, #380 and #385 go, the capacity headline with them until the fills are rerun. bench/fleet/results/2026-10-30 holds the traffic, wake, herd and wall runs on the six nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): console screenshots of the current console Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
These came out of comparing Sandstorm with other sandboxes on a bare-metal machine with KVM (2× Xeon E5-2637 v2, 16 threads, 31 GB). Each one was found by tracing a number that did not add up.
gateway.rs). A request for a sandbox whose program was not listening yet was held while the gateway retried, backing off from 50 ms up to 500 ms. That held the first answer of an app64 300–370 ms after the program was already listening; on the same machine, Docker with the same runsc answered 6 ms after listen. Retries now start at 5 ms and stop doubling at 50 ms. New test: a program that starts listening at 400 ms must be answered within 600 ms (it fails with the old backoff). Sandstorm on gVisor, cold app64: 941 → 616 ms, now ahead of Docker + gVisor (660 ms).boot_args' test.eval, and reads the version from package metadata.connect_upstream).ip neigh … nud permanentinensure_tap, which pooled namespaces also get).quieton the kernel command line. Every byte printed to ttyS0 costs a VM exit through the emulated serial port, and a boot printed 273 lines. Web 861 → 652 ms, app64 1,167 → 1,066 ms.posix_spawnbefore importing anything else, so the program no longer waits behind the agent's own startup on the single vCPU. Web 652 → 636 ms, app64 1,066 → 964 ms.sandstorm-init.sh). The kernel names a guest after its IP, and Python'sHTTPServerresolves the hostname before it listens. With no resolver reachable, that waited out a timeout: 20 s per Firecracker sandbox on bm. The init now names the guestsandboxand writes that name to/etc/hosts.main.rs). Two master-and-node processes on one machine both allocated gateway ports from 20000, so the second sandbox on a taken port never became reachable. A master-and-node now records its range under/run/sandstorm/gateway-portsand refuses to start if it overlaps a running process's range, naming that process and the--port-minand--port-maxflags to fix it.Tests
cargo test208 passed, clippy clean, and agent unittest 32 passed.detach_teststhat also fail on unmodified dev there.Each change was also checked on the machine itself: the port refusal message, tcpdump before and after the neighbour entry, and start times before and after.
🤖 Generated with Claude Code