Faster state loading: seeded fast hashing for the file graphs, no unused predecessor map, inline successor sets, lines moved rather than copied; OXY_TIMINGS=1 prints snapshot phase timings. Clean status at 10 000 files 0.96 s to 0.52 s, memory 657 to 239 MB 0bdc3f91a39f
closes #2
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 16:36 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
0bdc3f91a39f8a0391221c6dd769a30199da9410d48846b3155446997a38812d- Depends on
- 0bb9413f0118 119bbf50cb86 135212de5370 1d360c91a289 252e8cb23fbc 4dcb8c0916b7 4dee277c327a 614541c3cebd 75167672ebcd 8f10a07a1dbe 982864068a59 b7f102fcbb17 c863c7a7a2d1 ee06a6c557b7 ff0af1ee299d
- Depended on by
- 157d02e95586 c2e1d09047db e5f8eb2a2006 faa9cd0579d3 f9466021286a 992467d31d26 23ff1288a5dd f9575918477f dbf06d342e1d 2306a4d24779 d0d52107b074 f6451fbbae3f 598009a75f6c 01a47cd7f0c8 8b276e0106d1 a4fad5b085c7 86e1824f2d0b d443639bcf35 dfb23a3a8cfa cc7a00565161 e8d38e47e9d7 afaba5f1306d 696bbf29d3eb
- 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
- 46 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/oxy-core/src/fasthash.rs +131 −0
| 1 | + | //! A fast hasher for the in-memory graphs (spec §25): their keys begin with |
|
| 2 | + | //! BLAKE3 hashes, so the bytes are already uniform and SipHash's cost buys |
|
| 3 | + | //! nothing. A per-process random seed keeps bucket placement unpredictable, |
|
| 4 | + | //! so keys chosen by someone else cannot be aimed at one bucket. |
|
| 5 | + | ||
| 6 | + | use std::hash::{BuildHasher, Hasher}; |
|
| 7 | + | use std::sync::OnceLock; |
|
| 8 | + | ||
| 9 | + | fn seed() -> u64 { |
|
| 10 | + | static SEED: OnceLock<u64> = OnceLock::new(); |
|
| 11 | + | *SEED.get_or_init(rand::random::<u64>) |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | /// Builds [`FastHasher`]s with the process seed. |
|
| 15 | + | #[derive(Clone, Copy, Debug)] |
|
| 16 | + | pub struct FastState(u64); |
|
| 17 | + | ||
| 18 | + | impl Default for FastState { |
|
| 19 | + | fn default() -> FastState { |
|
| 20 | + | FastState(seed()) |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl BuildHasher for FastState { |
|
| 25 | + | type Hasher = FastHasher; |
|
| 26 | + | fn build_hasher(&self) -> FastHasher { |
|
| 27 | + | FastHasher(self.0) |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// Folds each 8-byte word into the state with a multiply and rotate, then |
|
| 32 | + | /// finishes with SplitMix64's mixer so every input bit reaches every output bit. |
|
| 33 | + | pub struct FastHasher(u64); |
|
| 34 | + | ||
| 35 | + | impl FastHasher { |
|
| 36 | + | #[inline] |
|
| 37 | + | fn mix(&mut self, word: u64) { |
|
| 38 | + | self.0 = (self.0 ^ word) |
|
| 39 | + | .wrapping_mul(0x9e37_79b9_7f4a_7c15) |
|
| 40 | + | .rotate_left(31); |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl Hasher for FastHasher { |
|
| 45 | + | #[inline] |
|
| 46 | + | fn write(&mut self, bytes: &[u8]) { |
|
| 47 | + | let mut chunks = bytes.chunks_exact(8); |
|
| 48 | + | for c in &mut chunks { |
|
| 49 | + | self.mix(u64::from_le_bytes(c.try_into().unwrap())); |
|
| 50 | + | } |
|
| 51 | + | let rest = chunks.remainder(); |
|
| 52 | + | if !rest.is_empty() { |
|
| 53 | + | let mut b = [0u8; 8]; |
|
| 54 | + | b[..rest.len()].copy_from_slice(rest); |
|
| 55 | + | self.mix(u64::from_le_bytes(b) ^ ((rest.len() as u64) << 59)); |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[inline] |
|
| 60 | + | fn write_u8(&mut self, n: u8) { |
|
| 61 | + | self.mix(n as u64 | 0x100); |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | #[inline] |
|
| 65 | + | fn write_u32(&mut self, n: u32) { |
|
| 66 | + | self.mix(n as u64 | 0x1_0000_0000); |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | #[inline] |
|
| 70 | + | fn write_u64(&mut self, n: u64) { |
|
| 71 | + | self.mix(n); |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | #[inline] |
|
| 75 | + | fn write_usize(&mut self, n: usize) { |
|
| 76 | + | self.mix(n as u64); |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #[inline] |
|
| 80 | + | fn finish(&self) -> u64 { |
|
| 81 | + | let mut z = self.0; |
|
| 82 | + | z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); |
|
| 83 | + | z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); |
|
| 84 | + | z ^ (z >> 31) |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | pub type FastMap<K, V> = std::collections::HashMap<K, V, FastState>; |
|
| 89 | + | pub type FastSet<K> = std::collections::HashSet<K, FastState>; |
|
| 90 | + | ||
| 91 | + | #[cfg(test)] |
|
| 92 | + | mod tests { |
|
| 93 | + | use super::*; |
|
| 94 | + | use std::hash::Hash; |
|
| 95 | + | ||
| 96 | + | fn h<T: Hash + ?Sized>(state: &FastState, v: &T) -> u64 { |
|
| 97 | + | state.hash_one(v) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | #[test] |
|
| 101 | + | fn distinct_keys_spread_and_equal_keys_agree() { |
|
| 102 | + | let s = FastState(42); |
|
| 103 | + | let a = ([7u8; 32], 1u32); |
|
| 104 | + | assert_eq!(h(&s, &a), h(&s, &a)); |
|
| 105 | + | assert_ne!(h(&s, &a), h(&s, &([7u8; 32], 2u32)), "the index matters"); |
|
| 106 | + | assert_ne!( |
|
| 107 | + | h(&s, &[1u8, 2, 3][..]), |
|
| 108 | + | h(&s, &[1u8, 2, 3, 0][..]), |
|
| 109 | + | "length matters" |
|
| 110 | + | ); |
|
| 111 | + | // Consecutive indices of one change land in different buckets of a small table. |
|
| 112 | + | let buckets: std::collections::HashSet<u64> = |
|
| 113 | + | (0..64u32).map(|i| h(&s, &([9u8; 32], i)) & 63).collect(); |
|
| 114 | + | assert!(buckets.len() > 32, "{} of 64 buckets used", buckets.len()); |
|
| 115 | + | assert_ne!( |
|
| 116 | + | h(&FastState(1), &a), |
|
| 117 | + | h(&FastState(2), &a), |
|
| 118 | + | "the seed matters" |
|
| 119 | + | ); |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn maps_work() { |
|
| 124 | + | let mut m: FastMap<([u8; 32], u32), u32> = FastMap::default(); |
|
| 125 | + | for i in 0..10_000u32 { |
|
| 126 | + | m.insert(([(i % 251) as u8; 32], i), i); |
|
| 127 | + | } |
|
| 128 | + | assert_eq!(m.len(), 10_000); |
|
| 129 | + | assert_eq!(m[&([77u8; 32], 77)], 77); |
|
| 130 | + | } |
|
| 131 | + | } |
added crates/oxy-core/src/timing.rs +44 −0
| 1 | + | //! Opt-in phase timings (`OXY_TIMINGS=1`): each named phase prints its |
|
| 2 | + | //! duration to stderr, for measuring where a command spends its time |
|
| 3 | + | //! without a profiler. |
|
| 4 | + | ||
| 5 | + | use std::sync::OnceLock; |
|
| 6 | + | use std::time::Instant; |
|
| 7 | + | ||
| 8 | + | fn enabled() -> bool { |
|
| 9 | + | static ON: OnceLock<bool> = OnceLock::new(); |
|
| 10 | + | *ON.get_or_init(|| std::env::var_os("OXY_TIMINGS").is_some_and(|v| !v.is_empty() && v != "0")) |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | /// Report a duration measured by hand (several intervals summed), if timings are on. |
|
| 14 | + | pub fn record(name: &str, d: std::time::Duration) { |
|
| 15 | + | if enabled() { |
|
| 16 | + | eprintln!("timing {:<28} {:>9.2} ms", name, d.as_secs_f64() * 1000.0); |
|
| 17 | + | } |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /// Prints `timing <name> <ms>` when dropped, if timings are on. |
|
| 21 | + | pub struct Phase { |
|
| 22 | + | name: &'static str, |
|
| 23 | + | started: Option<Instant>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// Start timing a phase; the guard reports when it goes out of scope. |
|
| 27 | + | pub fn phase(name: &'static str) -> Phase { |
|
| 28 | + | Phase { |
|
| 29 | + | name, |
|
| 30 | + | started: enabled().then(Instant::now), |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | impl Drop for Phase { |
|
| 35 | + | fn drop(&mut self) { |
|
| 36 | + | if let Some(t) = self.started { |
|
| 37 | + | eprintln!( |
|
| 38 | + | "timing {:<28} {:>9.2} ms", |
|
| 39 | + | self.name, |
|
| 40 | + | t.elapsed().as_secs_f64() * 1000.0 |
|
| 41 | + | ); |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | } |
modified crates/oxy-core/src/graph.rs +78 −24
| … | 3 unchanged lines |
||
| 4 | 4 | //! The graph is a pure function of the set of changes applied to it: inserts |
|
| 5 | 5 | //! add vertices and edges, deletes set a flag, and nothing is ever removed. |
|
| 6 | 6 | ||
| 7 | + | use crate::fasthash::{FastMap as HashMap, FastSet as HashSet}; |
|
| 7 | 8 | use crate::hash::ChangeHash; |
|
| 8 | 9 | use crate::objects::AtomRange; |
|
| 9 | - | use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; |
|
| 10 | + | use std::collections::{BTreeMap, BTreeSet}; |
|
| 10 | 11 | ||
| 11 | 12 | /// `(introducing change, index within that change for this node)`. |
|
| 12 | 13 | pub type AtomKey = (ChangeHash, u32); |
|
| … | 28 unchanged lines |
||
| 41 | 42 | #[derive(Clone, Debug)] |
|
| 42 | 43 | pub struct FileGraph { |
|
| 43 | 44 | atoms: HashMap<AtomKey, AtomInfo>, |
|
| 44 | - | succ: HashMap<Vertex, BTreeSet<Vertex>>, |
|
| 45 | - | pred: HashMap<Vertex, BTreeSet<Vertex>>, |
|
| 45 | + | /// Every vertex, with its direct successors. |
|
| 46 | + | succ: HashMap<Vertex, Edges>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// A vertex's direct successors, sorted and without repeats. Nearly every |
|
| 50 | + | /// vertex has one, which is kept inline without an allocation. |
|
| 51 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 52 | + | enum Edges { |
|
| 53 | + | #[default] |
|
| 54 | + | None, |
|
| 55 | + | One(Vertex), |
|
| 56 | + | Many(Vec<Vertex>), |
|
| 46 | 57 | } |
|
| 47 | 58 | ||
| 59 | + | impl Edges { |
|
| 60 | + | fn insert(&mut self, v: Vertex) { |
|
| 61 | + | match self { |
|
| 62 | + | Edges::None => *self = Edges::One(v), |
|
| 63 | + | Edges::One(a) if *a == v => {} |
|
| 64 | + | Edges::One(a) => { |
|
| 65 | + | let a = *a; |
|
| 66 | + | *self = Edges::Many(if a < v { vec![a, v] } else { vec![v, a] }); |
|
| 67 | + | } |
|
| 68 | + | Edges::Many(list) => { |
|
| 69 | + | if let Err(i) = list.binary_search(&v) { |
|
| 70 | + | list.insert(i, v); |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | fn as_slice(&self) -> &[Vertex] { |
|
| 77 | + | match self { |
|
| 78 | + | Edges::None => &[], |
|
| 79 | + | Edges::One(v) => std::slice::from_ref(v), |
|
| 80 | + | Edges::Many(list) => list, |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | fn iter(&self) -> std::slice::Iter<'_, Vertex> { |
|
| 85 | + | self.as_slice().iter() |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 48 | 89 | impl Default for FileGraph { |
|
| 49 | 90 | fn default() -> Self { |
|
| 50 | 91 | Self::new() |
|
| … | 48 unchanged lines |
||
| 99 | 140 | impl FileGraph { |
|
| 100 | 141 | pub fn new() -> Self { |
|
| 101 | 142 | let mut g = FileGraph { |
|
| 102 | - | atoms: HashMap::new(), |
|
| 103 | - | succ: HashMap::new(), |
|
| 104 | - | pred: HashMap::new(), |
|
| 143 | + | atoms: HashMap::default(), |
|
| 144 | + | succ: HashMap::default(), |
|
| 105 | 145 | }; |
|
| 106 | 146 | g.edge(Vertex::Start, Vertex::End); |
|
| 107 | 147 | g |
|
| … | 1 unchanged lines |
||
| 109 | 149 | ||
| 110 | 150 | fn edge(&mut self, a: Vertex, b: Vertex) { |
|
| 111 | 151 | self.succ.entry(a).or_default().insert(b); |
|
| 112 | - | self.pred.entry(b).or_default().insert(a); |
|
| 113 | 152 | self.succ.entry(b).or_default(); |
|
| 114 | - | self.pred.entry(a).or_default(); |
|
| 115 | 153 | } |
|
| 116 | 154 | ||
| 117 | 155 | /// Apply an `Insert` by `change` whose first new atom has index `first_index`. |
|
| … | 4 unchanged lines |
||
| 122 | 160 | after: Vertex, |
|
| 123 | 161 | before: Vertex, |
|
| 124 | 162 | lines: &[Vec<u8>], |
|
| 163 | + | ) { |
|
| 164 | + | self.insert_owned(change, first_index, after, before, lines.to_vec()); |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | /// [`FileGraph::insert`] taking the lines, so loading a state moves |
|
| 168 | + | /// them in rather than copying. Each new atom's entry is made once, as |
|
| 169 | + | /// the source of the next edge, and the chain's ends are ensured last. |
|
| 170 | + | pub fn insert_owned( |
|
| 171 | + | &mut self, |
|
| 172 | + | change: ChangeHash, |
|
| 173 | + | first_index: u32, |
|
| 174 | + | after: Vertex, |
|
| 175 | + | before: Vertex, |
|
| 176 | + | lines: Vec<Vec<u8>>, |
|
| 125 | 177 | ) { |
|
| 178 | + | self.atoms.reserve(lines.len()); |
|
| 179 | + | self.succ.reserve(lines.len() + 1); |
|
| 126 | 180 | let mut prev = after; |
|
| 127 | - | for (i, line) in lines.iter().enumerate() { |
|
| 181 | + | for (i, line) in lines.into_iter().enumerate() { |
|
| 128 | 182 | let key = (change, first_index + i as u32); |
|
| 129 | - | self.atoms.entry(key).or_default().content = Some(line.clone()); |
|
| 183 | + | self.atoms.entry(key).or_default().content = Some(line); |
|
| 130 | 184 | let v = Vertex::Atom(key); |
|
| 131 | - | self.edge(prev, v); |
|
| 185 | + | self.succ.entry(prev).or_default().insert(v); |
|
| 132 | 186 | prev = v; |
|
| 133 | 187 | } |
|
| 134 | - | self.edge(prev, before); |
|
| 188 | + | self.succ.entry(prev).or_default().insert(before); |
|
| 189 | + | self.succ.entry(before).or_default(); |
|
| 135 | 190 | } |
|
| 136 | 191 | ||
| 137 | 192 | /// Apply a `Delete` of `range` by change `by`. |
|
| … | 75 unchanged lines |
||
| 213 | 268 | .get(&v) |
|
| 214 | 269 | .map(|s| s.iter().copied().collect()) |
|
| 215 | 270 | .unwrap_or_default(); |
|
| 216 | - | let mut seen = HashSet::new(); |
|
| 271 | + | let mut seen = HashSet::default(); |
|
| 217 | 272 | while let Some(s) = stack.pop() { |
|
| 218 | 273 | if kept.contains(&s) { |
|
| 219 | 274 | out.insert(s); |
|
| … | 11 unchanged lines |
||
| 231 | 286 | /// Order the live atoms and locate conflicts. |
|
| 232 | 287 | pub fn materialize(&self, deps: &dyn DepOracle) -> Materialized { |
|
| 233 | 288 | // 1. Vertices that survive contraction: live atoms, ghosts, Start, End. |
|
| 234 | - | let mut kept: HashSet<Vertex> = HashSet::new(); |
|
| 235 | - | let mut ghosts: HashSet<AtomKey> = HashSet::new(); |
|
| 289 | + | let mut kept: HashSet<Vertex> = HashSet::default(); |
|
| 290 | + | let mut ghosts: HashSet<AtomKey> = HashSet::default(); |
|
| 236 | 291 | kept.insert(Vertex::Start); |
|
| 237 | 292 | kept.insert(Vertex::End); |
|
| 238 | 293 | for (k, info) in &self.atoms { |
|
| … | 9 unchanged lines |
||
| 248 | 303 | } |
|
| 249 | 304 | ||
| 250 | 305 | // 2. Reduced graph over kept vertices. |
|
| 251 | - | let mut memo = HashMap::new(); |
|
| 252 | - | let mut rsucc: HashMap<Vertex, BTreeSet<Vertex>> = HashMap::new(); |
|
| 253 | - | let mut indeg: HashMap<Vertex, usize> = HashMap::new(); |
|
| 306 | + | let mut memo = HashMap::default(); |
|
| 307 | + | let mut rsucc: HashMap<Vertex, BTreeSet<Vertex>> = HashMap::default(); |
|
| 308 | + | let mut indeg: HashMap<Vertex, usize> = HashMap::default(); |
|
| 254 | 309 | for &u in &kept { |
|
| 255 | 310 | indeg.entry(u).or_insert(0); |
|
| 256 | 311 | let rs = self.reduced_succ(u, &kept, &mut memo); |
|
| … | 9 unchanged lines |
||
| 266 | 321 | .filter(|(_, &d)| d == 0) |
|
| 267 | 322 | .map(|(v, _)| *v) |
|
| 268 | 323 | .collect(); |
|
| 269 | - | let mut side: HashMap<Vertex, u32> = HashMap::new(); |
|
| 270 | - | let mut joins: HashSet<Vertex> = HashSet::new(); |
|
| 324 | + | let mut side: HashMap<Vertex, u32> = HashMap::default(); |
|
| 325 | + | let mut joins: HashSet<Vertex> = HashSet::default(); |
|
| 271 | 326 | let mut next_side = 0u32; |
|
| 272 | 327 | let mut plain: Vec<Vertex> = Vec::new(); |
|
| 273 | 328 | let mut conflict: Option<BTreeMap<u32, Vec<Vertex>>> = None; |
|
| … | 240 unchanged lines |
||
| 514 | 569 | use crate::objects::{array, bytes, field, map, oref, u32_, ObjectError}; |
|
| 515 | 570 | let m = map(v, "file graph")?; |
|
| 516 | 571 | let mut g = FileGraph { |
|
| 517 | - | atoms: HashMap::new(), |
|
| 518 | - | succ: HashMap::new(), |
|
| 519 | - | pred: HashMap::new(), |
|
| 572 | + | atoms: HashMap::default(), |
|
| 573 | + | succ: HashMap::default(), |
|
| 520 | 574 | }; |
|
| 521 | 575 | for a in array(field(m, 0, "atoms")?, "atoms")? { |
|
| 522 | 576 | let e = array(a, "atom")?; |
|
| … | 86 unchanged lines |
||
| 609 | 663 | return true; |
|
| 610 | 664 | } |
|
| 611 | 665 | let mut stack = vec![*a]; |
|
| 612 | - | let mut seen = HashSet::new(); |
|
| 666 | + | let mut seen = HashSet::default(); |
|
| 613 | 667 | while let Some(x) = stack.pop() { |
|
| 614 | 668 | if let Some(ds) = self.0.get(&x) { |
|
| 615 | 669 | for d in ds { |
|
| … | 435 unchanged lines |
modified crates/oxy-core/src/lib.rs +2 −0
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod chunk; |
|
| 9 | 9 | pub mod diff; |
|
| 10 | 10 | pub mod durable; |
|
| 11 | + | pub mod fasthash; |
|
| 11 | 12 | pub mod graph; |
|
| 12 | 13 | pub mod hash; |
|
| 13 | 14 | pub mod identity; |
|
| … | 9 unchanged lines |
||
| 23 | 24 | pub mod state; |
|
| 24 | 25 | pub mod store; |
|
| 25 | 26 | pub mod subrepo; |
|
| 27 | + | pub mod timing; |
|
| 26 | 28 | pub mod tree; |
modified crates/oxy-core/src/repo/checkpoint.rs +9 −2
| … | 135 unchanged lines |
||
| 136 | 136 | let Some((_, cp)) = self.latest_checkpoint(&b)? else { |
|
| 137 | 137 | continue; |
|
| 138 | 138 | }; |
|
| 139 | - | let payload = self.read_blob(&cp.payload)?; |
|
| 140 | - | let base = State::from_checkpoint_payload(&payload)?; |
|
| 139 | + | let payload = { |
|
| 140 | + | let _t = crate::timing::phase("load: read checkpoint"); |
|
| 141 | + | self.read_blob(&cp.payload)? |
|
| 142 | + | }; |
|
| 143 | + | let base = { |
|
| 144 | + | let _t = crate::timing::phase("load: decode checkpoint"); |
|
| 145 | + | State::from_checkpoint_payload(&payload)? |
|
| 146 | + | }; |
|
| 141 | 147 | let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect(); |
|
| 142 | 148 | let mut found: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 143 | 149 | let mut delta: Vec<Change> = Vec::new(); |
|
| … | 16 unchanged lines |
||
| 160 | 166 | if found.len() != cp_heads.len() { |
|
| 161 | 167 | continue; |
|
| 162 | 168 | } |
|
| 169 | + | let _t = crate::timing::phase("load: apply delta and validate"); |
|
| 163 | 170 | let mut state = base; |
|
| 164 | 171 | for c in delta { |
|
| 165 | 172 | state.add_unchecked(c); |
|
| … | 7 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +17 −6
| … | 782 unchanged lines |
||
| 783 | 783 | let mut state = State::new(); |
|
| 784 | 784 | let mut stack: Vec<ChangeHash> = heads.to_vec(); |
|
| 785 | 785 | let mut seen: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 786 | - | while let Some(h) = stack.pop() { |
|
| 787 | - | if !seen.insert(h) { |
|
| 788 | - | continue; |
|
| 786 | + | { |
|
| 787 | + | let _t = crate::timing::phase("load: read and add changes"); |
|
| 788 | + | let (mut read, mut add) = (std::time::Duration::ZERO, std::time::Duration::ZERO); |
|
| 789 | + | while let Some(h) = stack.pop() { |
|
| 790 | + | if !seen.insert(h) { |
|
| 791 | + | continue; |
|
| 792 | + | } |
|
| 793 | + | let t0 = std::time::Instant::now(); |
|
| 794 | + | let c = self.store.get_change(&h)?; |
|
| 795 | + | read += t0.elapsed(); |
|
| 796 | + | stack.extend(c.deps.iter().copied()); |
|
| 797 | + | let t1 = std::time::Instant::now(); |
|
| 798 | + | state.add_unchecked(c); |
|
| 799 | + | add += t1.elapsed(); |
|
| 789 | 800 | } |
|
| 790 | - | let c = self.store.get_change(&h)?; |
|
| 791 | - | stack.extend(c.deps.iter().copied()); |
|
| 792 | - | state.add_unchecked(c); |
|
| 801 | + | crate::timing::record("load: of which reading", read); |
|
| 802 | + | crate::timing::record("load: of which applying", add); |
|
| 793 | 803 | } |
|
| 804 | + | let _t = crate::timing::phase("load: validate"); |
|
| 794 | 805 | state.validate()?; |
|
| 795 | 806 | Ok(state) |
|
| 796 | 807 | } |
|
| … | 590 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +18 −5
| … | 198 unchanged lines |
||
| 199 | 199 | } |
|
| 200 | 200 | ||
| 201 | 201 | fn snapshot_locked(&self, command: &str) -> Result<SnapshotResult> { |
|
| 202 | + | let _all = crate::timing::phase("snapshot"); |
|
| 202 | 203 | let (op_ref, op) = self.head_op()?; |
|
| 203 | 204 | let (old_hash, old_wc) = self.working_copy()?; |
|
| 204 | - | let base = self.load_state(&old_wc.deps)?; |
|
| 205 | - | let base_snapshot = base.materialize(); |
|
| 205 | + | let base = { |
|
| 206 | + | let _t = crate::timing::phase("snapshot: load base state"); |
|
| 207 | + | self.load_state(&old_wc.deps)? |
|
| 208 | + | }; |
|
| 209 | + | let base_snapshot = { |
|
| 210 | + | let _t = crate::timing::phase("snapshot: materialize base"); |
|
| 211 | + | base.materialize() |
|
| 212 | + | }; |
|
| 206 | 213 | ||
| 207 | 214 | // Reuse the NodeIds the previous working-copy version assigned to new |
|
| 208 | 215 | // paths, so an unchanged working copy hashes identically (spec §12.3). |
|
| 209 | 216 | let reuse = added_node_paths(&old_wc, &base_snapshot); |
|
| 210 | 217 | // The last manifest lets unchanged opaque files go unread. |
|
| 211 | 218 | let manifest = self.read_manifest()?.map(|(_, _, m)| m); |
|
| 212 | - | let mut fs_entries = |
|
| 213 | - | self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?; |
|
| 219 | + | let mut fs_entries = { |
|
| 220 | + | let _t = crate::timing::phase("snapshot: read working copy"); |
|
| 221 | + | self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))? |
|
| 222 | + | }; |
|
| 214 | 223 | self.platform_normalize(&mut fs_entries, &base_snapshot); |
|
| 215 | - | let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?; |
|
| 224 | + | let built = { |
|
| 225 | + | let _t = crate::timing::phase("snapshot: diff against base"); |
|
| 226 | + | self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)? |
|
| 227 | + | }; |
|
| 216 | 228 | ||
| 217 | 229 | let mut wc = Change { |
|
| 218 | 230 | change_id: old_wc.change_id, |
|
| … | 24 unchanged lines |
||
| 243 | 255 | self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?; |
|
| 244 | 256 | } |
|
| 245 | 257 | // The prompt compares against this without recording anything. |
|
| 258 | + | let _t = crate::timing::phase("snapshot: write manifest"); |
|
| 246 | 259 | self.write_manifest_entries( |
|
| 247 | 260 | base_snapshot.is_conflicted(), |
|
| 248 | 261 | !wc.tree_ops.is_empty() || !wc.edits.is_empty(), |
|
| … | 1796 unchanged lines |
modified crates/oxy-core/src/state.rs +9 −11
| … | 203 unchanged lines |
||
| 204 | 204 | if self.changes.contains_key(&h) { |
|
| 205 | 205 | return h; |
|
| 206 | 206 | } |
|
| 207 | - | self.apply(h, &change); |
|
| 208 | - | self.changes.insert(h, ChangeMeta::of(&change)); |
|
| 207 | + | let meta = ChangeMeta::of(&change); |
|
| 208 | + | self.apply(h, change); |
|
| 209 | + | self.changes.insert(h, meta); |
|
| 209 | 210 | self.closure.lock().unwrap().clear(); |
|
| 210 | 211 | h |
|
| 211 | 212 | } |
|
| … | 139 unchanged lines |
||
| 351 | 352 | Ok(()) |
|
| 352 | 353 | } |
|
| 353 | 354 | ||
| 354 | - | fn apply(&mut self, h: ChangeHash, c: &Change) { |
|
| 355 | + | fn apply(&mut self, h: ChangeHash, c: Change) { |
|
| 355 | 356 | for (i, op) in c.tree_ops.iter().enumerate() { |
|
| 356 | 357 | self.tree.apply(h, i as u32, &resolve_tree_op(op, h)); |
|
| 357 | 358 | } |
|
| 358 | 359 | let mut counters: HashMap<NodeId, u32> = HashMap::new(); |
|
| 359 | - | for e in &c.edits { |
|
| 360 | + | for e in c.edits { |
|
| 360 | 361 | match e { |
|
| 361 | 362 | Edit::Insert { |
|
| 362 | 363 | node, |
|
| … | 1 unchanged lines |
||
| 364 | 365 | before, |
|
| 365 | 366 | lines, |
|
| 366 | 367 | } => { |
|
| 367 | - | let idx = counters.entry(*node).or_default(); |
|
| 368 | + | let idx = counters.entry(node).or_default(); |
|
| 368 | 369 | let first = *idx; |
|
| 369 | 370 | *idx += lines.len() as u32; |
|
| 370 | - | self.files.entry(*node).or_default().insert( |
|
| 371 | + | self.files.entry(node).or_default().insert_owned( |
|
| 371 | 372 | h, |
|
| 372 | 373 | first, |
|
| 373 | 374 | vertex(after.resolve(h)), |
|
| … | 2 unchanged lines |
||
| 376 | 377 | ); |
|
| 377 | 378 | } |
|
| 378 | 379 | Edit::Delete { node, atoms } => { |
|
| 379 | - | let g = self.files.entry(*node).or_default(); |
|
| 380 | + | let g = self.files.entry(node).or_default(); |
|
| 380 | 381 | for r in atoms { |
|
| 381 | 382 | g.delete(h, &r.resolve(h)); |
|
| 382 | 383 | } |
|
| 383 | 384 | } |
|
| 384 | 385 | Edit::Replace { node, old, new } => { |
|
| 385 | - | self.replaces |
|
| 386 | - | .entry(*node) |
|
| 387 | - | .or_default() |
|
| 388 | - | .push((h, *old, *new)); |
|
| 386 | + | self.replaces.entry(node).or_default().push((h, old, new)); |
|
| 389 | 387 | } |
|
| 390 | 388 | } |
|
| 391 | 389 | } |
|
| … | 511 unchanged lines |
modified docs/benchmarks.md +37 −0
| … | 121 unchanged lines |
||
| 122 | 122 | Loading the whole base state is now the largest cost. O10c removes it: a |
|
| 123 | 123 | snapshot should load and render only the files whose stat changed, which |
|
| 124 | 124 | needs checkpoints that can be read per file. |
|
| 125 | + | ||
| 126 | + | ## 2026-09-28: O10c, first part: the in-memory state |
|
| 127 | + | ||
| 128 | + | Profiling a clean snapshot with `OXY_TIMINGS=1` (10 000 files of 40 lines, |
|
| 129 | + | 200 changes) showed loading the base state dominating it. Applying the |
|
| 130 | + | changes to the in-memory graphs cost 650 ms and reading them 160 ms. A |
|
| 131 | + | checkpoint did not help: decoding one took 680 ms, as long as replaying |
|
| 132 | + | every change, because both build the same graphs for 400 000 lines. So the |
|
| 133 | + | cost was in the graph structure, not in the format: |
|
| 134 | + | ||
| 135 | + | - **Hashing.** The graph maps used SipHash over 36-byte keys, three to |
|
| 136 | + | five times per line. The keys begin with BLAKE3 hashes, so they now use |
|
| 137 | + | a seeded multiply-rotate hasher (`fasthash.rs`). The seed is random per |
|
| 138 | + | process, so keys chosen by someone else cannot be aimed at one bucket. |
|
| 139 | + | - **The predecessor map.** It was written for every line and read nowhere, |
|
| 140 | + | so it is gone. |
|
| 141 | + | - **Successor sets.** A `BTreeSet` per vertex became a sorted edge list |
|
| 142 | + | that keeps its single successor inline. Nearly every line has one. |
|
| 143 | + | - **Line bytes.** Loading moves them into the graph instead of copying |
|
| 144 | + | them, and chains each new line with one lookup instead of three. |
|
| 145 | + | ||
| 146 | + | Both binaries were built from the same tree and run alternately, nine |
|
| 147 | + | times each, medians shown: |
|
| 148 | + | ||
| 149 | + | | 10 000 files, 200 changes | Before | After | |
|
| 150 | + | |---|---:|---:| |
|
| 151 | + | | status: clean working copy | 0.96 s | 0.52 s | |
|
| 152 | + | | commit: one file | 0.99 s | 0.51 s | |
|
| 153 | + | | peak memory (either) | 657 MB | 239 MB | |
|
| 154 | + | ||
| 155 | + | The checkpoint format is unchanged. What remains of O10c is split into |
|
| 156 | + | O10d and O10e in the backlog: |
|
| 157 | + | ||
| 158 | + | - **O10d.** Skip reading text files whose stat matches the manifest, when |
|
| 159 | + | the manifest recorded them equal to the base. |
|
| 160 | + | - **O10e.** Checkpoints readable per file, so a snapshot loads only the |
|
| 161 | + | graphs it needs. This one changes the checkpoint format in spec §25. |
modified docs/guide.md +1 −1
| … | 373 unchanged lines |
||
| 374 | 374 | ||
| 375 | 375 | Two files, both `key = "value"` lines: `~/.config/oxy/config.toml` |
|
| 376 | 376 | (`oxy config --user KEY VALUE`) and `.oxy/config` (`oxy config KEY VALUE`). |
|
| 377 | - | The repository file wins. `OXY_HOME` relocates the user directory. |
|
| 377 | + | The repository file wins. `OXY_HOME` relocates the user directory. `OXY_TIMINGS=1` prints how long each phase of a snapshot takes (loading the base state, rendering it, reading the working copy, building the change) to standard error, for finding out where a slow command spends its time. |
|
| 378 | 378 | ||
| 379 | 379 | | Key | Meaning | |
|
| 380 | 380 | |---|---| |
|
| … | 51 unchanged lines |