OXYGENHUB

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