Snapshots use every core e7ddd552b619
Rendering the base state's files, building file graphs from a checkpoint, and reading working-copy files are independent per file and now run in parallel (rayon). The walk still visits the tree in order; only the reads fan out, and a cached blob the store has lost is still read back from disk afterwards. At 10 000 files a clean status goes from about 800 ms to 460-580 ms: materializing from 190 to 75 ms, reading the working copy from 130 to 80 ms, loading the base from 320 to 180-280 ms.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 07:40 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e7ddd552b6199f06be9b66003a428db39cbdb77a1b3afd88756e7f11a6b5e622- Depends on
- 07eb99be126e 252e8cb23fbc 2b5ee371060e 37c299cec358 75036530b621 75167672ebcd 982864068a59 9ac1fbe211b4 cdb5beba7f05 ff0af1ee299d
- Depended on by
- c2e1d09047db c3d2ef6104c2 5b4c51307b14 e96a430c78f3 f57bf6702628 36e51aa806c6 1966f5db15ee 76740263089c 992467d31d26 40a40bb7b798 2306a4d24779 8b276e0106d1 cc7a00565161 e8d38e47e9d7 afaba5f1306d 696bbf29d3eb e1dc6d6aad4f 540b229288ba cf949c7966b1 2cc29048b84a
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 16 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified Cargo.lock +21 −0
| … | 1251 unchanged lines |
||
| 1252 | 1252 | "ml-dsa", |
|
| 1253 | 1253 | "proptest", |
|
| 1254 | 1254 | "rand 0.8.8", |
|
| 1255 | + | "rayon", |
|
| 1255 | 1256 | "rustls", |
|
| 1256 | 1257 | "rustls-pemfile", |
|
| 1257 | 1258 | "similar", |
|
| … | 215 unchanged lines |
||
| 1473 | 1474 | ] |
|
| 1474 | 1475 | ||
| 1475 | 1476 | [[package]] |
|
| 1477 | + | name = "rayon" |
|
| 1478 | + | version = "1.12.0" |
|
| 1479 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1480 | + | checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" |
|
| 1481 | + | dependencies = [ |
|
| 1482 | + | "either", |
|
| 1483 | + | "rayon-core", |
|
| 1484 | + | ] |
|
| 1485 | + | ||
| 1486 | + | [[package]] |
|
| 1487 | + | name = "rayon-core" |
|
| 1488 | + | version = "1.13.0" |
|
| 1489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1490 | + | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" |
|
| 1491 | + | dependencies = [ |
|
| 1492 | + | "crossbeam-deque", |
|
| 1493 | + | "crossbeam-utils", |
|
| 1494 | + | ] |
|
| 1495 | + | ||
| 1496 | + | [[package]] |
|
| 1476 | 1497 | name = "rcgen" |
|
| 1477 | 1498 | version = "0.14.10" |
|
| 1478 | 1499 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 1033 unchanged lines |
modified crates/oxy-core/Cargo.toml +1 −0
| … | 19 unchanged lines |
||
| 20 | 20 | webpki-roots = "1" |
|
| 21 | 21 | zstd = "0.13" |
|
| 22 | 22 | globset = "0.4" |
|
| 23 | + | rayon = "1" |
|
| 23 | 24 | ||
| 24 | 25 | [dev-dependencies] |
|
| 25 | 26 | proptest = "1" |
|
| … | 1 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +61 −25
| … | 866 unchanged lines |
||
| 867 | 867 | let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); |
|
| 868 | 868 | let wb = self.walker(&nested); |
|
| 869 | 869 | let mut out = BTreeMap::new(); |
|
| 870 | + | let mut files: Vec<(String, PathBuf, fs::Metadata)> = Vec::new(); |
|
| 870 | 871 | for entry in wb.build() { |
|
| 871 | 872 | let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 872 | 873 | let path = entry.path(); |
|
| … | 26 unchanged lines |
||
| 899 | 900 | let meta = entry |
|
| 900 | 901 | .metadata() |
|
| 901 | 902 | .map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 902 | - | let e = self.file_entry(&rel, path, &meta, cache)?; |
|
| 903 | + | files.push((rel, path.to_path_buf(), meta)); |
|
| 904 | + | } |
|
| 905 | + | } |
|
| 906 | + | // Reading files is most of a walk's cost; do it in parallel. |
|
| 907 | + | { |
|
| 908 | + | use rayon::prelude::*; |
|
| 909 | + | let read: Vec<(String, FsEntry)> = files |
|
| 910 | + | .par_iter() |
|
| 911 | + | .map(|(rel, path, meta)| { |
|
| 912 | + | Ok((rel.clone(), read_file_entry(rel, path, meta, cache)?)) |
|
| 913 | + | }) |
|
| 914 | + | .collect::<Result<Vec<_>>>()?; |
|
| 915 | + | for (rel, mut e) in read { |
|
| 916 | + | // A cached blob the store lost (gc, a partial clone): read the file after all. |
|
| 917 | + | if e.content.is_empty() && e.blob.is_some_and(|b| !self.store.has(&b)) { |
|
| 918 | + | e.content = fs::read(self.root.join(&rel))?; |
|
| 919 | + | e.blob = None; |
|
| 920 | + | } |
|
| 903 | 921 | out.insert(rel, e); |
|
| 904 | 922 | } |
|
| 905 | 923 | } |
|
| … | 88 unchanged lines |
||
| 994 | 1012 | meta: &fs::Metadata, |
|
| 995 | 1013 | cache: Option<&prompt::Manifest>, |
|
| 996 | 1014 | ) -> Result<FsEntry> { |
|
| 997 | - | let exec = is_executable(meta); |
|
| 998 | - | let cached = cache.and_then(|m| { |
|
| 999 | - | let e = m.entries.get(rel)?; |
|
| 1000 | - | let mtime_ns = prompt::mtime_ns(meta); |
|
| 1001 | - | let racy = mtime_ns + 2_000_000_000 >= m.written_ns; |
|
| 1002 | - | (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e) |
|
| 1003 | - | }); |
|
| 1004 | - | Ok(match cached { |
|
| 1005 | - | Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry { |
|
| 1006 | - | kind: NodeKind::File, |
|
| 1007 | - | exec, |
|
| 1008 | - | content: vec![], |
|
| 1009 | - | blob: e.blob, |
|
| 1010 | - | content_hash: Some(e.hash.clone()), |
|
| 1011 | - | }, |
|
| 1012 | - | Some(e) => FsEntry { |
|
| 1013 | - | kind: NodeKind::File, |
|
| 1014 | - | exec, |
|
| 1015 | - | content: fs::read(path)?, |
|
| 1016 | - | blob: None, |
|
| 1017 | - | content_hash: Some(e.hash.clone()), |
|
| 1018 | - | }, |
|
| 1019 | - | None => FsEntry::new(NodeKind::File, exec, fs::read(path)?), |
|
| 1020 | - | }) |
|
| 1015 | + | let mut e = read_file_entry(rel, path, meta, cache)?; |
|
| 1016 | + | if e.content.is_empty() && e.blob.is_some_and(|b| !self.store.has(&b)) { |
|
| 1017 | + | e.content = fs::read(path)?; |
|
| 1018 | + | e.blob = None; |
|
| 1019 | + | } |
|
| 1020 | + | Ok(e) |
|
| 1021 | 1021 | } |
|
| 1022 | 1022 | ||
| 1023 | 1023 | /// The non-directory paths of a snapshot with their kinds, for the |
|
| … | 195 unchanged lines |
||
| 1219 | 1219 | None => ("", path), |
|
| 1220 | 1220 | } |
|
| 1221 | 1221 | } |
|
| 1222 | + | ||
| 1223 | + | /// One regular file's entry from disk, or from the stat cache when its |
|
| 1224 | + | /// size and mtime match the manifest outside the racy window. Touches no |
|
| 1225 | + | /// repository state, so the walk runs it in parallel; the caller checks |
|
| 1226 | + | /// that a cached blob is still in the store. |
|
| 1227 | + | fn read_file_entry( |
|
| 1228 | + | rel: &str, |
|
| 1229 | + | path: &Path, |
|
| 1230 | + | meta: &fs::Metadata, |
|
| 1231 | + | cache: Option<&prompt::Manifest>, |
|
| 1232 | + | ) -> Result<FsEntry> { |
|
| 1233 | + | let exec = is_executable(meta); |
|
| 1234 | + | let cached = cache.and_then(|m| { |
|
| 1235 | + | let e = m.entries.get(rel)?; |
|
| 1236 | + | let mtime_ns = prompt::mtime_ns(meta); |
|
| 1237 | + | let racy = mtime_ns + 2_000_000_000 >= m.written_ns; |
|
| 1238 | + | (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e) |
|
| 1239 | + | }); |
|
| 1240 | + | Ok(match cached { |
|
| 1241 | + | Some(e) if e.blob.is_some() => FsEntry { |
|
| 1242 | + | kind: NodeKind::File, |
|
| 1243 | + | exec, |
|
| 1244 | + | content: vec![], |
|
| 1245 | + | blob: e.blob, |
|
| 1246 | + | content_hash: Some(e.hash.clone()), |
|
| 1247 | + | }, |
|
| 1248 | + | Some(e) => FsEntry { |
|
| 1249 | + | kind: NodeKind::File, |
|
| 1250 | + | exec, |
|
| 1251 | + | content: fs::read(path)?, |
|
| 1252 | + | blob: None, |
|
| 1253 | + | content_hash: Some(e.hash.clone()), |
|
| 1254 | + | }, |
|
| 1255 | + | None => FsEntry::new(NodeKind::File, exec, fs::read(path)?), |
|
| 1256 | + | }) |
|
| 1257 | + | } |
modified crates/oxy-core/src/state.rs +36 −20
| … | 288 unchanged lines |
||
| 289 | 289 | st.changes.insert(hash, meta); |
|
| 290 | 290 | } |
|
| 291 | 291 | st.tree = TreeGraph::from_value(o::field(m, 2, "tree")?)?; |
|
| 292 | - | for f in o::array(o::field(m, 3, "files")?, "files")? { |
|
| 293 | - | let e = o::array(f, "file")?; |
|
| 294 | - | if e.len() != 2 { |
|
| 295 | - | return Err(ObjectError::Schema("file: expected [node, graph]".into())); |
|
| 296 | - | } |
|
| 297 | - | let node = NodeId::from_slice(o::bytes(&e[0], "node")?) |
|
| 298 | - | .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?; |
|
| 299 | - | st.files.insert(node, FileGraph::from_value(&e[1])?); |
|
| 292 | + | // File graphs are independent; build them in parallel. |
|
| 293 | + | { |
|
| 294 | + | use rayon::prelude::*; |
|
| 295 | + | let graphs = o::array(o::field(m, 3, "files")?, "files")? |
|
| 296 | + | .par_iter() |
|
| 297 | + | .map(|f| { |
|
| 298 | + | let e = o::array(f, "file")?; |
|
| 299 | + | if e.len() != 2 { |
|
| 300 | + | return Err(ObjectError::Schema("file: expected [node, graph]".into())); |
|
| 301 | + | } |
|
| 302 | + | let node = NodeId::from_slice(o::bytes(&e[0], "node")?) |
|
| 303 | + | .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?; |
|
| 304 | + | Ok((node, FileGraph::from_value(&e[1])?)) |
|
| 305 | + | }) |
|
| 306 | + | .collect::<Result<Vec<_>, ObjectError>>()?; |
|
| 307 | + | st.files.extend(graphs); |
|
| 300 | 308 | } |
|
| 301 | 309 | let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> { |
|
| 302 | 310 | if v.is_null() { |
|
| … | 246 unchanged lines |
||
| 549 | 557 | (bytes, m) |
|
| 550 | 558 | } |
|
| 551 | 559 | ||
| 560 | + | /// Every file rendered, in parallel: files are independent, and |
|
| 561 | + | /// rendering them is most of a snapshot's cost in a large tree. |
|
| 552 | 562 | pub fn materialize(&self) -> Snapshot { |
|
| 563 | + | use rayon::prelude::*; |
|
| 553 | 564 | let tree = self.tree.view(); |
|
| 554 | - | let mut files = BTreeMap::new(); |
|
| 555 | - | for (path, e) in &tree.entries { |
|
| 565 | + | let entries: Vec<(&String, &crate::tree::TreeEntry)> = tree.entries.iter().collect(); |
|
| 566 | + | let rendered: Vec<(String, FileState)> = entries |
|
| 567 | + | .par_iter() |
|
| 568 | + | .map(|(path, e)| (path.to_string(), self.file_state(e))) |
|
| 569 | + | .collect(); |
|
| 570 | + | let files: BTreeMap<String, FileState> = rendered.into_iter().collect(); |
|
| 571 | + | Snapshot { tree, files } |
|
| 572 | + | } |
|
| 573 | + | ||
| 574 | + | fn file_state(&self, e: &crate::tree::TreeEntry) -> FileState { |
|
| 575 | + | { |
|
| 556 | 576 | let content = match e.kind { |
|
| 557 | 577 | NodeKind::Dir => FileContent::Dir, |
|
| 558 | 578 | NodeKind::Symlink | NodeKind::Subrepo => FileContent::Opaque { |
|
| … | 10 unchanged lines |
||
| 569 | 589 | } |
|
| 570 | 590 | } |
|
| 571 | 591 | }; |
|
| 572 | - | files.insert( |
|
| 573 | - | path.clone(), |
|
| 574 | - | FileState { |
|
| 575 | - | node: e.node, |
|
| 576 | - | kind: e.kind, |
|
| 577 | - | mode: e.mode, |
|
| 578 | - | content, |
|
| 579 | - | }, |
|
| 580 | - | ); |
|
| 592 | + | FileState { |
|
| 593 | + | node: e.node, |
|
| 594 | + | kind: e.kind, |
|
| 595 | + | mode: e.mode, |
|
| 596 | + | content, |
|
| 597 | + | } |
|
| 581 | 598 | } |
|
| 582 | - | Snapshot { tree, files } |
|
| 583 | 599 | } |
|
| 584 | 600 | } |
|
| 585 | 601 | ||
| … | 323 unchanged lines |
modified docs/backlog-overnight-2.md +2 −1
| … | 44 unchanged lines |
||
| 45 | 45 | - [x] O3 Crash safety audit: every write goes through write_atomic or the store's batch; a test that interrupts commit and push at each step and proves the repository still opens and fscks. |
|
| 46 | 46 | - [x] O4 Benchmarks with thresholds: 10 000 files, 5 000 changes; status, commit, log, blame, push, clone; record numbers in docs. |
|
| 47 | 47 | - [x] O10a Heads at scale (first part of O10): `State::maximal` compared every pair of heads (quadratic; about 1.5 s per call at 4 500 heads, called three times per commit); now one walk. Commit reuses the snapshot's base state instead of loading it again. |
|
| 48 | - | - [ ] O10b Snapshot fast path (rest of O10): at 10 000 files and 5 000 changes a commit takes 3.8 s and a clean status 3.0 s, because every snapshot loads and materializes the whole base state (about 75 ms load, 75 ms materialize, 50 ms walk at 4 000 files). Skip load and materialize when every file's stat matches a manifest written for the same base; make the base state lazy for callers that need it; cache the materialized snapshot per state. Target the spec §25 numbers. |
|
| 48 | + | - [x] O10b Parallel snapshot work: rendering the base state's files, building file graphs from a checkpoint, and reading working-copy files now run on every core (rayon). |
|
| 49 | + | - [ ] O10c Snapshot without the whole base (rest of O10): load only the file graphs a snapshot needs (a checkpoint split per file or indexed), skip reading and rendering files whose stat matches a manifest written for the same base, cache materialized states. Needs a spec change to the checkpoint format; target spec §25. |
|
| 49 | 50 | - [ ] O5 Concurrency: two processes committing, snapshotting and pushing on one repository; locks hold, nothing is lost. |
|
| 50 | 51 | - [ ] O6 Error messages audit: every user-facing error says what to do next; `oxy doctor` checks identity, config, remotes, CA files, hub reachability. |
|
| 51 | 52 | - [ ] O7 Shell completions (`oxy completions bash|zsh|fish`) and man pages generated from clap. |
|
| … | 14 unchanged lines |