From 75874bacd04c1e5a2dff21d03e6c645448317616 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Thu, 21 May 2026 13:11:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20\`forkd=20bench\`=20=E2=80=94=20qu?= =?UTF-8?q?ick=20latency=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to \`forkd doctor\` (#134). Runs a representative spawn → exec → branch(diff=true) → fanout(N) → cleanup cycle against a live daemon and prints per-step timing. Output is screenshot-friendly. Use case: "is forkd actually fast on this box?" — answer in 1 command, without writing a benchmark script. Also useful for regression checks after a daemon config change. Example output: forkd bench against snapshot coding-agent-fork-prewarm-v1 fanout n=5 per_child_netns=true spawn (n=1) 123 ms sb-...-0001 exec round-trip 8 ms exit=0 branch (diff=true) 287 ms pause_ms=234 diff_physical_bytes=393216 fanout (n=5) 142 ms 28ms/child cleanup 45 ms 6 sandboxes ----- total 605 ms Implementation: ~210 LOC in crates/forkd-cli/src/bench.rs. Uses ureq directly (small wrapper) instead of pulling reqwest for one command. Subcommand wired in main.rs with --tag / --n / --per-child-netns / --daemon-url / --daemon-token options. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forkd-cli/src/bench.rs | 232 ++++++++++++++++++++++++++++++++++ crates/forkd-cli/src/main.rs | 34 +++++ 2 files changed, 266 insertions(+) create mode 100644 crates/forkd-cli/src/bench.rs diff --git a/crates/forkd-cli/src/bench.rs b/crates/forkd-cli/src/bench.rs new file mode 100644 index 0000000..8096626 --- /dev/null +++ b/crates/forkd-cli/src/bench.rs @@ -0,0 +1,232 @@ +//! `forkd bench` — quick latency probe against a live daemon. +//! +//! Runs a representative spawn → exec → branch → fanout → cleanup +//! cycle and prints per-step timing. The point is to answer +//! "is forkd actually fast on YOUR box?" without making the user +//! cook up a benchmark themselves. Output is screenshot-friendly. + +use anyhow::{Context, Result}; +use std::time::{Duration, Instant}; + +pub fn run( + daemon_url: &str, + daemon_token: Option, + tag: Option, + fanout_n: usize, + netns: bool, +) -> Result<()> { + let client = Client::new(daemon_url, daemon_token); + + // 1) Pick a snapshot. + let tag = match tag { + Some(t) => t, + None => { + let snaps = client.list_snapshots()?; + let first = snaps + .iter() + .filter_map(|v| v.get("tag").and_then(|t| t.as_str())) + .next() + .ok_or_else(|| { + anyhow::anyhow!("no snapshots on the daemon; build one with `forkd snapshot`") + })?; + first.to_string() + } + }; + println!("forkd bench against snapshot \x1b[1m{tag}\x1b[0m"); + println!(" fanout n={fanout_n} per_child_netns={netns}\n"); + + let total_start = Instant::now(); + + // 2) Spawn 1 source sandbox. + let t = Instant::now(); + let source = client.spawn_one(&tag)?; + let spawn_ms = t.elapsed().as_millis(); + let source_id = source + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("spawn response missing id: {source}"))? + .to_string(); + print_row("spawn (n=1)", spawn_ms, &source_id); + + // 3) Exec round-trip (sh -c echo). + let t = Instant::now(); + let exec = client.exec(&source_id, &["sh", "-c", "echo bench"])?; + let exec_ms = t.elapsed().as_millis(); + let exit_code = exec.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1); + print_row("exec round-trip", exec_ms, &format!("exit={exit_code}")); + + // 4) Diff BRANCH. + let t = Instant::now(); + let branch = client.branch_diff(&source_id)?; + let branch_client_ms = t.elapsed().as_millis(); + let branch_tag = branch + .get("tag") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("branch response missing tag: {branch}"))? + .to_string(); + let pause_ms = branch.get("pause_ms").and_then(|v| v.as_u64()).unwrap_or(0); + let diff_bytes = branch + .get("diff_physical_bytes") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + print_row( + "branch (diff=true)", + branch_client_ms, + &format!("pause_ms={pause_ms} diff_physical_bytes={diff_bytes}"), + ); + + // 5) Fanout N grandchildren from the branch. + let t = Instant::now(); + let kids = client.spawn_many(&branch_tag, fanout_n, netns)?; + let fanout_ms = t.elapsed().as_millis(); + let per_child = if fanout_n > 0 { + fanout_ms / fanout_n as u128 + } else { + 0 + }; + print_row( + &format!("fanout (n={fanout_n})"), + fanout_ms, + &format!("{per_child}ms/child"), + ); + + // 6) Cleanup. + let t = Instant::now(); + let kid_ids: Vec = kids + .iter() + .filter_map(|k| k.get("id").and_then(|v| v.as_str()).map(String::from)) + .collect(); + for k in &kid_ids { + let _ = client.kill(k); + } + let _ = client.kill(&source_id); + let cleanup_ms = t.elapsed().as_millis(); + print_row( + "cleanup", + cleanup_ms, + &format!("{} sandboxes", kid_ids.len() + 1), + ); + + let total_ms = total_start.elapsed().as_millis(); + println!(" -----"); + println!(" \x1b[1m{:<22}{:>5} ms\x1b[0m", "total", total_ms); + Ok(()) +} + +fn print_row(name: &str, ms: u128, detail: &str) { + println!(" {:<22}{:>5} ms \x1b[90m{}\x1b[0m", name, ms, detail); +} + +// ---------------------------------------------------------------------- +// HTTP client — small wrapper around ureq for the few endpoints we need. +// Avoids pulling reqwest just for the bench command. +// ---------------------------------------------------------------------- + +struct Client { + agent: ureq::Agent, + base: String, + token: Option, +} + +impl Client { + fn new(base: &str, token: Option) -> Self { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(60)) + .build(); + Self { + agent, + base: base.trim_end_matches('/').to_string(), + token, + } + } + + fn req(&self, method: &str, path: &str) -> ureq::Request { + let mut r = self.agent.request(method, &format!("{}{path}", self.base)); + if let Some(t) = &self.token { + r = r.set("Authorization", &format!("Bearer {t}")); + } + r.set("Content-Type", "application/json") + } + + fn list_snapshots(&self) -> Result> { + let resp = self + .req("GET", "/v1/snapshots") + .call() + .map_err(map_ureq_err)?; + let v: serde_json::Value = parse_json_resp(resp).context("parse snapshots")?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + + fn spawn_one(&self, tag: &str) -> Result { + let body = serde_json::json!({"snapshot_tag": tag, "n": 1}); + let resp = self + .req("POST", "/v1/sandboxes") + .send_string(&body.to_string()) + .map_err(map_ureq_err)?; + let v: serde_json::Value = parse_json_resp(resp).context("parse spawn")?; + v.as_array() + .and_then(|a| a.first().cloned()) + .ok_or_else(|| anyhow::anyhow!("spawn returned empty array: {v}")) + } + + fn spawn_many( + &self, + tag: &str, + n: usize, + per_child_netns: bool, + ) -> Result> { + let body = serde_json::json!({ + "snapshot_tag": tag, + "n": n, + "per_child_netns": per_child_netns + }); + let resp = self + .req("POST", "/v1/sandboxes") + .send_string(&body.to_string()) + .map_err(map_ureq_err)?; + let v: serde_json::Value = parse_json_resp(resp).context("parse spawn_many")?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + + fn exec(&self, id: &str, args: &[&str]) -> Result { + let body = serde_json::json!({"args": args, "timeout_secs": 5}); + let resp = self + .req("POST", &format!("/v1/sandboxes/{id}/exec")) + .send_string(&body.to_string()) + .map_err(map_ureq_err)?; + parse_json_resp(resp).context("parse exec") + } + + fn branch_diff(&self, id: &str) -> Result { + let body = serde_json::json!({"diff": true}); + let resp = self + .req("POST", &format!("/v1/sandboxes/{id}/branch")) + .send_string(&body.to_string()) + .map_err(map_ureq_err)?; + parse_json_resp(resp).context("parse branch") + } + + fn kill(&self, id: &str) -> Result<()> { + self.req("DELETE", &format!("/v1/sandboxes/{id}")) + .call() + .map_err(map_ureq_err)?; + Ok(()) + } +} + +fn parse_json_resp(resp: ureq::Response) -> Result { + // ureq 2.x is built without the `json` feature here; parse the + // body string ourselves. + let body = resp.into_string().context("read response body")?; + serde_json::from_str(&body).with_context(|| format!("parse JSON: {body}")) +} + +fn map_ureq_err(e: ureq::Error) -> anyhow::Error { + match e { + ureq::Error::Status(code, r) => { + let body = r.into_string().unwrap_or_default(); + anyhow::anyhow!("daemon HTTP {code}: {body}") + } + e => anyhow::anyhow!("daemon transport: {e}"), + } +} diff --git a/crates/forkd-cli/src/main.rs b/crates/forkd-cli/src/main.rs index 03c82fe..a62760e 100644 --- a/crates/forkd-cli/src/main.rs +++ b/crates/forkd-cli/src/main.rs @@ -10,6 +10,7 @@ //! //! Snapshots live under $XDG_DATA_HOME/forkd/snapshots//. +mod bench; mod doctor; mod hub; @@ -270,6 +271,32 @@ enum Cmd { #[arg(long, env = "FORKD_TOKEN")] daemon_token: Option, }, + /// Quick latency probe against a live daemon. Runs a representative + /// spawn → exec → branch (diff=true) → fanout → cleanup cycle and + /// prints per-step timing. Screenshot-friendly output. + /// + /// Useful for: "is forkd actually fast on this box?", regression + /// checks after a config change, and showing the v0.3 numbers + /// reproduce on your hardware. + Bench { + /// Snapshot tag to spawn from. Defaults to the first snapshot + /// the daemon knows about. + #[arg(long)] + tag: Option, + /// Fanout: how many grandchildren to spawn from the BRANCH. + #[arg(long, default_value_t = 5)] + n: usize, + /// Per-child netns for the fanout. Defaults to true (the + /// fanout will fail without per-child netns when n > 1). + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + per_child_netns: bool, + /// Controller daemon base URL. + #[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")] + daemon_url: String, + /// Bearer token for the controller daemon (matches `--token-file`). + #[arg(long, env = "FORKD_TOKEN")] + daemon_token: Option, + }, /// Remove orphaned `/tmp/forkd-{fork,parent}-*` work directories. /// /// Each `forkd fork` / `forkd snapshot` creates a temp work dir holding @@ -543,6 +570,13 @@ fn main() -> Result<()> { daemon_url, daemon_token, } => doctor::run(&daemon_url, daemon_token), + Cmd::Bench { + tag, + n, + per_child_netns, + daemon_url, + daemon_token, + } => bench::run(&daemon_url, daemon_token, tag, n, per_child_netns), Cmd::Cleanup { yes } => cleanup_cmd(yes), Cmd::Push { tag,