Encode a large change once, and store it on every core 192fad1e46a2
The first commit of the Linux tree took 61 s. Its one huge change was encoded, and its 1.8 GB of text chunked and hashed, three or four times on one thread: hashing the working-copy change, storing it, storing the commit, adding it to the state. The commit also copied every line of the working copy, and the store compressed each chunk on one thread. A snapshot now encodes the working copy once and hashes and stores that encoding; a whole commit moves the working copy's operations instead of copying them and joins the state under the hash it was stored by (State::add_hashed, which State::add also uses instead of hashing twice). Text blobs are made on every core, 256 inserts at a time, and Store::put_many compresses a large batch's objects on every core before appending them in order. The first commit of the Linux tip takes 40.7 s (Git: 34 to 38 s). refs #35
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 23:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
192fad1e46a2c28cac7974bb9f9e37ab7963ef488d583867a0e969a77bd756c2- Depends on
- 07eb99be126e 1d360c91a289 6caacfff1d4f 6d3eefbce2f0 6f8aeb996558 75167672ebcd 8ab265dc4a51 8b276e0106d1 8f10a07a1dbe 957fee30826e 982864068a59 ff0af1ee299d
- Depended on by
- ef7d4ea6f33b dd21fd82687d 648189388f1a 5b4c51307b14 a17fc622ca43 f9466021286a 0f93f49ce059 598009a75f6c 01a47cd7f0c8 17f94b13ae54
- 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
- 33 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/objects.rs +67 −32
| … | 457 unchanged lines |
||
| 458 | 458 | if version >= 2 && cbor::byte_strings_len(lines) >= TEXT_BLOB_MIN) |
|
| 459 | 459 | } |
|
| 460 | 460 | ||
| 461 | - | fn to_value(&self, version: u64, texts: &mut Option<&mut Vec<StoredText>>) -> Value { |
|
| 461 | + | /// The edit's value; `stored` is the text blob of an insert stored as |
|
| 462 | + | /// one (see [`Change::value_with`], which works them out). |
|
| 463 | + | fn to_value(&self, stored: Option<ObjectRef>) -> Value { |
|
| 462 | 464 | let nid = |n: &NodeId| Value::Bytes(n.0.to_vec()); |
|
| 463 | 465 | let opt = |o: &Option<ObjectRef>| match o { |
|
| 464 | 466 | Some(r) => Value::Bytes(r.0.to_vec()), |
|
| 465 | 467 | None => Value::Null, |
|
| 466 | 468 | }; |
|
| 467 | - | match self { |
|
| 468 | - | Edit::Insert { |
|
| 469 | - | node, |
|
| 470 | - | after, |
|
| 471 | - | before, |
|
| 472 | - | lines, |
|
| 473 | - | } if self.stored_text(version) => { |
|
| 474 | - | let content = cbor::encode_byte_strings(lines); |
|
| 475 | - | let blob = Blob::of(&content); |
|
| 476 | - | let r = blob.hash(); |
|
| 477 | - | if let Some(t) = texts { |
|
| 478 | - | t.push(StoredText { blob, content }); |
|
| 479 | - | } |
|
| 480 | - | Value::map([ |
|
| 481 | - | (0, Value::Uint(0)), |
|
| 482 | - | (1, nid(node)), |
|
| 483 | - | (2, after.to_value()), |
|
| 484 | - | (3, before.to_value()), |
|
| 485 | - | (5, Value::Bytes(r.0.to_vec())), |
|
| 486 | - | (6, Value::Uint(lines.len() as u64)), |
|
| 487 | - | ]) |
|
| 488 | - | } |
|
| 489 | - | Edit::Insert { |
|
| 490 | - | node, |
|
| 491 | - | after, |
|
| 492 | - | before, |
|
| 493 | - | lines, |
|
| 494 | - | } => Value::map([ |
|
| 469 | + | match (self, stored) { |
|
| 470 | + | ( |
|
| 471 | + | Edit::Insert { |
|
| 472 | + | node, |
|
| 473 | + | after, |
|
| 474 | + | before, |
|
| 475 | + | lines, |
|
| 476 | + | }, |
|
| 477 | + | Some(r), |
|
| 478 | + | ) => Value::map([ |
|
| 495 | 479 | (0, Value::Uint(0)), |
|
| 496 | 480 | (1, nid(node)), |
|
| 497 | 481 | (2, after.to_value()), |
|
| 498 | 482 | (3, before.to_value()), |
|
| 483 | + | (5, Value::Bytes(r.0.to_vec())), |
|
| 484 | + | (6, Value::Uint(lines.len() as u64)), |
|
| 485 | + | ]), |
|
| 486 | + | ( |
|
| 487 | + | Edit::Insert { |
|
| 488 | + | node, |
|
| 489 | + | after, |
|
| 490 | + | before, |
|
| 491 | + | lines, |
|
| 492 | + | }, |
|
| 493 | + | None, |
|
| 494 | + | ) => Value::map([ |
|
| 495 | + | (0, Value::Uint(0)), |
|
| 496 | + | (1, nid(node)), |
|
| 497 | + | (2, after.to_value()), |
|
| 498 | + | (3, before.to_value()), |
|
| 499 | 499 | ( |
|
| 500 | 500 | 4, |
|
| 501 | 501 | Value::Array(lines.iter().map(|l| Value::Bytes(l.clone())).collect()), |
|
| 502 | 502 | ), |
|
| 503 | 503 | ]), |
|
| 504 | - | Edit::Delete { node, atoms } => Value::map([ |
|
| 504 | + | (Edit::Delete { node, atoms }, _) => Value::map([ |
|
| 505 | 505 | (0, Value::Uint(1)), |
|
| 506 | 506 | (1, nid(node)), |
|
| 507 | 507 | ( |
|
| … | 1 unchanged lines |
||
| 509 | 509 | Value::Array(atoms.iter().map(|r| r.to_value()).collect()), |
|
| 510 | 510 | ), |
|
| 511 | 511 | ]), |
|
| 512 | - | Edit::Replace { node, old, new } => Value::map([ |
|
| 512 | + | (Edit::Replace { node, old, new }, _) => Value::map([ |
|
| 513 | 513 | (0, Value::Uint(2)), |
|
| 514 | 514 | (1, nid(node)), |
|
| 515 | 515 | (2, opt(old)), |
|
| … | 124 unchanged lines |
||
| 640 | 640 | self.value_with(&mut None) |
|
| 641 | 641 | } |
|
| 642 | 642 | ||
| 643 | + | /// The text blob of each edit stored as one, `None` for the others; |
|
| 644 | + | /// the stored texts go to `texts`, in order, when it is given. Worked |
|
| 645 | + | /// out on every core, a bounded batch at a time: chunking and hashing |
|
| 646 | + | /// a whole tree's text is most of encoding a change that adds it. |
|
| 647 | + | fn stored_texts(&self, texts: &mut Option<&mut Vec<StoredText>>) -> Vec<Option<ObjectRef>> { |
|
| 648 | + | use rayon::prelude::*; |
|
| 649 | + | let mut refs = vec![None; self.edits.len()]; |
|
| 650 | + | let stored: Vec<usize> = (0..self.edits.len()) |
|
| 651 | + | .filter(|i| self.edits[*i].stored_text(self.version)) |
|
| 652 | + | .collect(); |
|
| 653 | + | for batch in stored.chunks(256) { |
|
| 654 | + | let made: Vec<StoredText> = batch |
|
| 655 | + | .par_iter() |
|
| 656 | + | .map(|i| { |
|
| 657 | + | let Edit::Insert { lines, .. } = &self.edits[*i] else { |
|
| 658 | + | unreachable!("only inserts are stored as text"); |
|
| 659 | + | }; |
|
| 660 | + | let content = cbor::encode_byte_strings(lines); |
|
| 661 | + | StoredText { |
|
| 662 | + | blob: Blob::of(&content), |
|
| 663 | + | content, |
|
| 664 | + | } |
|
| 665 | + | }) |
|
| 666 | + | .collect(); |
|
| 667 | + | for (i, t) in batch.iter().zip(made) { |
|
| 668 | + | refs[*i] = Some(t.blob.hash()); |
|
| 669 | + | if let Some(sink) = texts { |
|
| 670 | + | sink.push(t); |
|
| 671 | + | } |
|
| 672 | + | } |
|
| 673 | + | } |
|
| 674 | + | refs |
|
| 675 | + | } |
|
| 676 | + | ||
| 643 | 677 | fn value_with(&self, texts: &mut Option<&mut Vec<StoredText>>) -> Value { |
|
| 644 | 678 | Value::map([ |
|
| 645 | 679 | (0, Value::Uint(self.version)), |
|
| … | 19 unchanged lines |
||
| 665 | 699 | Value::Array( |
|
| 666 | 700 | self.edits |
|
| 667 | 701 | .iter() |
|
| 668 | - | .map(|e| e.to_value(self.version, texts)) |
|
| 702 | + | .zip(self.stored_texts(texts)) |
|
| 703 | + | .map(|(e, stored)| e.to_value(stored)) |
|
| 669 | 704 | .collect(), |
|
| 670 | 705 | ), |
|
| 671 | 706 | ), |
|
| … | 558 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +16 −6
| … | 395 unchanged lines |
||
| 396 | 396 | let statuses = built.statuses; |
|
| 397 | 397 | // Empty snapshot: hash the candidate before storing; only record an op on change. |
|
| 398 | 398 | let record = crate::timing::phase("snapshot: hash and record"); |
|
| 399 | - | let new_hash = wc.hash(); |
|
| 399 | + | // Encoded once: the hash and, when it changed, what is stored. |
|
| 400 | + | let (encoded, texts) = wc.encode_parts(); |
|
| 401 | + | let new_hash = crate::hash::hash_object(crate::hash::ObjectType::Change, &encoded); |
|
| 400 | 402 | let changed = new_hash != old_hash; |
|
| 401 | 403 | if changed { |
|
| 402 | - | self.store.put_change(&wc)?; |
|
| 404 | + | self.store.put_change_encoded(&encoded, &texts)?; |
|
| 403 | 405 | let mut view = op.view.clone(); |
|
| 404 | 406 | view.workspaces.insert(self.workspace, new_hash); |
|
| 405 | 407 | self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?; |
|
| … | 167 unchanged lines |
||
| 573 | 575 | selection: &Selection, |
|
| 574 | 576 | ) -> Result<ChangeHash> { |
|
| 575 | 577 | let _wc = self.lock_working_copy()?; |
|
| 576 | - | let snap = self.snapshot_status("oxy commit")?; |
|
| 578 | + | let mut snap = self.snapshot_status("oxy commit")?; |
|
| 577 | 579 | if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() { |
|
| 578 | 580 | return fail("nothing to commit: the working copy matches its base"); |
|
| 579 | 581 | } |
|
| … | 7 unchanged lines |
||
| 587 | 589 | if message.trim().is_empty() { |
|
| 588 | 590 | return fail("a commit message is required"); |
|
| 589 | 591 | } |
|
| 592 | + | // What the whole working copy references, before a whole commit |
|
| 593 | + | // takes its operations over (they are moved, not copied: for a |
|
| 594 | + | // large tree they are most of the memory). |
|
| 595 | + | let wc_refs = referenced_changes(&snap.wc, &snap.base); |
|
| 590 | 596 | let (tree_ops, edits, partial) = match selection { |
|
| 591 | - | Selection::All => (snap.wc.tree_ops.clone(), snap.wc.edits.clone(), false), |
|
| 597 | + | Selection::All => ( |
|
| 598 | + | std::mem::take(&mut snap.wc.tree_ops), |
|
| 599 | + | std::mem::take(&mut snap.wc.edits), |
|
| 600 | + | false, |
|
| 601 | + | ), |
|
| 592 | 602 | Selection::Paths(paths) => { |
|
| 593 | 603 | let (o, e) = select_paths(&snap.wc, &snap.base_snapshot, paths); |
|
| 594 | 604 | let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len(); |
|
| … | 25 unchanged lines |
||
| 620 | 630 | ), |
|
| 621 | 631 | )?; |
|
| 622 | 632 | let (op_ref, op) = self.head_op()?; |
|
| 623 | - | let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base); |
|
| 633 | + | let mut deps: BTreeSet<ChangeHash> = wc_refs; |
|
| 624 | 634 | // semantic-deps hook: may add explicit dependencies (spec §8.1). |
|
| 625 | 635 | if self.config.semantic_deps { |
|
| 626 | 636 | let touched: Vec<String> = snap.statuses.iter().map(|s| s.path.clone()).collect(); |
|
| … | 83 unchanged lines |
||
| 710 | 720 | } else { |
|
| 711 | 721 | self.load_state(&all)? |
|
| 712 | 722 | }; |
|
| 713 | - | st.add(change)?; |
|
| 723 | + | st.add_hashed(hash, change)?; |
|
| 714 | 724 | let mut with_new = old_heads.clone(); |
|
| 715 | 725 | with_new.push(hash); |
|
| 716 | 726 | let new_heads = st.maximal(&with_new); |
|
| … | 1631 unchanged lines |
modified crates/oxy-core/src/state.rs +8 −1
| … | 460 unchanged lines |
||
| 461 | 461 | /// Add a change whose dependencies are already present. |
|
| 462 | 462 | pub fn add(&mut self, change: Change) -> Result<ChangeHash, StateError> { |
|
| 463 | 463 | let h = change.hash(); |
|
| 464 | + | self.add_hashed(h, change) |
|
| 465 | + | } |
|
| 466 | + | ||
| 467 | + | /// [`State::add`] for a change whose hash `h` is already known (it was |
|
| 468 | + | /// just stored), so it is not encoded again. |
|
| 469 | + | pub fn add_hashed(&mut self, h: ChangeHash, change: Change) -> Result<ChangeHash, StateError> { |
|
| 464 | 470 | if self.changes.contains_key(&h) { |
|
| 465 | 471 | return Ok(h); |
|
| 466 | 472 | } |
|
| … | 2 unchanged lines |
||
| 469 | 475 | return Err(StateError::MissingDep(*d, h)); |
|
| 470 | 476 | } |
|
| 471 | 477 | } |
|
| 472 | - | Ok(self.add_unchecked(change)) |
|
| 478 | + | self.add_known(h, change, Vec::new()); |
|
| 479 | + | Ok(h) |
|
| 473 | 480 | } |
|
| 474 | 481 | ||
| 475 | 482 | /// Add a change in any order; call `validate` once all are in. |
|
| … | 1400 unchanged lines |
modified crates/oxy-core/src/store.rs +173 −34
| … | 236 unchanged lines |
||
| 237 | 237 | } |
|
| 238 | 238 | } |
|
| 239 | 239 | ||
| 240 | + | /// Append one stored object to a pending pack and index it. |
|
| 241 | + | fn write_pack_entry( |
|
| 242 | + | pack: &mut PendingPack, |
|
| 243 | + | r: &ObjectRef, |
|
| 244 | + | ty: ObjectType, |
|
| 245 | + | raw_len: usize, |
|
| 246 | + | flags: u8, |
|
| 247 | + | stored: &[u8], |
|
| 248 | + | report: &mut PackReport, |
|
| 249 | + | ) -> Result<PackEntry> { |
|
| 250 | + | let header_len = 1 + 1 + 4 + 4 + 32; |
|
| 251 | + | pack.writer.write_all(&[type_index(ty), flags])?; |
|
| 252 | + | pack.writer |
|
| 253 | + | .write_all(&(stored.len() as u32).to_le_bytes())?; |
|
| 254 | + | pack.writer.write_all(&(raw_len as u32).to_le_bytes())?; |
|
| 255 | + | pack.writer.write_all(&r.0)?; |
|
| 256 | + | pack.writer.write_all(stored)?; |
|
| 257 | + | let entry = PackEntry { |
|
| 258 | + | offset: pack.offset + header_len, |
|
| 259 | + | stored_len: stored.len() as u32, |
|
| 260 | + | raw_len: raw_len as u32, |
|
| 261 | + | ty, |
|
| 262 | + | flags, |
|
| 263 | + | }; |
|
| 264 | + | pack.offset += header_len + stored.len() as u64; |
|
| 265 | + | pack.index.push((*r, entry)); |
|
| 266 | + | report.objects += 1; |
|
| 267 | + | report.raw_bytes += raw_len as u64; |
|
| 268 | + | report.packed_bytes += stored.len() as u64; |
|
| 269 | + | Ok(entry) |
|
| 270 | + | } |
|
| 271 | + | ||
| 240 | 272 | impl Store { |
|
| 241 | 273 | pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> { |
|
| 242 | 274 | let dir = dir.into(); |
|
| … | 443 unchanged lines |
||
| 686 | 718 | (0, body.to_vec()) |
|
| 687 | 719 | } |
|
| 688 | 720 | }; |
|
| 689 | - | let header_len = 1 + 1 + 4 + 4 + 32; |
|
| 690 | - | pack.writer.write_all(&[type_index(ty), flags])?; |
|
| 691 | - | pack.writer |
|
| 692 | - | .write_all(&(stored.len() as u32).to_le_bytes())?; |
|
| 693 | - | pack.writer.write_all(&(body.len() as u32).to_le_bytes())?; |
|
| 694 | - | pack.writer.write_all(&r.0)?; |
|
| 695 | - | pack.writer.write_all(&stored)?; |
|
| 696 | - | let entry = PackEntry { |
|
| 697 | - | offset: pack.offset + header_len, |
|
| 698 | - | stored_len: stored.len() as u32, |
|
| 699 | - | raw_len: body.len() as u32, |
|
| 700 | - | ty, |
|
| 701 | - | flags, |
|
| 702 | - | }; |
|
| 703 | - | pack.offset += header_len + stored.len() as u64; |
|
| 704 | - | pack.index.push((*r, entry)); |
|
| 705 | - | report.objects += 1; |
|
| 706 | - | report.raw_bytes += body.len() as u64; |
|
| 707 | - | report.packed_bytes += stored.len() as u64; |
|
| 708 | - | Ok(entry) |
|
| 721 | + | write_pack_entry(pack, r, ty, body.len(), flags, &stored, report) |
|
| 709 | 722 | } |
|
| 710 | 723 | ||
| 711 | 724 | fn put_pending(&self, r: ObjectRef, ty: ObjectType, body: &[u8]) -> Result<()> { |
|
| … | 13 unchanged lines |
||
| 725 | 738 | .insert(r, PendingEntry::Buffered(pending.buffered.len() - 1)); |
|
| 726 | 739 | return Ok(()); |
|
| 727 | 740 | } |
|
| 741 | + | self.ensure_pack(pending)?; |
|
| 742 | + | let mut report = std::mem::take(&mut pending.report); |
|
| 743 | + | let pack = pending.pack.as_mut().unwrap(); |
|
| 744 | + | let e = self.pending_pack_write(pack, &r, ty, body, &mut report)?; |
|
| 745 | + | pending.report = report; |
|
| 746 | + | pending.lookup.insert(r, PendingEntry::Packed(e)); |
|
| 747 | + | Ok(()) |
|
| 748 | + | } |
|
| 749 | + | ||
| 750 | + | /// Open the pending batch's pack, moving the objects buffered so far |
|
| 751 | + | /// into it (a spill). |
|
| 752 | + | fn ensure_pack(&self, pending: &mut Pending) -> Result<()> { |
|
| 728 | 753 | if pending.pack.is_none() { |
|
| 729 | 754 | // Spill: open the pack and move the buffered objects into it. |
|
| 730 | 755 | let mut nonce = [0u8; 8]; |
|
| … | 19 unchanged lines |
||
| 750 | 775 | } |
|
| 751 | 776 | pending.report = report; |
|
| 752 | 777 | pending.pack = Some(pack); |
|
| 778 | + | } |
|
| 779 | + | Ok(()) |
|
| 780 | + | } |
|
| 781 | + | ||
| 782 | + | /// Store many objects at once. Inside a large write batch they are |
|
| 783 | + | /// compressed on every core and appended in order; otherwise each is |
|
| 784 | + | /// stored as [`Store::put`] does. |
|
| 785 | + | pub fn put_many(&self, objects: &[(ObjectType, &[u8])]) -> Result<Vec<ObjectRef>> { |
|
| 786 | + | use rayon::prelude::*; |
|
| 787 | + | let total: usize = objects.iter().map(|(_, b)| b.len()).sum(); |
|
| 788 | + | if self.pending.borrow().is_none() || total < SPILL_BYTES { |
|
| 789 | + | return objects.iter().map(|(ty, b)| self.put(*ty, b)).collect(); |
|
| 790 | + | } |
|
| 791 | + | for (ty, b) in objects { |
|
| 792 | + | if b.len() > MAX_OBJECT_LEN as usize { |
|
| 793 | + | return Err(StoreError::TooLarge { |
|
| 794 | + | kind: ty.name(), |
|
| 795 | + | size: b.len(), |
|
| 796 | + | limit: MAX_OBJECT_LEN, |
|
| 797 | + | }); |
|
| 798 | + | } |
|
| 753 | 799 | } |
|
| 800 | + | let refs: Vec<ObjectRef> = objects |
|
| 801 | + | .par_iter() |
|
| 802 | + | .map(|(ty, b)| hash_object(*ty, b)) |
|
| 803 | + | .collect(); |
|
| 804 | + | // What is new: not stored already, not earlier in this call. |
|
| 805 | + | let mut seen: std::collections::HashSet<ObjectRef> = std::collections::HashSet::new(); |
|
| 806 | + | let mut todo: Vec<usize> = Vec::new(); |
|
| 807 | + | for (i, r) in refs.iter().enumerate() { |
|
| 808 | + | let known = self |
|
| 809 | + | .pending |
|
| 810 | + | .borrow() |
|
| 811 | + | .as_ref() |
|
| 812 | + | .is_some_and(|p| p.lookup.contains_key(r)); |
|
| 813 | + | if seen.insert(*r) |
|
| 814 | + | && !known |
|
| 815 | + | && !self.path_for(r).is_file() |
|
| 816 | + | && self.find_packed(r).is_none() |
|
| 817 | + | { |
|
| 818 | + | todo.push(i); |
|
| 819 | + | } |
|
| 820 | + | } |
|
| 821 | + | let raw: std::collections::HashSet<ObjectRef> = |
|
| 822 | + | match fs::read_dir(self.dir.join("nocompress")) { |
|
| 823 | + | Ok(rd) => rd |
|
| 824 | + | .flatten() |
|
| 825 | + | .filter_map(|e| ObjectRef::from_hex(&e.file_name().to_string_lossy())) |
|
| 826 | + | .collect(), |
|
| 827 | + | Err(_) => std::collections::HashSet::new(), |
|
| 828 | + | }; |
|
| 829 | + | let packed: Vec<(u8, Vec<u8>)> = todo |
|
| 830 | + | .par_iter() |
|
| 831 | + | .map(|i| { |
|
| 832 | + | let body = objects[*i].1; |
|
| 833 | + | if raw.contains(&refs[*i]) { |
|
| 834 | + | return (0, body.to_vec()); |
|
| 835 | + | } |
|
| 836 | + | match zstd::bulk::compress(body, ZSTD_LEVEL) { |
|
| 837 | + | Ok(c) if c.len() < body.len() => (FLAG_ZSTD, c), |
|
| 838 | + | _ => (0, body.to_vec()), |
|
| 839 | + | } |
|
| 840 | + | }) |
|
| 841 | + | .collect(); |
|
| 842 | + | let mut guard = self.pending.borrow_mut(); |
|
| 843 | + | let pending = guard.as_mut().expect("batch open"); |
|
| 844 | + | self.ensure_pack(pending)?; |
|
| 754 | 845 | let mut report = std::mem::take(&mut pending.report); |
|
| 755 | 846 | let pack = pending.pack.as_mut().unwrap(); |
|
| 756 | - | let e = self.pending_pack_write(pack, &r, ty, body, &mut report)?; |
|
| 847 | + | for (i, (flags, stored)) in todo.iter().zip(packed) { |
|
| 848 | + | let (ty, body) = objects[*i]; |
|
| 849 | + | let e = write_pack_entry(pack, &refs[*i], ty, body.len(), flags, &stored, &mut report)?; |
|
| 850 | + | pending.lookup.insert(refs[*i], PendingEntry::Packed(e)); |
|
| 851 | + | } |
|
| 757 | 852 | pending.report = report; |
|
| 758 | - | pending.lookup.insert(r, PendingEntry::Packed(e)); |
|
| 759 | - | Ok(()) |
|
| 853 | + | Ok(refs) |
|
| 760 | 854 | } |
|
| 761 | 855 | ||
| 762 | 856 | /// Store `body` as an object of type `ty`; idempotent. |
|
| … | 114 unchanged lines |
||
| 877 | 971 | /// inserts are stored as (spec §6.1). |
|
| 878 | 972 | pub fn put_change(&self, c: &Change) -> Result<ObjectRef> { |
|
| 879 | 973 | let (bytes, texts) = c.encode_parts(); |
|
| 880 | - | for t in texts { |
|
| 881 | - | self.put_text(&t)?; |
|
| 882 | - | } |
|
| 883 | - | self.put(ObjectType::Change, &bytes) |
|
| 974 | + | self.put_change_encoded(&bytes, &texts) |
|
| 884 | 975 | } |
|
| 885 | 976 | ||
| 886 | - | fn put_text(&self, t: &crate::objects::StoredText) -> Result<ObjectRef> { |
|
| 887 | - | let r = t.blob.hash(); |
|
| 888 | - | if self.has(&r) { |
|
| 889 | - | return Ok(r); |
|
| 977 | + | /// Store a change already encoded by [`Change::encode_parts`]. |
|
| 978 | + | pub fn put_change_encoded( |
|
| 979 | + | &self, |
|
| 980 | + | bytes: &[u8], |
|
| 981 | + | texts: &[crate::objects::StoredText], |
|
| 982 | + | ) -> Result<ObjectRef> { |
|
| 983 | + | // Every text's chunks at once (compressed on every core in a large |
|
| 984 | + | // batch), then the blobs and the change. |
|
| 985 | + | let mut chunks: Vec<(ObjectType, &[u8])> = Vec::new(); |
|
| 986 | + | for t in texts { |
|
| 987 | + | if !self.has(&t.blob.hash()) { |
|
| 988 | + | chunks.extend( |
|
| 989 | + | crate::chunk::chunks(&t.content) |
|
| 990 | + | .into_iter() |
|
| 991 | + | .map(|c| (ObjectType::Chunk, c)), |
|
| 992 | + | ); |
|
| 993 | + | } |
|
| 890 | 994 | } |
|
| 891 | - | for c in crate::chunk::chunks(&t.content) { |
|
| 892 | - | self.put_chunk(c)?; |
|
| 995 | + | self.put_many(&chunks)?; |
|
| 996 | + | for t in texts { |
|
| 997 | + | self.put(ObjectType::Blob, &t.blob.encode())?; |
|
| 893 | 998 | } |
|
| 894 | - | self.put(ObjectType::Blob, &t.blob.encode()) |
|
| 999 | + | self.put(ObjectType::Change, bytes) |
|
| 895 | 1000 | } |
|
| 896 | 1001 | ||
| 897 | 1002 | pub fn get_change(&self, r: &ObjectRef) -> Result<Change> { |
|
| … | 805 unchanged lines |
||
| 1703 | 1808 | matches!(&e, StoreError::Corrupt(h, why) if *h == r && why.contains("missing")), |
|
| 1704 | 1809 | "{e}" |
|
| 1705 | 1810 | ); |
|
| 1811 | + | } |
|
| 1812 | + | } |
|
| 1813 | + | ||
| 1814 | + | #[cfg(test)] |
|
| 1815 | + | mod put_many_tests { |
|
| 1816 | + | use super::*; |
|
| 1817 | + | ||
| 1818 | + | #[test] |
|
| 1819 | + | fn many_objects_in_a_batch_are_compressed_together_and_all_read_back() { |
|
| 1820 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1821 | + | let s = Store::open(dir.path()).unwrap(); |
|
| 1822 | + | let before = s.put(ObjectType::Chunk, b"already here").unwrap(); |
|
| 1823 | + | let bodies: Vec<Vec<u8>> = (0..200u32) |
|
| 1824 | + | .map(|i| format!("chunk {i} ").repeat(6000).into_bytes()) |
|
| 1825 | + | .collect(); |
|
| 1826 | + | let mut objects: Vec<(ObjectType, &[u8])> = bodies |
|
| 1827 | + | .iter() |
|
| 1828 | + | .map(|b| (ObjectType::Chunk, b.as_slice())) |
|
| 1829 | + | .collect(); |
|
| 1830 | + | objects.push((ObjectType::Chunk, bodies[0].as_slice())); |
|
| 1831 | + | objects.push((ObjectType::Chunk, b"already here")); |
|
| 1832 | + | let refs = { |
|
| 1833 | + | let batch = s.begin_batch().unwrap(); |
|
| 1834 | + | let refs = s.put_many(&objects).unwrap(); |
|
| 1835 | + | batch.finish().unwrap(); |
|
| 1836 | + | refs |
|
| 1837 | + | }; |
|
| 1838 | + | assert_eq!(refs.len(), objects.len()); |
|
| 1839 | + | assert_eq!(refs[200], refs[0], "a repeat is the same object"); |
|
| 1840 | + | assert_eq!(refs[201], before); |
|
| 1841 | + | let again = Store::open(dir.path()).unwrap(); |
|
| 1842 | + | for (r, b) in refs.iter().zip(&objects) { |
|
| 1843 | + | assert_eq!(again.get(r).unwrap().1, b.1); |
|
| 1844 | + | } |
|
| 1706 | 1845 | } |
|
| 1707 | 1846 | } |
modified docs/benchmarks.md +29 −0
| … | 353 unchanged lines |
||
| 354 | 354 | the walk 0.35 s, rendering the changed file 0.47 s, the diff 0.18 s, |
|
| 355 | 355 | and the manifest 0.14 s. (Git: 0.23 s.) |
|
| 356 | 356 | ||
| 357 | + | ### The first commit (2026-09-30, oxygen#35) |
|
| 358 | + | ||
| 359 | + | A profile of the first commit of the Linux tip (61 s, 10 GB RSS): read |
|
| 360 | + | the working copy 2.3 s (1.6 GB of content), diff against the empty base |
|
| 361 | + | 6.2 s (4.2 GB live, a Vec per line), hash and record 18.8 s, then the |
|
| 362 | + | commit itself about 33 s. The text of the one huge change was chunked |
|
| 363 | + | and hashed three or four times on one thread (hashing the working-copy |
|
| 364 | + | change, storing it, storing the commit, adding it to the state), the |
|
| 365 | + | commit copied every line of the working copy, and the chunks were |
|
| 366 | + | compressed on one thread. |
|
| 367 | + | ||
| 368 | + | Now: |
|
| 369 | + | - a snapshot encodes the working copy once, and hashes and stores that |
|
| 370 | + | one encoding; |
|
| 371 | + | - a whole commit moves the working copy's lines rather than copying |
|
| 372 | + | them, and joins the state under the hash it was stored by; |
|
| 373 | + | - text blobs are made on every core, 256 inserts at a time; |
|
| 374 | + | - the chunks of a large change are compressed on every core |
|
| 375 | + | (`Store::put_many`). |
|
| 376 | + | ||
| 377 | + | | First commit, Linux tip | Time | Peak (counted) | RSS | |
|
| 378 | + | |---|---:|---:|---:| |
|
| 379 | + | | Before | 61 s | 7.1 GB | 10.1 GB | |
|
| 380 | + | | After | 40.7 s | 7.4 GB | 8.7 GB | |
|
| 381 | + | | Git | 33.7 to 37.9 s | | 42 MB | |
|
| 382 | + | ||
| 383 | + | The peak is now in storing the change, which holds every text's content |
|
| 384 | + | at once; the lines themselves (a Vec each) are the rest. |
|
| 385 | + |