Git import holds a budget of graph memory d45dab0484ea
A long-lived file's graph keeps every line it ever had, and a kept state shed its graphs only past 2 000 of them, so a few large graphs held gigabytes; past 1.5 million atoms they are shed as well (KEPT_ATOMS, State::built_atoms; OXY_IMPORT_KEPT_ATOMS for measuring). Freed pages go back to the system after each merge. depends_on walks down by topological level instead of memoising each queried change's whole ancestry, and the closure memo is capped. Measured on VS Code's 28 637-commit reproducer: 16.9 GB to 5.1 GB, 61 to 36 minutes, the same verify result as before. The trace adds atoms and deferred edits. refs #25
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 23:02 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d45dab0484eadd0d03432e03b51d6b71c144e7a27e0c978fd7cdc8bace414c50- Depends on
- 119bbf50cb86 3f1b8fe25ec9 55e5a16a2b9c 614f55e14eee 79ab8528ff62 8fd78eb52ad2 c07406bcc237 d443639bcf35 e8d38e47e9d7 f6451fbbae3f f9575918477f fb5b6181618a fd280228bdc2 ff0af1ee299d
- Depended on by
- 4476cdbb0443 aedfe465b611 5552e69c7325 810691ca6ce6 9935d490ddfb 33be24095b4b
- 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.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
- 21 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 | + | - **Git import holds a budget of graph memory (#25).** A long-lived file's graph keeps every line it ever had, and the import shed a kept state's graphs only past 2 000 of them; it now also sheds them past 1.5 million atoms, and gives freed memory back after each merge. A 28 637-commit slice of VS Code imports at 5.1 GB instead of 16.9 GB, in 36 minutes instead of 61, with the same verify result. The dependency check no longer memoises every queried change's whole ancestry. |
|
| 6 | + | ||
| 5 | 7 | - **A rotation follows you everywhere (#69).** `delegate add`, `delegate remove`, `rotate` and `renew` record the successor key in the user config when it named the old key, and in the repository's own file when that pinned it, so every checkout moves together. `oxy identity show` warns when a repository pins a key other than the user config's; `oxy config --unset KEY` drops a key; `oxy identity delegate add` takes several identity files in one rotation and reminds you that agents configured with your `author.key` need the new one. |
|
| 6 | 8 | ||
| 7 | 9 | ## 0.4.0 (2026-10-04) |
|
| … | 102 unchanged lines |
modified crates/oxy-core/src/alloc_stats.rs +13 −0
| … | 76 unchanged lines |
||
| 77 | 77 | count: COUNT.load(Relaxed), |
|
| 78 | 78 | }) |
|
| 79 | 79 | } |
|
| 80 | + | ||
| 81 | + | /// Give pages the program has freed back to the system. glibc keeps freed |
|
| 82 | + | /// memory in each thread's arena; after a large merge renders thousands of |
|
| 83 | + | /// files on many threads, that kept memory is most of the process's size |
|
| 84 | + | /// (#25). Elsewhere it does nothing. |
|
| 85 | + | pub fn release_freed_memory() { |
|
| 86 | + | #[cfg(all(target_os = "linux", target_env = "gnu"))] |
|
| 87 | + | // SAFETY: malloc_trim only returns free pages to the system; it is |
|
| 88 | + | // safe to call at any time from any thread. |
|
| 89 | + | unsafe { |
|
| 90 | + | libc::malloc_trim(0); |
|
| 91 | + | } |
|
| 92 | + | } |
modified crates/oxy-core/src/repo/git.rs +30 −2
| … | 287 unchanged lines |
||
| 288 | 288 | ||
| 289 | 289 | /// A merge bringing more commits than this sheds its base's graphs first. |
|
| 290 | 290 | const MERGE_SHED_COMMITS: usize = 100; |
|
| 291 | + | ||
| 292 | + | /// The atoms a kept state's built graphs may hold before they go back into |
|
| 293 | + | /// its history (#25): about a few hundred megabytes. |
|
| 294 | + | const KEPT_ATOMS: usize = 1_500_000; |
|
| 291 | 295 | /// A kept state with more built graphs than this sheds them. |
|
| 292 | 296 | const KEPT_GRAPHS: usize = 2000; |
|
| 293 | 297 | ||
| … | 465 unchanged lines |
||
| 759 | 763 | .unwrap_or(0) |
|
| 760 | 764 | }; |
|
| 761 | 765 | let mut done = 0usize; |
|
| 766 | + | // Atoms a kept state's graphs may hold before they are shed; |
|
| 767 | + | // OXY_IMPORT_KEPT_ATOMS overrides it, for measuring. |
|
| 768 | + | let kept_atoms: usize = std::env::var("OXY_IMPORT_KEPT_ATOMS") |
|
| 769 | + | .ok() |
|
| 770 | + | .and_then(|v| v.parse().ok()) |
|
| 771 | + | .unwrap_or(KEPT_ATOMS); |
|
| 762 | 772 | // Graphs turned back into history (see `State::shed_graphs`). |
|
| 763 | 773 | let mut shed = 0usize; |
|
| 764 | 774 | let mut lap = |i: usize, clock: &mut std::time::Instant| { |
|
| … | 380 unchanged lines |
||
| 1145 | 1155 | if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) { |
|
| 1146 | 1156 | // A kept state holds its graphs until its children come: |
|
| 1147 | 1157 | // past a few thousand, they go back into its history. |
|
| 1148 | - | if base.built_count() > KEPT_GRAPHS { |
|
| 1158 | + | // Past a few thousand graphs, or past a budget of atoms: a |
|
| 1159 | + | // long-lived file's graph keeps every line it ever had, so a |
|
| 1160 | + | // handful of them can hold gigabytes (#25). |
|
| 1161 | + | if base.built_count() > KEPT_GRAPHS || base.built_atoms() > kept_atoms { |
|
| 1149 | 1162 | shed += base.shed_graphs(|b| self.store_blob(b))?; |
|
| 1150 | 1163 | } |
|
| 1151 | 1164 | kept.insert(gc.sha.clone(), (base, tree)); |
|
| … | 8 unchanged lines |
||
| 1160 | 1173 | report.commits += 1; |
|
| 1161 | 1174 | lap(5, &mut clock); |
|
| 1162 | 1175 | done += 1; |
|
| 1176 | + | // A merge renders thousands of files on many threads and frees |
|
| 1177 | + | // it all; the allocator keeps those pages unless asked to give |
|
| 1178 | + | // them back, and the next merge piles on top (#25). |
|
| 1179 | + | let before_trim = if trace { rss_mb() } else { 0 }; |
|
| 1180 | + | if gc.parents.len() > 1 { |
|
| 1181 | + | crate::alloc_stats::release_freed_memory(); |
|
| 1182 | + | } |
|
| 1163 | 1183 | if trace { |
|
| 1164 | 1184 | let built = kept |
|
| 1165 | 1185 | .get(gc.sha.as_str()) |
|
| 1166 | 1186 | .map(|(st, _)| st.built_count()) |
|
| 1187 | + | .unwrap_or(0); |
|
| 1188 | + | let (edits, most) = kept |
|
| 1189 | + | .get(gc.sha.as_str()) |
|
| 1190 | + | .map(|(st, _)| st.deferred_edit_count()) |
|
| 1191 | + | .unwrap_or((0, 0)); |
|
| 1192 | + | let atoms = kept |
|
| 1193 | + | .get(gc.sha.as_str()) |
|
| 1194 | + | .map(|(st, _)| st.built_atoms()) |
|
| 1167 | 1195 | .unwrap_or(0); |
|
| 1168 | 1196 | eprintln!( |
|
| 1169 | - | "trace {done} {} parents={} base={base_how} merged={merged} built={built} kept={} shed={shed} rss_mb={}", |
|
| 1197 | + | "trace {done} {} parents={} base={base_how} merged={merged} built={built} atoms={atoms} kept={} shed={shed} rss_mb={} before_trim_mb={before_trim} deferred_edits={edits} most={most}", |
|
| 1170 | 1198 | &gc.sha[..12.min(gc.sha.len())], |
|
| 1171 | 1199 | gc.parents.len(), |
|
| 1172 | 1200 | kept.len(), |
|
| … | 534 unchanged lines |
modified crates/oxy-core/src/state.rs +161 −8
| … | 96 unchanged lines |
||
| 97 | 97 | /// by a few changes costs little more than one. |
|
| 98 | 98 | files: crate::fasthash::FastMap<NodeId, Arc<FileGraph>>, |
|
| 99 | 99 | replaces: HashMap<NodeId, Vec<ReplaceRecord>>, |
|
| 100 | - | /// Memoised transitive dependencies. A mutex, not a `RefCell`, so a |
|
| 101 | - | /// loaded state can be shared across threads by a host that caches it. |
|
| 102 | - | closure: Mutex<HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>>, |
|
| 100 | + | /// Memoised transitive dependencies, bounded by the hashes they hold |
|
| 101 | + | /// (each set is the whole history below a change, so keeping one for |
|
| 102 | + | /// every change asked about grew with the square of history: #25). A |
|
| 103 | + | /// mutex, not a `RefCell`, so a loaded state can be shared across |
|
| 104 | + | /// threads by a host that caches it. |
|
| 105 | + | closure: Mutex<ClosureMemo>, |
|
| 106 | + | /// Each change's level: 0 with no dependencies, else one more than its |
|
| 107 | + | /// highest dependency. A change can only depend on changes of lower |
|
| 108 | + | /// level, which bounds the walk [`DepOracle::depends_on`] does. |
|
| 109 | + | levels: Mutex<HashMap<ChangeHash, u32>>, |
|
| 103 | 110 | /// `None` for a full state. For a history-only state (loaded for its |
|
| 104 | 111 | /// changes and tree), the file graphs not built yet, by node: `files` |
|
| 105 | 112 | /// holds only those loaded with [`State::load_graphs`]. |
|
| … | 19 unchanged lines |
||
| 125 | 132 | key: std::sync::OnceLock<[u8; 32]>, |
|
| 126 | 133 | } |
|
| 127 | 134 | ||
| 135 | + | /// The closures kept, and how many hashes they hold in all. |
|
| 136 | + | #[derive(Default)] |
|
| 137 | + | struct ClosureMemo { |
|
| 138 | + | sets: HashMap<ChangeHash, Arc<HashSet<ChangeHash>>>, |
|
| 139 | + | held: usize, |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// The most hashes the closure memo holds before it starts again: a few |
|
| 143 | + | /// hundred megabytes at most. |
|
| 144 | + | const CLOSURE_MEMO_HASHES: usize = 4_000_000; |
|
| 145 | + | ||
| 128 | 146 | #[derive(Clone, Default)] |
|
| 129 | 147 | struct DeferredBody { |
|
| 130 | 148 | base: Option<DeferredBase>, |
|
| … | 376 unchanged lines |
||
| 507 | 525 | files: self.files.clone(), |
|
| 508 | 526 | replaces: self.replaces.clone(), |
|
| 509 | 527 | // A cache: the clone builds its own. |
|
| 510 | - | closure: Mutex::new(HashMap::new()), |
|
| 528 | + | closure: Mutex::new(ClosureMemo::default()), |
|
| 529 | + | levels: Mutex::new(HashMap::new()), |
|
| 511 | 530 | deferred: self.deferred.clone(), |
|
| 512 | 531 | } |
|
| 513 | 532 | } |
|
| … | 476 unchanged lines |
||
| 990 | 1009 | pub fn built_count(&self) -> usize { |
|
| 991 | 1010 | self.files.len() |
|
| 992 | 1011 | } |
|
| 1012 | + | ||
| 1013 | + | /// Atoms in this state's built graphs, live and deleted: what their |
|
| 1014 | + | /// memory follows, since a graph keeps every line its file ever had. |
|
| 1015 | + | pub fn built_atoms(&self) -> usize { |
|
| 1016 | + | self.files.values().map(|g| g.atom_count()).sum() |
|
| 1017 | + | } |
|
| 1018 | + | ||
| 1019 | + | /// Edits held in the deferred histories of files not built, and the |
|
| 1020 | + | /// most any one file holds (for measuring, #25). |
|
| 1021 | + | pub fn deferred_edit_count(&self) -> (usize, usize) { |
|
| 1022 | + | let Some(d) = self.deferred.as_ref() else { |
|
| 1023 | + | return (0, 0); |
|
| 1024 | + | }; |
|
| 1025 | + | let mut total = 0; |
|
| 1026 | + | let mut most = 0; |
|
| 1027 | + | for v in d.values() { |
|
| 1028 | + | let n: usize = v.body().edits.iter().map(|(_, e)| e.len()).sum(); |
|
| 1029 | + | total += n; |
|
| 1030 | + | most = most.max(n); |
|
| 1031 | + | } |
|
| 1032 | + | (total, most) |
|
| 1033 | + | } |
|
| 993 | 1034 | } |
|
| 994 | 1035 | ||
| 995 | 1036 | impl Deferred { |
|
| … | 133 unchanged lines |
||
| 1129 | 1170 | let meta = ChangeMeta::of(&change); |
|
| 1130 | 1171 | self.apply_with(h, change, texts); |
|
| 1131 | 1172 | self.changes.insert(h, Arc::new(meta)); |
|
| 1132 | - | self.closure.lock().unwrap().clear(); |
|
| 1173 | + | { |
|
| 1174 | + | let mut m = self.closure.lock().unwrap(); |
|
| 1175 | + | m.sets.clear(); |
|
| 1176 | + | m.held = 0; |
|
| 1177 | + | } |
|
| 1133 | 1178 | } |
|
| 1134 | 1179 | ||
| 1135 | 1180 | // ------------------------------------------------------------ checkpoints (spec §11) |
|
| … | 381 unchanged lines |
||
| 1517 | 1562 | ||
| 1518 | 1563 | /// Transitive dependencies of `h`, excluding `h` itself. |
|
| 1519 | 1564 | pub fn closure(&self, h: &ChangeHash) -> Arc<HashSet<ChangeHash>> { |
|
| 1520 | - | if let Some(c) = self.closure.lock().unwrap().get(h) { |
|
| 1565 | + | if let Some(c) = self.closure.lock().unwrap().sets.get(h) { |
|
| 1521 | 1566 | return c.clone(); |
|
| 1522 | 1567 | } |
|
| 1523 | 1568 | let mut out = HashSet::new(); |
|
| … | 10 unchanged lines |
||
| 1534 | 1579 | } |
|
| 1535 | 1580 | } |
|
| 1536 | 1581 | let rc = Arc::new(out); |
|
| 1537 | - | self.closure.lock().unwrap().insert(*h, rc.clone()); |
|
| 1582 | + | let mut memo = self.closure.lock().unwrap(); |
|
| 1583 | + | if memo.held + rc.len() > CLOSURE_MEMO_HASHES { |
|
| 1584 | + | memo.sets.clear(); |
|
| 1585 | + | memo.held = 0; |
|
| 1586 | + | } |
|
| 1587 | + | memo.held += rc.len(); |
|
| 1588 | + | memo.sets.insert(*h, rc.clone()); |
|
| 1538 | 1589 | rc |
|
| 1539 | 1590 | } |
|
| 1540 | 1591 | ||
| 1592 | + | /// `h`'s level (see `levels`), worked out for it and its ancestors not |
|
| 1593 | + | /// yet known, without recursion: histories are deeper than a stack. An |
|
| 1594 | + | /// unknown change has level 0. |
|
| 1595 | + | fn level(&self, h: &ChangeHash) -> u32 { |
|
| 1596 | + | let mut levels = self.levels.lock().unwrap(); |
|
| 1597 | + | if let Some(l) = levels.get(h) { |
|
| 1598 | + | return *l; |
|
| 1599 | + | } |
|
| 1600 | + | let mut stack: Vec<(ChangeHash, bool)> = vec![(*h, false)]; |
|
| 1601 | + | while let Some((c, expanded)) = stack.pop() { |
|
| 1602 | + | if levels.contains_key(&c) { |
|
| 1603 | + | continue; |
|
| 1604 | + | } |
|
| 1605 | + | let deps: &[ChangeHash] = self |
|
| 1606 | + | .changes |
|
| 1607 | + | .get(&c) |
|
| 1608 | + | .map(|m| m.deps.as_slice()) |
|
| 1609 | + | .unwrap_or(&[]); |
|
| 1610 | + | if expanded { |
|
| 1611 | + | let l = deps |
|
| 1612 | + | .iter() |
|
| 1613 | + | .map(|d| levels.get(d).map_or(0, |l| l + 1)) |
|
| 1614 | + | .max() |
|
| 1615 | + | .unwrap_or(0); |
|
| 1616 | + | levels.insert(c, l); |
|
| 1617 | + | } else { |
|
| 1618 | + | stack.push((c, true)); |
|
| 1619 | + | for d in deps { |
|
| 1620 | + | if !levels.contains_key(d) { |
|
| 1621 | + | stack.push((*d, false)); |
|
| 1622 | + | } |
|
| 1623 | + | } |
|
| 1624 | + | } |
|
| 1625 | + | } |
|
| 1626 | + | levels[h] |
|
| 1627 | + | } |
|
| 1628 | + | ||
| 1541 | 1629 | /// Changes that no other change depends on directly, sorted. |
|
| 1542 | 1630 | pub fn heads(&self) -> Vec<ChangeHash> { |
|
| 1543 | 1631 | let depended: HashSet<&ChangeHash> = |
|
| … | 295 unchanged lines |
||
| 1839 | 1927 | } |
|
| 1840 | 1928 | ||
| 1841 | 1929 | impl DepOracle for State { |
|
| 1930 | + | /// Whether `a` depends on `b`, by a walk down from `a` that stops at |
|
| 1931 | + | /// changes no higher than `b`: they cannot have `b` below them. Nothing |
|
| 1932 | + | /// of the walk is kept, so asking about many pairs costs no more memory |
|
| 1933 | + | /// than one walk (#25); a closure already memoised answers at once. |
|
| 1842 | 1934 | fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool { |
|
| 1843 | - | a == b || self.closure(a).contains(b) |
|
| 1935 | + | if a == b { |
|
| 1936 | + | return true; |
|
| 1937 | + | } |
|
| 1938 | + | if let Some(c) = self.closure.lock().unwrap().sets.get(a) { |
|
| 1939 | + | return c.contains(b); |
|
| 1940 | + | } |
|
| 1941 | + | if !self.changes.contains_key(b) { |
|
| 1942 | + | return false; |
|
| 1943 | + | } |
|
| 1944 | + | let lb = self.level(b); |
|
| 1945 | + | if self.level(a) <= lb { |
|
| 1946 | + | return false; |
|
| 1947 | + | } |
|
| 1948 | + | let mut seen: HashSet<ChangeHash> = HashSet::new(); |
|
| 1949 | + | let mut stack: Vec<ChangeHash> = self |
|
| 1950 | + | .changes |
|
| 1951 | + | .get(a) |
|
| 1952 | + | .map(|m| m.deps.clone()) |
|
| 1953 | + | .unwrap_or_default(); |
|
| 1954 | + | while let Some(c) = stack.pop() { |
|
| 1955 | + | if c == *b { |
|
| 1956 | + | return true; |
|
| 1957 | + | } |
|
| 1958 | + | if !seen.insert(c) || self.level(&c) <= lb { |
|
| 1959 | + | continue; |
|
| 1960 | + | } |
|
| 1961 | + | if let Some(m) = self.changes.get(&c) { |
|
| 1962 | + | stack.extend(m.deps.iter().copied()); |
|
| 1963 | + | } |
|
| 1964 | + | } |
|
| 1965 | + | false |
|
| 1844 | 1966 | } |
|
| 1845 | 1967 | } |
|
| 1846 | 1968 | ||
| … | 684 unchanged lines |
||
| 2531 | 2653 | want.sort(); |
|
| 2532 | 2654 | assert_eq!(m, want); |
|
| 2533 | 2655 | assert_eq!(st.heads(), want); |
|
| 2656 | + | } |
|
| 2657 | + | ||
| 2658 | + | /// The bounded walk of `depends_on` agrees with the closure on random |
|
| 2659 | + | /// graphs, and the memo stays within its bound. |
|
| 2660 | + | #[test] |
|
| 2661 | + | fn depends_on_matches_the_closure() { |
|
| 2662 | + | use rand::{Rng, SeedableRng}; |
|
| 2663 | + | let mut rng = rand::rngs::StdRng::seed_from_u64(11); |
|
| 2664 | + | for _ in 0..10 { |
|
| 2665 | + | let mut st = State::new(); |
|
| 2666 | + | let mut all: Vec<ChangeHash> = Vec::new(); |
|
| 2667 | + | for _ in 0..80 { |
|
| 2668 | + | let deps: Vec<ChangeHash> = |
|
| 2669 | + | all.iter().copied().filter(|_| rng.gen_bool(0.06)).collect(); |
|
| 2670 | + | all.push(st.add(change(deps, vec![], vec![])).unwrap()); |
|
| 2671 | + | } |
|
| 2672 | + | let unknown = ObjectRef([0xee; 32]); |
|
| 2673 | + | for a in &all { |
|
| 2674 | + | let want = st.closure(a).clone(); |
|
| 2675 | + | st.closure.lock().unwrap().sets.clear(); |
|
| 2676 | + | st.closure.lock().unwrap().held = 0; |
|
| 2677 | + | for b in all.iter().chain([&unknown]) { |
|
| 2678 | + | assert_eq!( |
|
| 2679 | + | st.depends_on(a, b), |
|
| 2680 | + | a == b || want.contains(b), |
|
| 2681 | + | "{a:?} on {b:?}" |
|
| 2682 | + | ); |
|
| 2683 | + | } |
|
| 2684 | + | } |
|
| 2685 | + | assert!(st.closure.lock().unwrap().held <= CLOSURE_MEMO_HASHES); |
|
| 2686 | + | } |
|
| 2534 | 2687 | } |
|
| 2535 | 2688 | ||
| 2536 | 2689 | /// The one-walk `maximal` agrees with the definition (not in the |
|
| … | 29 unchanged lines |