OXYGENHUB

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 +