Fewer waits on the disk: batched loose writes, the commit in one batch, fan-outs kept (refs #41) b30a880fa409
A batch's loose objects are written together, synced in parallel, renamed into place and each fan-out directory synced once (put_loose_many, with its crash steps). A commit runs in one batch, made durable before the operation that names it. Packing keeps emptied fan-out directories, and a missing one has all 256 created with one sync of loose, which alone took 130 ms on a busy disk. Serial syncs per commit fall from 18 to about 7. Old and new builds alternating on the Linux tree, five commits after a status each: median 0.39 s before, 0.34 s after (Git 0.15 s).
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 21:57 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b30a880fa40954c9943ef9c0813c16208a6dc557127248e6e566b55d8ca52b2d- Depends on
- 1d360c91a289 4dcb8c0916b7 6caacfff1d4f 75167672ebcd 8f10a07a1dbe 982864068a59 ba160f60219e d02e545dc265 e226a6cc2f71 ff0af1ee299d
- Depended on by
- 7505194116e7 648189388f1a 236f35c0baba cd4eb55e3b3d
- 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.10.0 v0.11.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
- 8 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/oxy-core/src/repo/snapshot.rs +14 −0
| … | 802 unchanged lines |
||
| 803 | 803 | extra_deps: &[ChangeHash], |
|
| 804 | 804 | selection: &Selection, |
|
| 805 | 805 | ) -> Result<ChangeHash> { |
|
| 806 | + | // The commit's objects are written together, made durable at once |
|
| 807 | + | // before the operation that names them (spec §13.6). |
|
| 808 | + | let batch = self.store.begin_batch()?; |
|
| 809 | + | let out = self.commit_selection_batched(message, extra_deps, selection); |
|
| 810 | + | batch.finish()?; |
|
| 811 | + | out |
|
| 812 | + | } |
|
| 813 | + | ||
| 814 | + | fn commit_selection_batched( |
|
| 815 | + | &self, |
|
| 816 | + | message: &str, |
|
| 817 | + | extra_deps: &[ChangeHash], |
|
| 818 | + | selection: &Selection, |
|
| 819 | + | ) -> Result<ChangeHash> { |
|
| 806 | 820 | let _wc = self.lock_working_copy()?; |
|
| 807 | 821 | let mut snap = match self.snapshot_from_cache()? { |
|
| 808 | 822 | Some(snap) => snap, |
|
| … | 2048 unchanged lines |
modified crates/oxy-core/src/store.rs +82 −12
| … | 429 unchanged lines |
||
| 430 | 430 | } |
|
| 431 | 431 | report.bytes += meta.len(); |
|
| 432 | 432 | report.objects += 1; |
|
| 433 | + | // The fan-out directory stays: recreating it costs a sync of |
|
| 434 | + | // `loose` on the next write. |
|
| 433 | 435 | fs::remove_file(&path)?; |
|
| 434 | - | let _ = fs::remove_dir(path.parent().unwrap()); |
|
| 435 | 436 | self.remove_sidecars(&r)?; |
|
| 436 | 437 | } |
|
| 437 | 438 | // Packs: rewrite those that hold unreachable objects. |
|
| … | 184 unchanged lines |
||
| 622 | 623 | fs::remove_file(p.with_extension("idx"))?; |
|
| 623 | 624 | } |
|
| 624 | 625 | self.reload_packs()?; |
|
| 626 | + | // Fan-out directories stay, emptied: see `ensure_fanouts`. |
|
| 625 | 627 | for path in loose_paths { |
|
| 626 | 628 | fs::remove_file(path)?; |
|
| 627 | - | if let Some(dir) = path.parent() { |
|
| 628 | - | let _ = fs::remove_dir(dir); |
|
| 629 | - | } |
|
| 630 | 629 | } |
|
| 631 | 630 | Ok(report) |
|
| 632 | 631 | } |
|
| … | 75 unchanged lines |
||
| 708 | 707 | fn write_pending(&self, pending: Pending) -> Result<()> { |
|
| 709 | 708 | match pending.pack { |
|
| 710 | 709 | None => { |
|
| 711 | - | for (ty, body) in &pending.buffered { |
|
| 712 | - | self.put_loose(*ty, body)?; |
|
| 713 | - | } |
|
| 714 | - | Ok(()) |
|
| 710 | + | let objects: Vec<(ObjectType, &[u8])> = pending |
|
| 711 | + | .buffered |
|
| 712 | + | .iter() |
|
| 713 | + | .map(|(ty, body)| (*ty, body.as_slice())) |
|
| 714 | + | .collect(); |
|
| 715 | + | self.put_loose_many(&objects) |
|
| 715 | 716 | } |
|
| 716 | 717 | Some(mut pack) => { |
|
| 717 | 718 | pack.writer.flush()?; |
|
| … | 220 unchanged lines |
||
| 938 | 939 | if path.is_file() { |
|
| 939 | 940 | return Ok(r); |
|
| 940 | 941 | } |
|
| 941 | - | let fanout = path.parent().unwrap(); |
|
| 942 | - | if !fanout.is_dir() { |
|
| 943 | - | fs::create_dir_all(fanout)?; |
|
| 944 | - | crate::durable::sync_dir(&self.dir.join("loose"))?; |
|
| 942 | + | if !path.parent().unwrap().is_dir() { |
|
| 943 | + | self.ensure_fanouts()?; |
|
| 945 | 944 | } |
|
| 946 | 945 | let mut nonce = [0u8; 8]; |
|
| 947 | 946 | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| … | 10 unchanged lines |
||
| 958 | 957 | } |
|
| 959 | 958 | crate::durable::rename(&tmp, &path)?; |
|
| 960 | 959 | Ok(r) |
|
| 960 | + | } |
|
| 961 | + | ||
| 962 | + | /// Create every fan-out directory of `loose` (`00` to `ff`) and sync |
|
| 963 | + | /// `loose` once. Syncing it is slow on a busy disk (130 ms seen), and |
|
| 964 | + | /// creating the directories one at a time paid that on most writes |
|
| 965 | + | /// after a gc had emptied them; they are kept from then on. |
|
| 966 | + | fn ensure_fanouts(&self) -> io::Result<()> { |
|
| 967 | + | let loose = self.dir.join("loose"); |
|
| 968 | + | for b in 0..=255u8 { |
|
| 969 | + | match fs::create_dir(loose.join(format!("{b:02x}"))) { |
|
| 970 | + | Ok(()) => {} |
|
| 971 | + | Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} |
|
| 972 | + | Err(e) => return Err(e), |
|
| 973 | + | } |
|
| 974 | + | } |
|
| 975 | + | crate::durable::sync_dir(&loose) |
|
| 976 | + | } |
|
| 977 | + | ||
| 978 | + | /// Write several loose objects with as few waits on the disk as the |
|
| 979 | + | /// guarantees allow: every file written, then all synced together, then |
|
| 980 | + | /// each renamed into place and each directory synced once. Durable when |
|
| 981 | + | /// this returns, as `put_loose` one at a time would be (spec §13.6). |
|
| 982 | + | fn put_loose_many(&self, objects: &[(ObjectType, &[u8])]) -> Result<()> { |
|
| 983 | + | use rayon::prelude::*; |
|
| 984 | + | if objects.len() <= 1 { |
|
| 985 | + | for (ty, body) in objects { |
|
| 986 | + | self.put_loose(*ty, body)?; |
|
| 987 | + | } |
|
| 988 | + | return Ok(()); |
|
| 989 | + | } |
|
| 990 | + | let mut seen = std::collections::HashSet::new(); |
|
| 991 | + | let todo: Vec<(PathBuf, PathBuf, ObjectType, &[u8])> = objects |
|
| 992 | + | .iter() |
|
| 993 | + | .filter_map(|(ty, body)| { |
|
| 994 | + | let r = hash_object(*ty, body); |
|
| 995 | + | let path = self.path_for(&r); |
|
| 996 | + | (seen.insert(r) && !path.is_file()).then(|| { |
|
| 997 | + | let mut nonce = [0u8; 8]; |
|
| 998 | + | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 999 | + | let tmp = self |
|
| 1000 | + | .dir |
|
| 1001 | + | .join("loose") |
|
| 1002 | + | .join(format!("tmp-{}", hex::encode(nonce))); |
|
| 1003 | + | (path, tmp, *ty, *body) |
|
| 1004 | + | }) |
|
| 1005 | + | }) |
|
| 1006 | + | .collect(); |
|
| 1007 | + | if todo.is_empty() { |
|
| 1008 | + | return Ok(()); |
|
| 1009 | + | } |
|
| 1010 | + | let fanouts: std::collections::BTreeSet<&Path> = |
|
| 1011 | + | todo.iter().map(|(p, ..)| p.parent().unwrap()).collect(); |
|
| 1012 | + | if fanouts.iter().any(|f| !f.is_dir()) { |
|
| 1013 | + | self.ensure_fanouts()?; |
|
| 1014 | + | } |
|
| 1015 | + | crate::durable::step("write loose objects")?; |
|
| 1016 | + | todo.par_iter() |
|
| 1017 | + | .try_for_each(|(_, tmp, ty, body)| -> io::Result<()> { |
|
| 1018 | + | let mut f = fs::File::create(tmp)?; |
|
| 1019 | + | f.write_all(&header(*ty, body.len()))?; |
|
| 1020 | + | f.write_all(body)?; |
|
| 1021 | + | f.sync_all() |
|
| 1022 | + | })?; |
|
| 1023 | + | crate::durable::step("rename loose objects")?; |
|
| 1024 | + | for (path, tmp, ..) in &todo { |
|
| 1025 | + | fs::rename(tmp, path)?; |
|
| 1026 | + | } |
|
| 1027 | + | fanouts |
|
| 1028 | + | .par_iter() |
|
| 1029 | + | .try_for_each(|d| crate::durable::sync_dir(d))?; |
|
| 1030 | + | Ok(()) |
|
| 961 | 1031 | } |
|
| 962 | 1032 | ||
| 963 | 1033 | /// Read and verify an object, loose, packed, or pending in a batch. |
|
| … | 1013 unchanged lines |
modified docs/benchmarks.md +10 −0
| … | 816 unchanged lines |
||
| 817 | 817 | remains is mostly durability: 18 fsyncs take 210 ms of the commit (one |
|
| 818 | 818 | alone 120 ms). Git does not sync loose objects by default. |
|
| 819 | 819 | ||
| 820 | + | Fewer waits on the disk (same evening): a batch's loose objects are |
|
| 821 | + | written together, synced in parallel, renamed and their directories |
|
| 822 | + | synced once each; a commit runs in one batch; packing keeps the emptied |
|
| 823 | + | fan-out directories, and a missing one has all 256 created with one sync |
|
| 824 | + | of `loose` (that sync alone took 130 ms on a busy disk). Serial syncs per |
|
| 825 | + | commit fall from 18 to about 7; each still waits on the same ext4 journal |
|
| 826 | + | commit, so the gain is modest. Old and new builds alternating, five |
|
| 827 | + | commits after a status each: median 0.39 s before, 0.34 s after (Git |
|
| 828 | + | 0.14 to 0.16 s). |
|
| 829 | + |