git import: graphs shed before large merges and from kept states (refs #25) fd280228bdc2
The full vscode import passed 14 GB on merges of long-lived branches: thousands of changes were applied to built graphs that the kept states shared, so each was copied and grown. State::shed_graphs turns a history-only state's built graphs back into deferred histories, each starting from the graph stored as a checkpoint-form blob. The importer sheds before a merge bringing more than 100 commits, so their changes go in as skeletons, and when a kept state holds more than 2 000 graphs. The 10 000-commit slice: 146 s and 1.6 GB (from 136 s and 2.2 GB), the last 1 000 commits verified.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 05:21 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
fd280228bdc252bb48aecdfb07489c7f3fbc5304362888c85a8f22b400add118- Depends on
- 2306a4d24779 30ea9c1e70fa 79ab8528ff62 958fb27dda7c 982864068a59 c3069537d7c5 d4dbab97e948 fb5b6181618a ff0af1ee299d
- Depended on by
- 9935d490ddfb 33be24095b4b d45dab0484ea c3d2ef6104c2 360aa74a6277
- 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.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
- 9 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/oxy-core/src/repo/git.rs +30 −2
| … | 285 unchanged lines |
||
| 286 | 286 | pub heads: usize, |
|
| 287 | 287 | } |
|
| 288 | 288 | ||
| 289 | + | /// A merge bringing more commits than this sheds its base's graphs first. |
|
| 290 | + | const MERGE_SHED_COMMITS: usize = 100; |
|
| 291 | + | /// A kept state with more built graphs than this sheds them. |
|
| 292 | + | const KEPT_GRAPHS: usize = 2000; |
|
| 293 | + | ||
| 289 | 294 | fn git(dir: &Path, args: &[&str]) -> Result<String> { |
|
| 290 | 295 | let out = Command::new("git") |
|
| 291 | 296 | .arg("-C") |
|
| … | 462 unchanged lines |
||
| 754 | 759 | .unwrap_or(0) |
|
| 755 | 760 | }; |
|
| 756 | 761 | let mut done = 0usize; |
|
| 762 | + | // Graphs turned back into history (see `State::shed_graphs`). |
|
| 763 | + | let mut shed = 0usize; |
|
| 757 | 764 | let mut lap = |i: usize, clock: &mut std::time::Instant| { |
|
| 758 | 765 | let now = std::time::Instant::now(); |
|
| 759 | 766 | spent[i] += now - *clock; |
|
| … | 54 unchanged lines |
||
| 814 | 821 | let mut args: Vec<String> = gc.parents[1..].to_vec(); |
|
| 815 | 822 | args.push(format!("^{}", gc.parents[0])); |
|
| 816 | 823 | let refs: Vec<&str> = args.iter().map(String::as_str).collect(); |
|
| 824 | + | // A merge of a long-lived branch brings thousands of |
|
| 825 | + | // changes: added to built graphs, which kept states share, |
|
| 826 | + | // they were copied and applied whole (vscode's import passed |
|
| 827 | + | // 14 GB on such merges). Shed the graphs first, so the |
|
| 828 | + | // changes go in as skeletons and only what this commit |
|
| 829 | + | // touches is built again. |
|
| 830 | + | if base.built_count() > 0 { |
|
| 831 | + | let mut count_args = vec!["rev-list", "--count"]; |
|
| 832 | + | count_args.extend(refs.iter().copied()); |
|
| 833 | + | let others: usize = git(git_dir, &count_args)?.trim().parse().unwrap_or(0); |
|
| 834 | + | if others > MERGE_SHED_COMMITS { |
|
| 835 | + | shed += base.shed_graphs(|b| self.store_blob(b))?; |
|
| 836 | + | } |
|
| 837 | + | } |
|
| 817 | 838 | let before = base.len(); |
|
| 818 | 839 | self.add_git_ancestors_touching(&mut base, git_dir, &refs, &known, &mut touched)?; |
|
| 819 | 840 | merged = base.len() - before; |
|
| … | 302 unchanged lines |
||
| 1122 | 1143 | crate::timing::add("store: add to state", t.elapsed()); |
|
| 1123 | 1144 | known.imported.insert(gc.sha.clone(), hash); |
|
| 1124 | 1145 | if waiting.get(gc.sha.as_str()).is_some_and(|n| *n > 0) { |
|
| 1146 | + | // A kept state holds its graphs until its children come: |
|
| 1147 | + | // past a few thousand, they go back into its history. |
|
| 1148 | + | if base.built_count() > KEPT_GRAPHS { |
|
| 1149 | + | shed += base.shed_graphs(|b| self.store_blob(b))?; |
|
| 1150 | + | } |
|
| 1125 | 1151 | kept.insert(gc.sha.clone(), (base, tree)); |
|
| 1126 | 1152 | kept_order.push_back(gc.sha.clone()); |
|
| 1127 | 1153 | if kept_order.len() > max_kept { |
|
| … | 12 unchanged lines |
||
| 1140 | 1166 | .map(|(st, _)| st.built_count()) |
|
| 1141 | 1167 | .unwrap_or(0); |
|
| 1142 | 1168 | eprintln!( |
|
| 1143 | - | "trace {done} {} parents={} base={base_how} merged={merged} built={built} kept={} rss_mb={}", |
|
| 1169 | + | "trace {done} {} parents={} base={base_how} merged={merged} built={built} kept={} shed={shed} rss_mb={}", |
|
| 1144 | 1170 | &gc.sha[..12.min(gc.sha.len())], |
|
| 1145 | 1171 | gc.parents.len(), |
|
| 1146 | 1172 | kept.len(), |
|
| … | 16 unchanged lines |
||
| 1163 | 1189 | } |
|
| 1164 | 1190 | crate::timing::report_totals(); |
|
| 1165 | 1191 | if crate::timing::enabled() { |
|
| 1166 | - | eprintln!("timing git import: {rebuilt} bases rebuilt from history"); |
|
| 1192 | + | eprintln!( |
|
| 1193 | + | "timing git import: {rebuilt} bases rebuilt from history, {shed} graphs shed" |
|
| 1194 | + | ); |
|
| 1167 | 1195 | } |
|
| 1168 | 1196 | ||
| 1169 | 1197 | // The bookmark: heads of the old state plus every new change. |
|
| … | 509 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +34 −0
| … | 6418 unchanged lines |
||
| 6419 | 6419 | let snap = repo.snapshot_status("status").unwrap(); |
|
| 6420 | 6420 | assert!(snap.statuses.iter().any(|s| s.path == "kept.log")); |
|
| 6421 | 6421 | } |
|
| 6422 | + | ||
| 6423 | + | #[test] |
|
| 6424 | + | fn shed_graphs_come_back_the_same() { |
|
| 6425 | + | hermetic(); |
|
| 6426 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6427 | + | let root = dir.path(); |
|
| 6428 | + | let repo = Repo::init(root).unwrap(); |
|
| 6429 | + | write(root, "a.txt", b"1\n2\n3\n"); |
|
| 6430 | + | write(root, "b.txt", b"x\n"); |
|
| 6431 | + | repo.commit("first", &[]).unwrap(); |
|
| 6432 | + | write(root, "a.txt", b"1\ntwo\n3\n4\n"); |
|
| 6433 | + | repo.commit("second", &[]).unwrap(); |
|
| 6434 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 6435 | + | let mut st = repo.load_state_history(&heads).unwrap(); |
|
| 6436 | + | let view = st.tree().view(); |
|
| 6437 | + | let nodes: Vec<NodeId> = ["a.txt", "b.txt"] |
|
| 6438 | + | .iter() |
|
| 6439 | + | .map(|p| view.entries[*p].node) |
|
| 6440 | + | .collect(); |
|
| 6441 | + | repo.load_graphs_of(&mut st, &nodes).unwrap(); |
|
| 6442 | + | let before: Vec<Vec<u8>> = nodes.iter().map(|n| st.render_file(n).0).collect(); |
|
| 6443 | + | assert_eq!(st.built_count(), 2); |
|
| 6444 | + | let shed = st.shed_graphs(|b| repo.store_blob(b)).unwrap(); |
|
| 6445 | + | assert_eq!((shed, st.built_count()), (2, 0)); |
|
| 6446 | + | // A later change applies on top of the shed history. |
|
| 6447 | + | write(root, "a.txt", b"1\ntwo\n3\n4\n5\n"); |
|
| 6448 | + | let h3 = repo.commit("third", &[]).unwrap(); |
|
| 6449 | + | let (skeleton, texts) = repo.store().get_change_lazy(&h3).unwrap(); |
|
| 6450 | + | st.add_known(h3, skeleton, texts); |
|
| 6451 | + | repo.load_graphs_of(&mut st, &nodes).unwrap(); |
|
| 6452 | + | let after: Vec<Vec<u8>> = nodes.iter().map(|n| st.render_file(n).0).collect(); |
|
| 6453 | + | assert_eq!(after[1], before[1]); |
|
| 6454 | + | assert_eq!(after[0], b"1\ntwo\n3\n4\n5\n"); |
|
| 6455 | + | } |
modified crates/oxy-core/src/state.rs +35 −0
| … | 920 unchanged lines |
||
| 921 | 921 | self.changes.len() |
|
| 922 | 922 | } |
|
| 923 | 923 | ||
| 924 | + | /// Turn this history-only state's built graphs back into deferred |
|
| 925 | + | /// histories, each starting from its graph encoded as a checkpoint |
|
| 926 | + | /// graph and stored by `store` (it returns the blob's reference): what |
|
| 927 | + | /// they held stays, and memory goes back to what a history-only state |
|
| 928 | + | /// costs. A graph built later again decodes that blob and applies only |
|
| 929 | + | /// the edits that came after. Graph keys change (the base is now a |
|
| 930 | + | /// blob), which only means a cached rendering is not reused. |
|
| 931 | + | pub fn shed_graphs<E>( |
|
| 932 | + | &mut self, |
|
| 933 | + | mut store: impl FnMut(&[u8]) -> Result<ObjectRef, E>, |
|
| 934 | + | ) -> Result<usize, E> { |
|
| 935 | + | use rayon::prelude::*; |
|
| 936 | + | let Some(deferred) = self.deferred.as_mut() else { |
|
| 937 | + | return Ok(0); |
|
| 938 | + | }; |
|
| 939 | + | let graphs: Vec<(NodeId, Arc<FileGraph>)> = self.files.drain().collect(); |
|
| 940 | + | let encoded: Vec<(NodeId, Vec<u8>)> = graphs |
|
| 941 | + | .par_iter() |
|
| 942 | + | .map(|(n, g)| (*n, cbor::encode(&g.to_value()))) |
|
| 943 | + | .collect(); |
|
| 944 | + | drop(graphs); |
|
| 945 | + | let shed = encoded.len(); |
|
| 946 | + | for (node, bytes) in encoded { |
|
| 947 | + | let blob = store(&bytes)?; |
|
| 948 | + | deferred.insert( |
|
| 949 | + | node, |
|
| 950 | + | Arc::new(Deferred::from_body(DeferredBody { |
|
| 951 | + | base: Some(DeferredBase::Blob(blob)), |
|
| 952 | + | ..DeferredBody::default() |
|
| 953 | + | })), |
|
| 954 | + | ); |
|
| 955 | + | } |
|
| 956 | + | Ok(shed) |
|
| 957 | + | } |
|
| 958 | + | ||
| 924 | 959 | /// How many file graphs this state has built. |
|
| 925 | 960 | pub fn built_count(&self) -> usize { |
|
| 926 | 961 | self.files.len() |
|
| … | 1554 unchanged lines |