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
4 changes: 4 additions & 0 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ harness = false
name = "file_ops"
harness = false

[[bench]]
name = "remote_fs"
harness = false

[[bench]]
name = "awk_regex"
harness = false
Expand Down
226 changes: 226 additions & 0 deletions crates/bashkit/benches/remote_fs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! Tree-walking builtins over a simulated remote filesystem.
//!
//! Remote VFS backends (a database, an object store, a network share) pay
//! one round trip per `FileSystem` call, ~10 ms in production. What makes a
//! command slow there is its call count, not CPU. This bench loads a real
//! tree, the bashkit checkout itself (`git ls-files`, ~3.7k files), into a
//! `CountingFs` and:
//!
//! 1. prints, per command and backend, the call counts and the time they
//! would cost at 10 ms per call (`--counts` or always, before timing);
//! 2. times each command with a simulated per-call latency
//! (`BASHKIT_BENCH_LATENCY_US`, default 100 us) under criterion.
//!
//! Backends: `plain` (no index, `walk` = `read_dir` per directory), `index`
//! (`SearchCapable` content index), `index+walk` (index plus a one-call bulk
//! `walk`, like a path-prefix query).
//!
//! Run with: `cargo bench --bench remote_fs`
//! Counts only: `cargo bench --bench remote_fs -- --counts`

use bashkit::testing::{CallCounts, CountingFs};
use bashkit::{Bash, FileSystem, FsLimits};
use criterion::{BenchmarkId, Criterion, criterion_group};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Runtime;

/// Per-call latency the production report is about.
const REMOTE_CALL_MS: f64 = 10.0;
/// Files above this are skipped (generated fixtures, lockfiles).
const MAX_FILE_BYTES: u64 = 1 << 20;

/// Commands under test, run with cwd `/workspace` (the checkout root).
const COMMANDS: &[&str] = &[
// The production report's commands (no match in this checkout: worst case).
"rg -l markkit crates",
"grep -rln markkit crates",
"find /workspace -path '*markkit*'",
// A rare match and a common one.
"rg -l SearchCapable crates",
"rg -l --no-ignore SearchCapable crates",
"grep -rl SearchCapable crates",
"rg -c fn crates/bashkit/src/builtins",
"find crates -type f -name '*.rs'",
"find crates -ls",
"ls -R crates",
"du -s crates",
"tree crates",
"tar -cf /tmp/src.tar crates/bashkit/src/builtins",
"cp -r crates/bashkit/src/builtins /tmp/builtins-copy",
];

#[derive(Clone, Copy)]
enum Backend {
Plain,
Index,
IndexWalk,
}

impl Backend {
const ALL: [Backend; 3] = [Backend::Plain, Backend::Index, Backend::IndexWalk];

fn name(self) -> &'static str {
match self {
Backend::Plain => "plain",
Backend::Index => "index",
Backend::IndexWalk => "index+walk",
}
}

fn fs(self) -> CountingFs {
let fs = CountingFs::new();
match self {
Backend::Plain => fs,
Backend::Index => fs.with_index(),
Backend::IndexWalk => fs.with_index().with_native_walk(),
}
}
}

/// Repository root and its tracked files.
fn checkout_files() -> (PathBuf, Vec<String>) {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let out = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["ls-files", "-z"])
.output()
.expect("git ls-files (the bench needs a git checkout)");
let files = out
.stdout
.split(|b| *b == 0)
.filter(|f| !f.is_empty())
.map(|f| String::from_utf8_lossy(f).into_owned())
.collect();
(root, files)
}

/// Copy the checkout into a fresh backend under `/workspace`; `latency`
/// applies after seeding.
fn load(rt: &Runtime, backend: Backend, latency: Duration) -> (Arc<CountingFs>, usize) {
let (root, files) = checkout_files();
let fs = Arc::new(backend.fs().with_limits(FsLimits::unlimited()));
let mut loaded = 0;
rt.block_on(async {
// `.git` marks the tree as a repository, so `.gitignore` applies.
fs.mkdir(Path::new("/workspace/.git"), true).await.unwrap();
for rel in &files {
let host = root.join(rel);
let Ok(meta) = std::fs::symlink_metadata(&host) else {
continue;
};
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
continue;
}
let Ok(content) = std::fs::read(&host) else {
continue;
};
let path = PathBuf::from(format!("/workspace/{rel}"));
if let Some(parent) = path.parent() {
fs.mkdir(parent, true).await.unwrap();
}
fs.write_file(&path, &content).await.unwrap();
loaded += 1;
}
});
fs.set_latency(latency);
(fs, loaded)
}

fn bash_for(fs: &Arc<CountingFs>) -> Bash {
let fs_dyn: Arc<dyn FileSystem> = fs.clone();
Bash::builder()
.fs(fs_dyn)
.cwd("/workspace")
.limits(bashkit::ExecutionLimits::default().timeout(Duration::from_secs(600)))
.build()
}

fn run_once(rt: &Runtime, fs: &Arc<CountingFs>, cmd: &str) -> (CallCounts, Duration) {
let mut bash = bash_for(fs);
rt.block_on(async {
let _ = bash.exec("rm -rf /tmp/src.tar /tmp/builtins-copy").await;
});
fs.reset();
let start = std::time::Instant::now();
let r = rt.block_on(bash.exec(&format!("{cmd} > /dev/null")));
let elapsed = start.elapsed();
if let Ok(r) = &r
&& r.exit_code > 1
{
eprintln!("warning: `{cmd}` exited {}: {}", r.exit_code, r.stderr);
}
(fs.counts(), elapsed)
}

/// Markdown table of call counts and projected remote time.
fn print_counts(rt: &Runtime) {
println!("\n## Round trips per command (bashkit checkout, cwd = repo root)\n");
let mut header_done = false;
for backend in Backend::ALL {
let (fs, loaded) = load(rt, backend, Duration::ZERO);
if !header_done {
println!("{loaded} files loaded. Projected time = calls x {REMOTE_CALL_MS} ms.\n");
println!(
"| command | backend | calls | read_file | read_dir | stat+lstat+exists | walk | search | at 10 ms |"
);
println!("|---|---|---:|---:|---:|---:|---:|---:|---:|");
header_done = true;
}
for cmd in COMMANDS {
let (c, _) = run_once(rt, &fs, cmd);
println!(
"| `{cmd}` | {} | {} | {} | {} | {} | {} | {} | {:.1} s |",
backend.name(),
c.total(),
c.read_file,
c.read_dir,
c.metadata(),
c.walk,
c.search,
c.total() as f64 * REMOTE_CALL_MS / 1000.0
);
}
}
println!();
}

fn latency() -> Duration {
let us = std::env::var("BASHKIT_BENCH_LATENCY_US")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
Duration::from_micros(us)
}

fn bench_remote(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let latency = latency();
let mut group = c.benchmark_group(format!("remote_fs_{}us", latency.as_micros()));
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
for backend in Backend::ALL {
let (fs, _) = load(&rt, backend, latency);
for cmd in COMMANDS {
group.bench_with_input(BenchmarkId::new(backend.name(), cmd), cmd, |b, cmd| {
b.iter(|| run_once(&rt, &fs, cmd))
});
}
}
group.finish();
}

criterion_group!(benches, bench_remote);

fn main() {
let counts_only = std::env::args().any(|a| a == "--counts");
let rt = Runtime::new().unwrap();
print_counts(&rt);
if counts_only {
return;
}
benches();
Criterion::default().configure_from_args().final_summary();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# remote_fs bench, bashkit checkout (598ebb4 + this change)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Generated Criterion reports are committed (crates/bashkit/benches/results/criterion-remote_fs-*.md, e.g. the table with per-command round-trip counts). These are machine- and run-specific artifacts that will churn on every bench run. Fix: gitignore benches/results/ (or criterion/) and keep at most a hand-written summary in the PR/docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping these: the repo contract (AGENTS.md, Benches) requires saving criterion results under crates/bashkit/benches/results/ as criterion-<bench>-<moniker>-<timestamp>.md, so future runs have a baseline to diff against. Each run adds a new timestamped file, and existing files are never rewritten.


Generated by Claude Code


Host: cloud container, linux x86_64. Tree: `git ls-files` of this repo, 3,720 files under `/workspace` (a `.git` dir present so `.gitignore` applies). Command: `cargo bench --bench remote_fs`.

Call counts are the signal: on a remote backend each call is a round trip ("at 10 ms" column).

## Before (origin/main, same CountingFs, no `walk` hook)

| command | backend | calls | read_file | read_dir | stat+lstat+exists | walk | search | at 10 ms |
|---|---|---:|---:|---:|---:|---:|---:|---:|
| `rg -l markkit crates` | index | 176281 | 7101 | 1981 | 167199 | 0 | 0 | 1762.8 s |
| `grep -rln markkit crates` | index | 23709 | 3140 | 1982 | 18587 | 0 | 0 | 237.1 s |
| `find /workspace -path '*markkit*'` | index | 7831 | 0 | 2055 | 5776 | 0 | 0 | 78.3 s |
| `rg -l SearchCapable crates` | index | 176281 | 7101 | 1981 | 167199 | 0 | 0 | 1762.8 s |
| `rg -l --no-ignore SearchCapable crates` | index | 5116 | 3133 | 1981 | 2 | 0 | 0 | 51.2 s |
| `grep -rl SearchCapable crates` | index | 23709 | 3140 | 1982 | 18587 | 0 | 0 | 237.1 s |
| `rg -c fn crates/bashkit/src/builtins` | index | 1255 | 264 | 17 | 974 | 0 | 0 | 12.6 s |
| `find crates -type f -name '*.rs'` | index | 7105 | 0 | 1982 | 5123 | 0 | 0 | 71.0 s |
| `find crates -ls` | index | 9087 | 0 | 3964 | 5123 | 0 | 0 | 90.9 s |
| `ls -R crates` | index | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `du -s crates` | index | 7105 | 0 | 1982 | 5123 | 0 | 0 | 71.0 s |
| `tree crates` | index | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `tar -cf /tmp/src.tar crates/bashkit/src/builtins` | index | 463 | 212 | 17 | 233 | 0 | 0 | 4.6 s |
| `cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | index | 2785 | 0 | 17 | 2522 | 0 | 0 | 27.9 s |

## After

| command | backend | calls | read_file | read_dir | stat+lstat+exists | walk | search | at 10 ms |
|---|---|---:|---:|---:|---:|---:|---:|---:|
| `rg -l markkit crates` | plain | 5127 | 3135 | 1983 | 9 | 0 | 0 | 51.3 s |
| `grep -rln markkit crates` | plain | 5125 | 3140 | 1982 | 3 | 0 | 0 | 51.2 s |
| `find /workspace -path '*markkit*'` | plain | 2056 | 0 | 2055 | 1 | 0 | 0 | 20.6 s |
| `rg -l SearchCapable crates` | plain | 5127 | 3135 | 1983 | 9 | 0 | 0 | 51.3 s |
| `rg -l --no-ignore SearchCapable crates` | plain | 5116 | 3133 | 1981 | 2 | 0 | 0 | 51.2 s |
| `grep -rl SearchCapable crates` | plain | 5125 | 3140 | 1982 | 3 | 0 | 0 | 51.2 s |
| `rg -c fn crates/bashkit/src/builtins` | plain | 245 | 214 | 22 | 9 | 0 | 0 | 2.5 s |
| `find crates -type f -name '*.rs'` | plain | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `find crates -ls` | plain | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `ls -R crates` | plain | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `du -s crates` | plain | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `tree crates` | plain | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `tar -cf /tmp/src.tar crates/bashkit/src/builtins` | plain | 233 | 212 | 17 | 3 | 0 | 0 | 2.3 s |
| `cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | plain | 780 | 0 | 17 | 517 | 0 | 0 | 7.8 s |
| `rg -l markkit crates` | index | 3 | 0 | 0 | 2 | 0 | 1 | 0.0 s |
| `grep -rln markkit crates` | index | 2 | 0 | 0 | 1 | 0 | 1 | 0.0 s |
| `find /workspace -path '*markkit*'` | index | 2056 | 0 | 2055 | 1 | 0 | 0 | 20.6 s |
| `rg -l SearchCapable crates` | index | 33 | 15 | 8 | 9 | 0 | 1 | 0.3 s |
| `rg -l --no-ignore SearchCapable crates` | index | 16 | 13 | 0 | 2 | 0 | 1 | 0.2 s |
| `grep -rl SearchCapable crates` | index | 28 | 13 | 0 | 14 | 0 | 1 | 0.3 s |
| `rg -c fn crates/bashkit/src/builtins` | index | 242 | 210 | 22 | 9 | 0 | 1 | 2.4 s |
| `find crates -type f -name '*.rs'` | index | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `find crates -ls` | index | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `ls -R crates` | index | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `du -s crates` | index | 1983 | 0 | 1982 | 1 | 0 | 0 | 19.8 s |
| `tree crates` | index | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `tar -cf /tmp/src.tar crates/bashkit/src/builtins` | index | 233 | 212 | 17 | 3 | 0 | 0 | 2.3 s |
| `cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | index | 780 | 0 | 17 | 517 | 0 | 0 | 7.8 s |
| `rg -l markkit crates` | index+walk | 3 | 0 | 0 | 2 | 0 | 1 | 0.0 s |
| `grep -rln markkit crates` | index+walk | 2 | 0 | 0 | 1 | 0 | 1 | 0.0 s |
| `find /workspace -path '*markkit*'` | index+walk | 2 | 0 | 0 | 1 | 1 | 0 | 0.0 s |
| `rg -l SearchCapable crates` | index+walk | 33 | 15 | 8 | 9 | 0 | 1 | 0.3 s |
| `rg -l --no-ignore SearchCapable crates` | index+walk | 16 | 13 | 0 | 2 | 0 | 1 | 0.2 s |
| `grep -rl SearchCapable crates` | index+walk | 28 | 13 | 0 | 14 | 0 | 1 | 0.3 s |
| `rg -c fn crates/bashkit/src/builtins` | index+walk | 242 | 210 | 22 | 9 | 0 | 1 | 2.4 s |
| `find crates -type f -name '*.rs'` | index+walk | 2 | 0 | 0 | 1 | 1 | 0 | 0.0 s |
| `find crates -ls` | index+walk | 2 | 0 | 0 | 1 | 1 | 0 | 0.0 s |
| `ls -R crates` | index+walk | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `du -s crates` | index+walk | 2 | 0 | 0 | 1 | 1 | 0 | 0.0 s |
| `tree crates` | index+walk | 1982 | 0 | 1981 | 1 | 0 | 0 | 19.8 s |
| `tar -cf /tmp/src.tar crates/bashkit/src/builtins` | index+walk | 233 | 212 | 17 | 3 | 0 | 0 | 2.3 s |
| `cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | index+walk | 780 | 0 | 17 | 517 | 0 | 0 | 7.8 s |

## Criterion wall time, 100 us simulated latency per call

Run concurrently with a test build, so noisy. Walk-heavy rows are CPU-bound by `InMemoryFs::read_dir`, which scans every entry per call (O(entries x dirs)); that is the in-memory backend's cost, not round trips.

| bench | median |
|---|---:|
| `plain/rg -l markkit crates` | 3.1882 s |
| `plain/grep -rln markkit crates` | 2.9633 s |
| `plain/find /workspace -path '*markkit*'` | 2.5221 s |
| `plain/rg -l SearchCapable crates` | 3.2156 s |
| `plain/rg -l --no-ignore SearchCapable crates` | 3.0005 s |
| `plain/grep -rl SearchCapable crates` | 2.9670 s |
| `plain/rg -c fn crates/bashkit/src/builtins` | 67.001 ms |
| `plain/find crates -type f -name '*.rs'` | 2.3600 s |
| `plain/find crates -ls` | 2.3169 s |
| `plain/ls -R crates` | 2.4517 s |
| `plain/du -s crates` | 2.4584 s |
| `plain/tree crates` | 2.6678 s |
| `plain/tar -cf /tmp/src.tar crates/bashkit/src/builtins` | 48.890 ms |
| `plain/cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | 199.99 ms |
| `index/rg -l markkit crates` | 79.563 ms |
| `index/grep -rln markkit crates` | 80.336 ms |
| `index/find /workspace -path '*markkit*'` | 2.9004 s |
| `index/rg -l SearchCapable crates` | 94.123 ms |
| `index/rg -l --no-ignore SearchCapable crates` | 82.366 ms |
| `index/grep -rl SearchCapable crates` | 80.259 ms |
| `index/rg -c fn crates/bashkit/src/builtins` | 92.512 ms |
| `index/find crates -type f -name '*.rs'` | 2.8683 s |
| `index/find crates -ls` | 2.8529 s |
| `index/ls -R crates` | 2.9771 s |
| `index/du -s crates` | 3.0325 s |
| `index/tree crates` | 3.0087 s |
| `index/tar -cf /tmp/src.tar crates/bashkit/src/builtins` | 56.475 ms |
| `index/cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | 263.19 ms |
| `index+walk/rg -l markkit crates` | 80.651 ms |
| `index+walk/grep -rln markkit crates` | 78.429 ms |
| `index+walk/find /workspace -path '*markkit*'` | 2.6263 s |
| `index+walk/rg -l SearchCapable crates` | 96.695 ms |
| `index+walk/rg -l --no-ignore SearchCapable crates` | 81.962 ms |
| `index+walk/grep -rl SearchCapable crates` | 96.617 ms |
| `index+walk/rg -c fn crates/bashkit/src/builtins` | 96.170 ms |
| `index+walk/find crates -type f -name '*.rs'` | 2.5372 s |
| `index+walk/find crates -ls` | 2.5755 s |
| `index+walk/ls -R crates` | 2.8837 s |
| `index+walk/du -s crates` | 2.6339 s |
| `index+walk/tree crates` | 3.0670 s |
| `index+walk/tar -cf /tmp/src.tar crates/bashkit/src/builtins` | 51.691 ms |
| `index+walk/cp -r crates/bashkit/src/builtins /tmp/builtins-copy` | 249.46 ms |
Loading
Loading