OXYGENHUB

log and the state index without needless copies 79b5a90ffd43

oxy log read the working copy's metadata through the store's metas
cache, which it loads whole; it now uses the change it holds. The
metadata walk uses the fast hasher and takes each change's metadata out
of the index's map rather than cloning it, sorts unstably (the hash
makes the order total), and the shortest unique id prefixes are worked
out for the changes shown, against a sorted list, rather than for all.
The state index is read into a buffer the file histories point into, not
copied a second time, and its change list is hashed on every core.

VS Code at 19,978 commits: log -n 20 74 ms to 40 ms, the state index
load 43 ms to 28 ms; log and diff output unchanged.

refs #94
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-09 22:32 UTC
Signature
✔ signed by Andy McCall
Hash
79b5a90ffd432e0b213376f08421dd02b1ab8855816d41c6e84d96c9085f851a
Depends on
05059d434aca 170bb436e1d5 252e8cb23fbc 2bf22305bc48 36e51aa806c6 3f1b8fe25ec9 4cce912a0953 4d6c9c1b358c 728da0f92161 7505194116e7 75167672ebcd 8fd78eb52ad2 982864068a59 bc489b6c7f69 e02bf0cd0533 f9466021286a f9575918477f fce44ae9ad29 ff0af1ee299d
Depended on by
c6e69e4b8d8f 74b4a3c62ea4 bf0247ee3590 5552e69c7325 e8e6e0cac26a
In bookmarks
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.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
35 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A quicker log and diff on long histories (#94, first part).** `oxy log` no longer reads the working copy's metadata through the store's cache, walks the history with a fast hash and without copying each change's metadata, and works out the shortest unique id prefixes for the changes it shows rather than for every change. The state index is read without copying the file histories in it, and its change list is checked on every core. On VS Code at 20 000 commits, `log -n 20` went from 74 ms to 40 ms and loading the state index from 43 ms to 28 ms, with the same output.
6 +
5 7
- **Faster commits, and the state index after a checkpoint (#93).** A state loaded through a checkpoint read the changes after it whole, text included, so it could never be saved as the workspace's state index and every command after a checkpoint loaded history the long way (25 s for the first commit on VS Code at 20 000 commits). It now reads them as the index needs them. `oxy commit PATHS` no longer takes a second snapshot to count what it left when it took everything. A one-file commit on VS Code went from about 450 ms to 300 ms; 70 ms of that is the store's fsync, which stays.
6 8
7 9
- **Reading one file reads one file (#92).** `oxy cat` rendered every file of the state to print one. It now builds only that file, from the workspace's state index when it reads the working copy's base or the state it is on. On VS Code at 20 000 commits, `cat package.json -r main` went from 26 s to 0.10 s and at a change from 2016 from 0.66 s to 0.16 s; on the full 166 000-change history it takes 1.9 s and 1.3 GB, where it ran for minutes and used 9 GB.
…
150 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +46 −20
…
1485 unchanged lines
1486 1486
            heads.push(r.object().ok()?);
1487 1487
        }
1488 1488
        let (changes_sum, rest_sum) = (r.object().ok()?, r.object().ok()?);
1489 -
        let body = &bytes[4 + 32 * heads.len() + 64..];
1489 +
        let at = 4 + 32 * heads.len() + 64;
1490 +
        let body = bytes.get(at..)?;
1490 1491
        let split = 4 + r.count().ok()?;
1491 1492
        // Checked whole, so a part decoded later (the tree's maps, a
1492 1493
        // file's history) can trust its bytes.
1493 1494
        let (changes, rest) = (body.get(..split)?, body.get(split..)?);
1494 -
        let mut h = blake3::Hasher::new();
1495 -
        h.update_rayon(rest);
1496 -
        if *blake3::hash(changes).as_bytes() != changes_sum.0
1497 -
            || *h.finalize().as_bytes() != rest_sum.0
1498 -
        {
1495 +
        let sum = |b: &[u8]| *blake3::Hasher::new().update_rayon(b).finalize().as_bytes();
1496 +
        if sum(changes) != changes_sum.0 || sum(rest) != rest_sum.0 {
1499 1497
            return None;
1500 1498
        }
1501 -
        Some((State::read_index(body).ok()?, heads))
1499 +
        // The histories point into this buffer rather than copying it.
1500 +
        Some((
1501 +
            State::read_index_in(&std::sync::Arc::new(bytes), at).ok()?,
1502 +
            heads,
1503 +
        ))
1502 1504
    }
1503 1505
1504 1506
    /// The changes of this workspace's state index (the closure of its
…
15 unchanged lines
1520 1522
        let mut changes = vec![0u8; 4 + len];
1521 1523
        changes[..4].copy_from_slice(&head[32 * n + 64..]);
1522 1524
        f.read_exact(&mut changes[4..]).ok()?;
1523 -
        if blake3::hash(&changes).as_bytes() != sum {
1525 +
        let checking = crate::timing::phase("load:   of which checking");
1526 +
        let mut h = blake3::Hasher::new();
1527 +
        h.update_rayon(&changes);
1528 +
        if h.finalize().as_bytes() != sum {
1524 1529
            return None;
1525 1530
        }
1531 +
        drop(checking);
1532 +
        let _decoding = crate::timing::phase("load:   of which decoding");
1526 1533
        State::read_index_changes(&changes[4..]).ok()
1527 1534
    }
1528 1535
…
20 unchanged lines
1549 1556
            w.object(h);
1550 1557
        }
1551 1558
        let split = 4 + u32::from_le_bytes(body[..4].try_into().unwrap()) as usize;
1552 -
        w.fixed(blake3::hash(&body[..split]).as_bytes());
1559 +
        w.fixed(
1560 +
            blake3::Hasher::new()
1561 +
                .update_rayon(&body[..split])
1562 +
                .finalize()
1563 +
                .as_bytes(),
1564 +
        );
1553 1565
        let mut sum = blake3::Hasher::new();
1554 1566
        sum.update_rayon(&body[split..]);
1555 1567
        w.fixed(sum.finalize().as_bytes());
…
291 unchanged lines
1847 1859
        if self.bare {
1848 1860
            return self.log_state(&heads);
1849 1861
        }
1850 -
        let (wc_hash, _) = self.working_copy()?;
1862 +
        let (wc_hash, wc) = self.working_copy()?;
1851 1863
        let mut all = heads.clone();
1852 1864
        all.push(wc_hash);
1853 1865
        let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect();
…
6 unchanged lines
1860 1872
                .map(|(h, m)| (*h, m.clone()))
1861 1873
                .collect()
1862 1874
        } else {
1863 -
            self.metas_of(&all)?
1875 +
            // The working copy's change is in hand, and is never in the
1876 +
            // state index: its metadata is not looked up again.
1877 +
            let mut below: Vec<ChangeHash> = head_set.iter().copied().collect();
1878 +
            below.extend(wc.deps.iter().copied());
1879 +
            let _t = crate::timing::phase("log: metadata");
1880 +
            let mut metas = self.metas_of(&below)?;
1881 +
            if !metas.iter().any(|(h, _)| *h == wc_hash) {
1882 +
                metas.push((wc_hash, crate::state::ChangeMeta::of(&wc)));
1883 +
            }
1884 +
            metas
1864 1885
        };
1865 1886
        for (h, meta) in metas {
1866 1887
            entries.push(LogEntry {
…
3 unchanged lines
1870 1891
                is_head: head_set.contains(&h),
1871 1892
            });
1872 1893
        }
1873 -
        entries.sort_by(|a, b| {
1894 +
        let _sorting = crate::timing::phase("log: sort");
1895 +
        // The hash makes the order total, so an unstable sort gives the same.
1896 +
        entries.sort_unstable_by(|a, b| {
1874 1897
            b.is_working_copy
1875 1898
                .cmp(&a.is_working_copy)
1876 1899
                .then(b.change.hlc.cmp(&a.change.hlc))
…
36 unchanged lines
1913 1936
                is_working_copy: false,
1914 1937
            })
1915 1938
            .collect();
1916 -
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
1939 +
        entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
1917 1940
        Ok(entries)
1918 1941
    }
1919 1942
…
8 unchanged lines
1928 1951
        // The state index holds the metadata of its heads' closure: a
1929 1952
        // change found there is not read from the store (a change object
1930 1953
        // can be as large as the tree it adds).
1931 -
        let indexed = self.read_state_index_changes();
1954 +
        let mut indexed = self.read_state_index_changes();
1955 +
        let _walk = crate::timing::phase("metadata: walk");
1932 1956
        let (mut stored, mut from_store) = (0usize, std::time::Duration::ZERO);
1933 -
        let mut seen: HashSet<ChangeHash> = HashSet::new();
1957 +
        let mut seen: crate::fasthash::FastSet<ChangeHash> = Default::default();
1934 1958
        let mut stack: Vec<ChangeHash> = heads.to_vec();
1935 -
        let mut out = Vec::new();
1959 +
        let mut out = Vec::with_capacity(indexed.as_ref().map_or(0, |m| m.len()));
1936 1960
        while let Some(h) = stack.pop() {
1937 1961
            if !seen.insert(h) {
1938 1962
                continue;
1939 1963
            }
1940 -
            if let Some(meta) = indexed.as_ref().and_then(|m| m.get(&h)) {
1964 +
            // Taken out of the index's map, each once: not copied.
1965 +
            if let Some(meta) = indexed.as_mut().and_then(|m| m.remove(&h)) {
1941 1966
                #[cfg(test)]
1942 1967
                assert_eq!(
1943 -
                    **meta,
1968 +
                    *meta,
1944 1969
                    self.store.get_change_meta(&h)?,
1945 1970
                    "the state index holds a change's metadata"
1946 1971
                );
1972 +
                let meta = std::sync::Arc::unwrap_or_clone(meta);
1947 1973
                stack.extend(meta.deps.iter().copied());
1948 -
                out.push((h, (**meta).clone()));
1974 +
                out.push((h, meta));
1949 1975
                continue;
1950 1976
            }
1951 1977
            let started = std::time::Instant::now();
…
65 unchanged lines
2017 2043
                is_head: head_set.contains(h),
2018 2044
            })
2019 2045
            .collect();
2020 -
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
2046 +
        entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
2021 2047
        entries
2022 2048
    }
2023 2049
…
264 unchanged lines
modified crates/oxy-core/src/state.rs +16 −7
…
125 unchanged lines
126 126
struct Deferred {
127 127
    body: std::sync::OnceLock<DeferredBody>,
128 128
    /// The body as the index encodes it, decoded into `body` on first use.
129 -
    raw: Option<(Arc<[u8]>, std::ops::Range<usize>)>,
129 +
    raw: Option<(Arc<Vec<u8>>, std::ops::Range<usize>)>,
130 130
    /// Its graph key, once worked out (or read from an index); reset by
131 131
    /// any edit added.
132 132
    key: std::sync::OnceLock<[u8; 32]>,
…
225 unchanged lines
358 358
359 359
    /// The state an index holds (see [`State::write_index`]).
360 360
    pub fn read_index(bytes: &[u8]) -> Result<State, ObjectError> {
361 +
        Self::read_index_in(&Arc::new(bytes.to_vec()), 0)
362 +
    }
363 +
364 +
    /// As [`State::read_index`], for an index at `at` in `file`, which the
365 +
    /// files' histories keep pointing into rather than copying: most of an
366 +
    /// index is histories a command never decodes.
367 +
    pub fn read_index_in(file: &Arc<Vec<u8>>, at: usize) -> Result<State, ObjectError> {
361 368
        use crate::flat::Reader;
362 369
        let bad = |what: &str| ObjectError::Schema(format!("state index: {what}"));
370 +
        let bytes = file.get(at..).ok_or_else(|| bad("truncated"))?;
363 371
        let mut r = Reader::new(bytes);
364 372
        let mut st = State::new_history_only();
365 373
        {
…
13 unchanged lines
379 387
        if !r.at_end() {
380 388
            return Err(bad("trailing bytes"));
381 389
        }
390 +
        let start = deferred_bytes.as_ptr() as usize - file.as_ptr() as usize;
382 391
        let (tree, deferred) = rayon::join(
383 392
            || TreeGraph::read_flat(&mut Reader::new(tree_bytes)),
384 -
            || Self::read_deferred(deferred_bytes),
393 +
            || Self::read_deferred(file, start..start + deferred_bytes.len()),
385 394
        );
386 395
        st.tree = tree?;
387 396
        st.deferred = Some(deferred?);
…
25 unchanged lines
413 422
    /// The deferred block: each node with its graph key and its body,
414 423
    /// left encoded (see [`Deferred`]).
415 424
    fn read_deferred(
416 -
        bytes: &[u8],
425 +
        file: &Arc<Vec<u8>>,
426 +
        within: std::ops::Range<usize>,
417 427
    ) -> Result<crate::fasthash::FastMap<NodeId, Arc<Deferred>>, ObjectError> {
418 428
        use crate::flat::Reader;
419 429
        let _t = crate::timing::phase("index: deferred");
420 -
        let all: Arc<[u8]> = Arc::from(bytes);
421 -
        let mut r = Reader::new(bytes);
430 +
        let mut r = Reader::new(&file[within]);
422 431
        let mut deferred = crate::fasthash::FastMap::default();
423 432
        let n = r.count()?;
424 433
        deferred.reserve(n);
…
1 unchanged lines
426 435
            let id = NodeId(r.fixed::<16>()?);
427 436
            let key = r.fixed::<32>()?;
428 437
            let body = r.bytes()?;
429 -
            let start = body.as_ptr() as usize - bytes.as_ptr() as usize;
438 +
            let start = body.as_ptr() as usize - file.as_ptr() as usize;
430 439
            let d = Deferred {
431 -
                raw: Some((all.clone(), start..start + body.len())),
440 +
                raw: Some((file.clone(), start..start + body.len())),
432 441
                key: std::sync::OnceLock::from(key),
433 442
                ..Deferred::default()
434 443
            };
…
2295 unchanged lines
modified src/cli/workspace.rs +73 −2
…
685 unchanged lines
686 686
    } else {
687 687
        &entries
688 688
    };
689 -
    let id_prefix = unique_prefixes(all.iter().map(|e| e.change.change_id.to_hex()));
689 +
    let prefixes = oxy_core::timing::phase("log: id prefixes");
690 +
    let id_prefix = id_prefixes(
691 +
        all.iter().map(|e| e.change.change_id.0).collect(),
692 +
        entries.iter().take(shown).map(|e| e.change.change_id.0),
693 +
    );
694 +
    drop(prefixes);
690 695
    let labels = reference_labels(&repo)?;
691 696
    for (e, row) in entries.iter().take(shown).zip(&rows) {
692 697
        let c = &e.change;
…
3 unchanged lines
696 701
            e.is_working_copy && conflicted,
697 702
        );
698 703
        let id = c.change_id.to_hex();
699 -
        let n = id_prefix.get(&id).copied().unwrap_or(8).min(8);
704 +
        let n = id_prefix.get(&c.change_id.0).copied().unwrap_or(8).min(8);
700 705
        let mut head = vec![format!("{}{}", ui::id(&id[..n]), ui::dim(&id[n..8]))];
701 706
        head.push(ui::author(author_name(&repo, c)));
702 707
        head.push(ui::dim(ui::ago(c.hlc.wall_ms)));
…
151 unchanged lines
854 859
}
855 860
856 861
/// For each hex ID, the length of its shortest prefix no other ID shares.
862 +
/// The shortest prefix, in hex digits, that names each of `wanted` among
863 +
/// `all`: worked out for the few shown rather than for the whole history.
864 +
fn id_prefixes(
865 +
    mut all: Vec<[u8; 16]>,
866 +
    wanted: impl Iterator<Item = [u8; 16]>,
867 +
) -> std::collections::HashMap<[u8; 16], usize> {
868 +
    all.sort_unstable();
869 +
    all.dedup();
870 +
    // Hex digits two ids share at the start.
871 +
    let common = |a: &[u8; 16], b: &[u8; 16]| {
872 +
        let bytes = a.iter().zip(b).take_while(|(x, y)| x == y).count();
873 +
        match (a.get(bytes), b.get(bytes)) {
874 +
            (Some(x), Some(y)) if x >> 4 == y >> 4 => 2 * bytes + 1,
875 +
            _ => 2 * bytes,
876 +
        }
877 +
    };
878 +
    let mut out = std::collections::HashMap::new();
879 +
    for id in wanted {
880 +
        let Ok(i) = all.binary_search(&id) else {
881 +
            continue;
882 +
        };
883 +
        let before = i.checked_sub(1).map_or(0, |j| common(&all[j], &id));
884 +
        let after = all.get(i + 1).map_or(0, |n| common(&id, n));
885 +
        out.insert(id, before.max(after) + 1);
886 +
    }
887 +
    out
888 +
}
889 +
857 890
fn unique_prefixes(ids: impl Iterator<Item = String>) -> std::collections::HashMap<String, usize> {
858 891
    let mut v: Vec<String> = ids.collect();
859 892
    v.sort();
…
1841 unchanged lines
2701 2734
    ));
2702 2735
    where_now(&repo);
2703 2736
    Ok(0)
2737 +
}
2738 +
2739 +
#[cfg(test)]
2740 +
mod tests {
2741 +
    use super::*;
2742 +
2743 +
    #[test]
2744 +
    fn id_prefixes_agree_with_the_whole_list() {
2745 +
        // Ids that share long prefixes, and some that differ only in a
2746 +
        // half byte, from a fixed sequence.
2747 +
        let mut x: u64 = 0x9e37_79b9_7f4a_7c15;
2748 +
        let mut next = || {
2749 +
            x ^= x << 13;
2750 +
            x ^= x >> 7;
2751 +
            x ^= x << 17;
2752 +
            x
2753 +
        };
2754 +
        let mut ids: Vec<[u8; 16]> = Vec::new();
2755 +
        for _ in 0..2000 {
2756 +
            let mut id = [0u8; 16];
2757 +
            id[..8].copy_from_slice(&next().to_le_bytes());
2758 +
            id[8..].copy_from_slice(&next().to_le_bytes());
2759 +
            let share = (next() % 4) as usize;
2760 +
            if let Some(prev) = ids.last() {
2761 +
                id[..share].copy_from_slice(&prev[..share]);
2762 +
                if next() % 2 == 0 {
2763 +
                    id[share] = (prev[share] & 0xf0) | (id[share] & 0x0f);
2764 +
                }
2765 +
            }
2766 +
            ids.push(id);
2767 +
        }
2768 +
        let hex = |id: &[u8; 16]| oxy_core::ids::ChangeId(*id).to_hex();
2769 +
        let whole = unique_prefixes(ids.iter().map(hex));
2770 +
        let some = id_prefixes(ids.clone(), ids.iter().copied());
2771 +
        for id in &ids {
2772 +
            assert_eq!(some[id], whole[&hex(id)], "{}", hex(id));
2773 +
        }
2774 +
    }
2704 2775
}