Edited files are diffed in parallel (refs #41) 360aa74a6277
The change builder diffs the edited text files against their base renderings on every core before it updates them one by one, taking the diffs it made. A 1 000-file commit on the Linux tree builds its change in 72 ms (from 243); the commit takes 0.79-0.86 s (from 0.99, Git 1.1-1.2 s). Graph loading reports how many graphs, changes and blobs it read.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 02:24 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
360aa74a6277f6f8fbf738776cf8e02e7dc741ef5576924f04b4fe861509aa0d- Depends on
- 2306a4d24779 57d8dc05c9d4 75167672ebcd 982864068a59 a87c7b9d563a c3069537d7c5 cdb5beba7f05 fd280228bdc2 ff0af1ee299d
- Depended on by
- b35df36b7dc5
- 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.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/snapshot.rs +68 −6
| … | 196 unchanged lines |
||
| 197 | 197 | .map(|(old, new)| (old.as_str(), new.as_str())) |
|
| 198 | 198 | .collect(); |
|
| 199 | 199 | laps.lap("build: renames"); |
|
| 200 | + | b.diff_in_parallel(fs_entries); |
|
| 201 | + | laps.lap("build: diffs"); |
|
| 200 | 202 | for (path, fs_entry) in fs_entries { |
|
| 201 | 203 | match base_snapshot.files.get(path) { |
|
| 202 | 204 | None => match renamed_from.get(path.as_str()) { |
|
| … | 1448 unchanged lines |
||
| 1651 | 1653 | /// For a Git import, the base's dead directories by where they were |
|
| 1652 | 1654 | /// (see `ensure_dir`), worked out when first needed. |
|
| 1653 | 1655 | dead_dirs: Option<HashMap<(NodeId, String), (NodeId, crate::tree::OpKey)>>, |
|
| 1656 | + | /// Text edits of edited files, worked out in parallel beforehand |
|
| 1657 | + | /// (`diff_in_parallel`); a file not here is diffed when it is updated. |
|
| 1658 | + | diffs: HashMap<String, Vec<Edit>>, |
|
| 1654 | 1659 | } |
|
| 1655 | 1660 | ||
| 1656 | 1661 | impl<'a> ChangeBuilder<'a> { |
|
| … | 24 unchanged lines |
||
| 1681 | 1686 | resolve: HashSet::new(), |
|
| 1682 | 1687 | node_seed: None, |
|
| 1683 | 1688 | dead_dirs: None, |
|
| 1689 | + | diffs: HashMap::new(), |
|
| 1684 | 1690 | } |
|
| 1685 | 1691 | } |
|
| 1686 | 1692 | ||
| 1693 | + | /// Diff the edited text files among `entries` against their base |
|
| 1694 | + | /// renderings at once, on every core, for `update_path` to take: the |
|
| 1695 | + | /// same diff it would make, only not one file after another. |
|
| 1696 | + | fn diff_in_parallel(&mut self, entries: &BTreeMap<String, FsEntry>) { |
|
| 1697 | + | use rayon::prelude::*; |
|
| 1698 | + | let files = &self.base_snapshot.files; |
|
| 1699 | + | let edited: Vec<(&String, &FsEntry, &crate::state::FileState)> = entries |
|
| 1700 | + | .iter() |
|
| 1701 | + | .filter_map(|(p, e)| { |
|
| 1702 | + | let x = files.get(p)?; |
|
| 1703 | + | (x.kind == e.kind && !e.unchanged && !self.opaque_for(p, e)).then_some((p, e, x)) |
|
| 1704 | + | }) |
|
| 1705 | + | .collect(); |
|
| 1706 | + | // Below this, starting the threads costs more than it saves. |
|
| 1707 | + | if edited.len() < 16 { |
|
| 1708 | + | return; |
|
| 1709 | + | } |
|
| 1710 | + | let this = &*self; |
|
| 1711 | + | let attrs = &this.attrs; |
|
| 1712 | + | let diffs: Vec<(String, Vec<Edit>)> = edited |
|
| 1713 | + | .par_iter() |
|
| 1714 | + | .filter_map(|(p, e, x)| { |
|
| 1715 | + | let FileContent::Text { |
|
| 1716 | + | bytes, |
|
| 1717 | + | materialized, |
|
| 1718 | + | } = &x.content |
|
| 1719 | + | else { |
|
| 1720 | + | return None; |
|
| 1721 | + | }; |
|
| 1722 | + | if materialized.is_conflicted() { |
|
| 1723 | + | return None; |
|
| 1724 | + | } |
|
| 1725 | + | let disk_text = if attrs.lookup(p).eol.is_some() { |
|
| 1726 | + | std::sync::Arc::new(crate::attributes::normalize_eol(&e.content)) |
|
| 1727 | + | } else { |
|
| 1728 | + | e.content.clone() |
|
| 1729 | + | }; |
|
| 1730 | + | if *bytes == *disk_text { |
|
| 1731 | + | return None; |
|
| 1732 | + | } |
|
| 1733 | + | let keys = materialized.live_atoms(); |
|
| 1734 | + | let lines = crate::diff::split_lines(bytes); |
|
| 1735 | + | (keys.len() == lines.len()).then(|| { |
|
| 1736 | + | let edits = crate::diff::text_edits_of(x.node, &keys, &lines, &disk_text); |
|
| 1737 | + | ((*p).clone(), edits) |
|
| 1738 | + | }) |
|
| 1739 | + | }) |
|
| 1740 | + | .collect(); |
|
| 1741 | + | self.diffs = diffs.into_iter().collect(); |
|
| 1742 | + | } |
|
| 1743 | + | ||
| 1687 | 1744 | fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self { |
|
| 1688 | 1745 | self.attrs = attrs; |
|
| 1689 | 1746 | self |
|
| … | 421 unchanged lines |
||
| 2111 | 2168 | let mut laps = crate::timing::Laps::start(); |
|
| 2112 | 2169 | // The base's rendering holds its live atoms in order, |
|
| 2113 | 2170 | // one per line (it is not conflicted here). |
|
| 2114 | - | let keys = materialized.live_atoms(); |
|
| 2115 | - | let lines = crate::diff::split_lines(bytes); |
|
| 2116 | - | let edits = if keys.len() == lines.len() { |
|
| 2117 | - | crate::diff::text_edits_of(node, &keys, &lines, &disk_text) |
|
| 2118 | - | } else { |
|
| 2119 | - | text_edits(node, &self.base.live_lines(&node), &disk_text) |
|
| 2171 | + | let edits = match self.diffs.remove(path) { |
|
| 2172 | + | Some(edits) => edits, |
|
| 2173 | + | None => { |
|
| 2174 | + | let keys = materialized.live_atoms(); |
|
| 2175 | + | let lines = crate::diff::split_lines(bytes); |
|
| 2176 | + | if keys.len() == lines.len() { |
|
| 2177 | + | crate::diff::text_edits_of(node, &keys, &lines, &disk_text) |
|
| 2178 | + | } else { |
|
| 2179 | + | text_edits(node, &self.base.live_lines(&node), &disk_text) |
|
| 2180 | + | } |
|
| 2181 | + | } |
|
| 2120 | 2182 | }; |
|
| 2121 | 2183 | laps.lap("build: text diff"); |
|
| 2122 | 2184 | if !edits.is_empty() { |
|
| … | 473 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +45 −0
| … | 6452 unchanged lines |
||
| 6453 | 6453 | assert_eq!(after[1], before[1]); |
|
| 6454 | 6454 | assert_eq!(after[0], b"1\ntwo\n3\n4\n5\n"); |
|
| 6455 | 6455 | } |
|
| 6456 | + | ||
| 6457 | + | #[test] |
|
| 6458 | + | fn many_edited_files_diff_in_parallel() { |
|
| 6459 | + | // Enough edited files that the builder diffs them in parallel; the |
|
| 6460 | + | // commit must hold exactly what is on disk, and a second snapshot |
|
| 6461 | + | // must find nothing left to commit. |
|
| 6462 | + | hermetic(); |
|
| 6463 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6464 | + | let root = dir.path(); |
|
| 6465 | + | let repo = Repo::init(root).unwrap(); |
|
| 6466 | + | let text = |i: usize, round: usize| { |
|
| 6467 | + | let mut s = String::new(); |
|
| 6468 | + | for line in 0..50 { |
|
| 6469 | + | if round > 0 && line % 7 == i % 7 { |
|
| 6470 | + | s.push_str(&format!("edited {round} {i} {line}\n")); |
|
| 6471 | + | } else { |
|
| 6472 | + | s.push_str(&format!("file {i} line {line}\n")); |
|
| 6473 | + | } |
|
| 6474 | + | } |
|
| 6475 | + | if round > 0 && i.is_multiple_of(3) { |
|
| 6476 | + | s.push_str("appended\n"); |
|
| 6477 | + | } |
|
| 6478 | + | s |
|
| 6479 | + | }; |
|
| 6480 | + | for i in 0..40 { |
|
| 6481 | + | write(root, &format!("d/f{i}.txt"), text(i, 0).as_bytes()); |
|
| 6482 | + | } |
|
| 6483 | + | repo.commit("first", &[]).unwrap(); |
|
| 6484 | + | for i in 0..40 { |
|
| 6485 | + | write(root, &format!("d/f{i}.txt"), text(i, 1).as_bytes()); |
|
| 6486 | + | } |
|
| 6487 | + | let h = repo.commit("edit every file", &[]).unwrap(); |
|
| 6488 | + | let snap = repo.load_state(&[h]).unwrap().materialize(); |
|
| 6489 | + | for i in 0..40 { |
|
| 6490 | + | let want = text(i, 1); |
|
| 6491 | + | assert!( |
|
| 6492 | + | matches!(&snap.files[&format!("d/f{i}.txt")].content, FileContent::Text { bytes, .. } if bytes == want.as_bytes()), |
|
| 6493 | + | "d/f{i}.txt as committed" |
|
| 6494 | + | ); |
|
| 6495 | + | } |
|
| 6496 | + | assert!( |
|
| 6497 | + | repo.commit("nothing", &[]).is_err(), |
|
| 6498 | + | "nothing left to commit" |
|
| 6499 | + | ); |
|
| 6500 | + | } |
modified crates/oxy-core/src/state.rs +10 −0
| … | 673 unchanged lines |
||
| 674 | 674 | } |
|
| 675 | 675 | } |
|
| 676 | 676 | } |
|
| 677 | + | let n_changes = changes.len(); |
|
| 678 | + | drop(reading); |
|
| 679 | + | let reading = crate::timing::phase("graphs: of which blobs"); |
|
| 677 | 680 | let mut blobs = if refs.is_empty() { |
|
| 678 | 681 | Vec::new() |
|
| 679 | 682 | } else { |
|
| 680 | 683 | load(&refs)? |
|
| 681 | 684 | }; |
|
| 685 | + | if crate::timing::enabled() { |
|
| 686 | + | eprintln!( |
|
| 687 | + | "timing graphs: {} graphs, {n_changes} changes, {} blobs", |
|
| 688 | + | taken.len(), |
|
| 689 | + | refs.len() |
|
| 690 | + | ); |
|
| 691 | + | } |
|
| 682 | 692 | if blobs.len() != refs.len() { |
|
| 683 | 693 | return Err(ObjectError::Schema("graph blobs: wrong count".into())); |
|
| 684 | 694 | } |
|
| … | 1831 unchanged lines |