Skip to content

Commit e493b9f

Browse files
perf(clone): stream cat-file into import via SDK ImportSession — 0.911s -> 0.754s (2.58x -> 2.22x) on codex
ImportSession holds one pooled connection and the dir-path->ino map across chunk calls; agentfs clone imports directories up front, then overlaps blob parsing with bounded-channel import chunks. Also de-flakes overlay_reads_flag_off test (global counter -> per-inode has_pending). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent a04bcff commit e493b9f

6 files changed

Lines changed: 254 additions & 100 deletions

.agents/specs/2026-06-11-per-phase-1-5x-roadmap-read-ttls-per-request-cost-native-bulk-ingest.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ until re-verified against codex.
3232

3333
| Phase | noopen off | Default (WS9) | +uring (opt-in) | Target |
3434
|---|---|---|---|---|
35-
| clone (plain FUSE) | 9.63x (3.63s) | 9.48x (3.25s) | 8.81x (3.14s) | ≤1.5x miss; `agentfs clone` 2.58x (07-03 remeasure); floor = whole-state double write (pack+worktree 2x43MB into SQLite); pipelining reaches ~2.0x, not 1.5x |
35+
| clone (plain FUSE) | 9.63x (3.63s) | 9.48x (3.25s) | 8.81x (3.14s) | ≤1.5x miss; `agentfs clone` 2.22x (07-03, streamed ImportSession pipeline: cat-file hidden under import); floor = whole-state double write (pack+worktree 2x43MB into SQLite); 1.5x unreachable in userspace |
3636
| checkout | 0.49x | **0.42x**| 0.42x ✓ | hold |
3737
| status | 1.10x | **0.93x**| 0.60x ✓ | ≤1.5x **MET** |
3838
| read_search | 1.87x | **1.41x** ✓ (p25 1.24, p75 1.63) | 1.37x ✓ | ≤1.5x **MET** |

.agents/specs/2026-06-12-enosys-open-eliminate-open-release-round-trips-via-kernel-no_open.notes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ User comment: none
5050
**Type**: decision
5151
**Context**: (1) agentfs clone remeasured on codex under current defaults: 0.911s / 2.58x (n=5, verified) — noopen+uring do not help the write/SQLite-bound path. Stage budget: clone-no-checkout 293ms + ls-tree 34ms + cat-file 124ms + import 355-382ms + index 6ms + mount ~90ms. Pipelining cat-file into import (-120ms) and import txn tuning land ~0.7s (~2.0x); 1.5x (0.53s) is blocked by the whole-state-in-DB double content write (pack + worktree, 2x43MB into SQLite vs native raw-FS writes). (2) Edit phase decomposed: the benchmark fsyncs each edited file; per-edit floor = fsync drain txn (~154us) + close-time WRITE RT + 2 stat GETATTRs (the same kernel close-time STATX_BLOCKS invalidation). Exact-shape micro: default 2.5-2.8ms per 8 edits — the <=3ms target is already met at micro level; the codex 6-7ms adds larger appends, deeper lookups, and noise. Deferred SETATTR (AGENTFS_DRAIN_ON_SETATTR=0) wins the micro -30% but is codex-parity for the third time (edit 7ms, total noisier) — stays opt-in.
5252
**Resolution**: Neither miss is legitimately knockable to threshold in userspace: clone's floor is the double write (pipeline work would buy ~0.2s but cannot cross 1.5x, offered to user), edit's residual is the same kernel invalidation plus the fsync txn floor. Scoreboard annotated; per-phase work concludes with 5 of 8 at/under 1.5x and every miss carrying a named, measured floor.
53+
54+
## 2026-07-03T13:35-07:00 — Clone pipeline streamed: 0.911s -> 0.754s (2.58x -> 2.22x)
55+
**Type**: milestone
56+
**Context**: SDK gained `ImportSession` (`begin_import` / `import_chunk` / `finish`): one pooled connection plus the directory-path->ino map persist across chunk calls, so imports can be fed incrementally; `import_entries` is now the buffered one-shot wrapper over it. `agentfs clone` now imports all directories in one up-front chunk, then a producer thread parses `git cat-file --batch` output blob-by-blob and sends 4MB/512-entry chunks down a bounded channel while the async consumer imports them — the 124ms cat-file stage now hides entirely under import (stage timings: stream-import 369ms ~= old import alone). Codex n=5: median 0.754s vs native 0.340s = 2.22x (was 0.911s / 2.58x). Correctness: byte-identical `diff -r` vs native clone, clean `git status`, `agentfs integrity` all-ok (cross-chunk parent nlink bumps included), SDK 168 tests x3 parallel + CLI 109 green, noopen-coherence 6/6 both modes. Also fixed a pre-existing test flake exposed by the refactor's timing shift: `overlay_reads_flag_off_falls_back_to_drain_on_write` asserted equality on the process-global batcher enqueue counter, which races under parallel tests; it now asserts the per-inode `has_pending` state immediately after pwrite (a strictly tighter check).
57+
**Resolution**: ~2.2x is the streaming landing, consistent with the floor analysis: remaining budget is git-clone-no-checkout ~300ms + import ~355ms (SQLite ingest of 43MB at ~120MB/s) + mount ~90ms; 1.5x (0.53s) stays blocked by the whole-state double content write. Clone work concludes here.

cli/src/cmd/clone.rs

Lines changed: 153 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
//! A regular `git clone` through the mount pays ~9-11 FUSE round trips plus
55
//! two SQLite transactions per worktree file. This command instead runs
66
//! `git clone --no-checkout` through a temporary mount (pack files are a few
7-
//! large sequential writes), reads the worktree content out of the object
8-
//! database with `git ls-tree` + `git cat-file --batch`, bulk-imports it via
9-
//! `AgentFS::import_entries` (large multi-inode transactions), and fabricates
10-
//! a git index whose cached stat data matches exactly what the filesystem
11-
//! serves — so `git status` is clean without re-reading any content.
7+
//! large sequential writes), then streams the worktree content out of the
8+
//! object database: a producer thread parses `git ls-tree` + `git cat-file
9+
//! --batch` output while an [`agentfs_sdk::ImportSession`] consumer bulk
10+
//! imports each chunk (large multi-inode transactions), so blob decoding
11+
//! overlaps SQLite writes instead of buffering every blob in memory first.
12+
//! Finally it fabricates a git index whose cached stat data matches exactly
13+
//! what the filesystem serves — so `git status` is clean without re-reading
14+
//! any content.
1215
//!
1316
//! Invariants: all state lands in the single database file; nothing is
1417
//! written to the host filesystem. Limitations (v1): submodules are
@@ -136,35 +139,56 @@ async fn clone_into_mount(
136139

137140
let rows = ls_tree(&repo_dir)?;
138141
stage("ls-tree");
139-
let blobs = cat_file_batch(&repo_dir, &rows)?;
140-
stage("cat-file-batch");
141142

142143
let dur = SystemTime::now().duration_since(UNIX_EPOCH)?;
143144
let timestamp = (dur.as_secs() as i64, dur.subsec_nanos() as i64);
144145
let uid = unsafe { libc::geteuid() };
145146
let gid = unsafe { libc::getegid() };
146147

147-
let entries = build_import_entries(&rows, &blobs)?;
148-
let bytes: u64 = entries.iter().map(|e| e.data.len() as u64).sum();
149-
150148
use std::os::unix::fs::MetadataExt;
151149
let repo_meta = std::fs::metadata(&repo_dir).context("failed to stat repository root")?;
152150
let dest_parent = repo_meta.ino() as i64;
153151
let dev = repo_meta.dev();
154152

155-
let imported = agent
156-
.import_entries(
153+
let mut session = agent
154+
.begin_import(
157155
dest_parent,
158-
&entries,
159-
&ImportOptions {
156+
ImportOptions {
160157
uid,
161158
gid,
162159
timestamp,
163160
},
164161
)
165162
.await
166-
.context("bulk import failed")?;
167-
stage("import-entries");
163+
.context("failed to begin bulk import")?;
164+
165+
// All directories go in one up-front chunk so streamed file chunks may
166+
// arrive in any order relative to each other.
167+
session
168+
.import_chunk(&dir_entries(&rows)?)
169+
.await
170+
.context("bulk import failed (directories)")?;
171+
172+
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<ImportEntry>>(4);
173+
let producer = spawn_blob_producer(repo_dir.clone(), &rows, tx)?;
174+
175+
let mut import_err: Option<anyhow::Error> = None;
176+
while let Some(chunk) = rx.recv().await {
177+
if let Err(error) = session.import_chunk(&chunk).await {
178+
import_err = Some(anyhow::Error::from(error));
179+
break;
180+
}
181+
}
182+
drop(rx); // unblocks the producer if the import bailed early
183+
let produced = producer
184+
.join()
185+
.map_err(|_| anyhow::anyhow!("blob producer thread panicked"))?;
186+
if let Some(error) = import_err {
187+
return Err(error.context("bulk import failed"));
188+
}
189+
let bytes = produced?;
190+
let imported = session.finish();
191+
stage("stream-import");
168192

169193
let index = build_index_v2(&rows, &imported, timestamp, uid, gid, dev)?;
170194
std::fs::write(repo_dir.join(".git").join("index"), index)
@@ -268,28 +292,69 @@ fn ls_tree(repo: &Path) -> Result<Vec<TreeRow>> {
268292
Ok(rows)
269293
}
270294

271-
/// Fetch every unique blob via one `git cat-file --batch` process. A writer
272-
/// thread feeds requests so neither side blocks on a full pipe.
273-
fn cat_file_batch(repo: &Path, rows: &[TreeRow]) -> Result<HashMap<String, Vec<u8>>> {
274-
let unique: Vec<String> = {
275-
let mut seen = HashSet::new();
276-
rows.iter()
277-
.filter(|row| seen.insert(row.sha.as_str()))
278-
.map(|row| row.sha.clone())
279-
.collect()
280-
};
295+
/// Synthesize one import entry per parent directory, first-seen order.
296+
/// `ls-tree -r` emits paths in index order, so parents always precede
297+
/// children. Also validates every row's tree entry mode so the streaming
298+
/// pipeline never starts for an unsupported repository.
299+
fn dir_entries(rows: &[TreeRow]) -> Result<Vec<ImportEntry>> {
300+
let mut entries = Vec::new();
301+
let mut known_dirs: HashSet<&str> = HashSet::new();
302+
303+
for row in rows {
304+
match row.mode {
305+
MODE_FILE | MODE_EXEC | MODE_SYMLINK => {}
306+
// Tolerate historical non-canonical modes git itself normalizes.
307+
other => bail!("unsupported tree entry mode {other:o} for {}", row.path),
308+
}
309+
let mut offset = 0;
310+
while let Some(pos) = row.path[offset..].find('/') {
311+
let dir = &row.path[..offset + pos];
312+
if known_dirs.insert(dir) {
313+
entries.push(ImportEntry {
314+
path: dir.to_string(),
315+
mode: S_IFDIR | 0o755,
316+
data: Vec::new(),
317+
});
318+
}
319+
offset += pos + 1;
320+
}
321+
}
322+
Ok(entries)
323+
}
324+
325+
/// Producer half of the streaming import: fetch every unique blob via one
326+
/// `git cat-file --batch` process (a writer thread feeds requests so neither
327+
/// side blocks on a full pipe), fan each blob out to the tree rows that
328+
/// reference it, and send bounded chunks of import entries down `tx` as they
329+
/// accumulate. Returns the total content bytes emitted.
330+
fn spawn_blob_producer(
331+
repo: std::path::PathBuf,
332+
rows: &[TreeRow],
333+
tx: tokio::sync::mpsc::Sender<Vec<ImportEntry>>,
334+
) -> Result<std::thread::JoinHandle<Result<u64>>> {
335+
// sha -> (path, mode) fanout, plus unique shas in first-seen order.
336+
let mut unique: Vec<String> = Vec::new();
337+
let mut fanout: HashMap<String, Vec<(String, u32)>> = HashMap::new();
338+
for row in rows {
339+
let refs = fanout.entry(row.sha.clone()).or_insert_with(|| {
340+
unique.push(row.sha.clone());
341+
Vec::new()
342+
});
343+
refs.push((row.path.clone(), row.mode));
344+
}
281345

282346
let mut child = Command::new("git")
283347
.arg("-C")
284-
.arg(repo)
348+
.arg(&repo)
285349
.args(["cat-file", "--batch"])
286350
.stdin(Stdio::piped())
287351
.stdout(Stdio::piped())
288352
.stderr(Stdio::null())
289353
.spawn()
290354
.context("failed to spawn git cat-file --batch")?;
291-
292355
let mut stdin = child.stdin.take().context("missing cat-file stdin")?;
356+
let stdout = child.stdout.take().context("missing cat-file stdout")?;
357+
293358
let requests = unique.clone();
294359
let writer = std::thread::spawn(move || -> std::io::Result<()> {
295360
for sha in &requests {
@@ -299,15 +364,49 @@ fn cat_file_batch(repo: &Path, rows: &[TreeRow]) -> Result<HashMap<String, Vec<u
299364
Ok(())
300365
});
301366

302-
let mut blobs = HashMap::with_capacity(unique.len());
303-
let mut stdout = BufReader::new(child.stdout.take().context("missing cat-file stdout")?);
304-
for sha in &unique {
367+
let handle = std::thread::spawn(move || -> Result<u64> {
368+
let streamed = stream_blobs(&unique, &mut fanout, stdout, &tx);
369+
if streamed.is_err() {
370+
// Consumer went away or the stream broke; don't leave a git
371+
// process wedged on a dead pipe.
372+
let _ = child.kill();
373+
}
374+
let writer_result = writer
375+
.join()
376+
.map_err(|_| anyhow::anyhow!("cat-file writer thread panicked"));
377+
let status = child.wait()?;
378+
let bytes = streamed?;
379+
writer_result??;
380+
if !status.success() {
381+
bail!("git cat-file --batch failed with {status}");
382+
}
383+
Ok(bytes)
384+
});
385+
Ok(handle)
386+
}
387+
388+
/// Parse `cat-file --batch` output blob by blob, emitting bounded chunks.
389+
fn stream_blobs(
390+
unique: &[String],
391+
fanout: &mut HashMap<String, Vec<(String, u32)>>,
392+
stdout: std::process::ChildStdout,
393+
tx: &tokio::sync::mpsc::Sender<Vec<ImportEntry>>,
394+
) -> Result<u64> {
395+
const CHUNK_BYTES: usize = 4 * 1024 * 1024;
396+
const CHUNK_ENTRIES: usize = 512;
397+
398+
let mut stdout = BufReader::new(stdout);
399+
let mut chunk: Vec<ImportEntry> = Vec::new();
400+
let mut chunk_bytes = 0usize;
401+
let mut total_bytes = 0u64;
402+
403+
for sha in unique {
305404
let mut header = String::new();
306405
stdout.read_line(&mut header)?;
307406
let mut fields = header.trim_end().split(' ');
308407
let echoed = fields.next().unwrap_or_default();
309408
let kind = fields.next().unwrap_or_default();
310-
if kind == "missing" || echoed != sha {
409+
if kind == "missing" || echoed != sha.as_str() {
311410
bail!("git cat-file returned unexpected header for {sha}: {header}");
312411
}
313412
let size: usize = fields
@@ -319,59 +418,32 @@ fn cat_file_batch(repo: &Path, rows: &[TreeRow]) -> Result<HashMap<String, Vec<u
319418
stdout.read_exact(&mut data)?;
320419
let mut newline = [0u8; 1];
321420
stdout.read_exact(&mut newline)?;
322-
blobs.insert(sha.clone(), data);
323-
}
324421

325-
writer
326-
.join()
327-
.map_err(|_| anyhow::anyhow!("cat-file writer thread panicked"))??;
328-
let status = child.wait()?;
329-
if !status.success() {
330-
bail!("git cat-file --batch failed with {status}");
331-
}
332-
Ok(blobs)
333-
}
334-
335-
/// Expand tree rows into import entries, synthesizing each parent directory
336-
/// the first time it is seen. `ls-tree -r` emits paths in index order, so
337-
/// parents always precede children.
338-
fn build_import_entries(
339-
rows: &[TreeRow],
340-
blobs: &HashMap<String, Vec<u8>>,
341-
) -> Result<Vec<ImportEntry>> {
342-
let mut entries = Vec::with_capacity(rows.len());
343-
let mut known_dirs: HashSet<String> = HashSet::new();
344-
345-
for row in rows {
346-
let mut offset = 0;
347-
while let Some(pos) = row.path[offset..].find('/') {
348-
let dir = &row.path[..offset + pos];
349-
if known_dirs.insert(dir.to_string()) {
350-
entries.push(ImportEntry {
351-
path: dir.to_string(),
352-
mode: S_IFDIR | 0o755,
353-
data: Vec::new(),
354-
});
422+
let refs = fanout
423+
.remove(sha.as_str())
424+
.with_context(|| format!("no tree rows reference blob {sha}"))?;
425+
let last = refs.len() - 1;
426+
for (index, (path, mode)) in refs.into_iter().enumerate() {
427+
let data = if index == last {
428+
std::mem::take(&mut data)
429+
} else {
430+
data.clone()
431+
};
432+
total_bytes += data.len() as u64;
433+
chunk_bytes += data.len();
434+
chunk.push(ImportEntry { path, mode, data });
435+
if chunk_bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_ENTRIES {
436+
tx.blocking_send(std::mem::take(&mut chunk))
437+
.map_err(|_| anyhow::anyhow!("import consumer stopped"))?;
438+
chunk_bytes = 0;
355439
}
356-
offset += pos + 1;
357440
}
358-
359-
let data = blobs
360-
.get(&row.sha)
361-
.with_context(|| format!("missing blob {} for {}", row.sha, row.path))?
362-
.clone();
363-
let mode = match row.mode {
364-
MODE_FILE | MODE_EXEC | MODE_SYMLINK => row.mode,
365-
// Tolerate historical non-canonical modes git itself normalizes.
366-
other => bail!("unsupported tree entry mode {other:o} for {}", row.path),
367-
};
368-
entries.push(ImportEntry {
369-
path: row.path.clone(),
370-
mode,
371-
data,
372-
});
373441
}
374-
Ok(entries)
442+
if !chunk.is_empty() {
443+
tx.blocking_send(chunk)
444+
.map_err(|_| anyhow::anyhow!("import consumer stopped"))?;
445+
}
446+
Ok(total_bytes)
375447
}
376448

377449
/// Serialize a git index (version 2) whose cached stat data matches exactly

0 commit comments

Comments
 (0)