OXYGENHUB

gc: a trained zstd dictionary per pack (refs #41) a17fc622ca43

A full repack of 1 000 objects or more trains a 110 KB dictionary from a
sample of them (10 000 at most, 32 MB) and compresses objects up to
64 KB with it; the dictionary is kept beside the pack as pack-ID.dict,
entries using it are flagged, and a pack removed takes it along.

On the Linux tree the pack is 293.5 MB (from 301 MB; Git's is 295.5 MB)
and gc takes 33.7 s (from 29.4 s; Git 70 s). With the state index, the
whole .oxy is still larger than .git.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 23:42 UTC
Signature
✔ signed by Andy McCall
Hash
a17fc622ca439a6b809f2ce59d8fc719e39c83a2db1f181b8f1e9dfaf2bea01d
Depends on
192fad1e46a2 75167672ebcd 8f10a07a1dbe ba160f60219e d02e545dc265 da652d6392d8 ff0af1ee299d
Depended on by
767ac6cd0545 a67914bced23 648189388f1a 9ae4d15a0e4b
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
17 atoms, 0 tree operations, 1 file

Browse the tree at this change

modified crates/oxy-core/src/store.rs +164 −17
…
22 unchanged lines
23 23
const IDX_MAGIC: &[u8; 8] = b"OXYIDX01";
24 24
const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1;
25 25
const FLAG_ZSTD: u8 = 1;
26 +
/// Compressed with the pack's trained dictionary (`pack-ID.dict`), which a
27 +
/// full repack makes from a sample of the objects it packs: small files
28 +
/// share most of their vocabulary, which each one alone cannot use.
29 +
const FLAG_DICT: u8 = 2;
30 +
/// The size of a pack's dictionary, and the least a repack must pack for
31 +
/// one to pay.
32 +
const DICT_BYTES: usize = 110 << 10;
33 +
const DICT_MIN_OBJECTS: usize = 1000;
34 +
/// Objects up to this size are compressed with the dictionary.
35 +
const DICT_OBJECT_MAX: usize = 64 << 10;
26 36
const ZSTD_LEVEL: i32 = 3;
27 37
/// The level of a full repack (`oxy gc`): slower, once, for a smaller store.
28 38
pub const REPACK_ZSTD_LEVEL: i32 = 9;
…
14 unchanged lines
43 53
}
44 54
45 55
/// One pack's index, fully in memory and sorted by ObjectRef.
46 -
#[derive(Debug)]
47 56
struct PackIndex {
48 57
    path: PathBuf,
49 58
    refs: Vec<ObjectRef>,
50 59
    entries: Vec<PackEntry>,
60 +
    /// The pack's dictionary, prepared once, when it has one.
61 +
    dict: Option<std::sync::Arc<zstd::dict::DecoderDictionary<'static>>>,
51 62
}
52 63
64 +
impl std::fmt::Debug for PackIndex {
65 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 +
        f.debug_struct("PackIndex")
67 +
            .field("path", &self.path)
68 +
            .field("objects", &self.refs.len())
69 +
            .field("dict", &self.dict.is_some())
70 +
            .finish()
71 +
    }
72 +
}
73 +
53 74
impl PackIndex {
54 75
    fn load(idx_path: &Path, pack_path: &Path) -> Result<PackIndex> {
55 76
        let data = fs::read(idx_path)?;
…
44 unchanged lines
100 121
            }
101 122
            entries.push(entry);
102 123
        }
124 +
        let dict = match fs::read(pack_path.with_extension("dict")) {
125 +
            Ok(d) => Some(std::sync::Arc::new(zstd::dict::DecoderDictionary::copy(&d))),
126 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
127 +
            Err(e) => return Err(e.into()),
128 +
        };
103 129
        Ok(PackIndex {
104 130
            path: pack_path.to_path_buf(),
105 131
            refs,
106 132
            entries,
133 +
            dict,
107 134
        })
108 135
    }
109 136
…
6 unchanged lines
116 143
        f.seek(SeekFrom::Start(e.offset))?;
117 144
        let mut stored = vec![0u8; e.stored_len as usize];
118 145
        f.read_exact(&mut stored)?;
119 -
        let body = if e.flags & FLAG_ZSTD != 0 {
146 +
        let body = if e.flags & FLAG_DICT != 0 {
147 +
            let dict = self.dict.as_ref().ok_or_else(|| {
148 +
                StoreError::Corrupt(*r, "packed with a dictionary the pack lacks".into())
149 +
            })?;
150 +
            zstd::bulk::Decompressor::with_prepared_dictionary(dict)
151 +
                .and_then(|mut d| d.decompress(&stored, e.raw_len as usize))
152 +
                .map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
153 +
        } else if e.flags & FLAG_ZSTD != 0 {
120 154
            zstd::bulk::decompress(&stored, e.raw_len as usize)
121 155
                .map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
122 156
        } else {
…
410 unchanged lines
533 567
    ) -> Result<PackReport> {
534 568
        if refs.is_empty() {
535 569
            for p in old_packs {
536 -
                fs::remove_file(p)?;
537 -
                fs::remove_file(p.with_extension("idx"))?;
570 +
                remove_pack_files(p)?;
538 571
            }
539 572
            self.reload_packs()?;
540 573
            return Ok(PackReport::default());
…
7 unchanged lines
548 581
        let tmp_pack = self.dir.join(format!("tmp-{id}.pack"));
549 582
        let mut report = PackReport::default();
550 583
        let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(refs.len());
584 +
        // A full repack of enough objects trains a dictionary for them.
585 +
        let dict = if level == REPACK_ZSTD_LEVEL && refs.len() >= DICT_MIN_OBJECTS {
586 +
            let _t = crate::timing::phase("pack: train a dictionary");
587 +
            self.train_dictionary(&refs)
588 +
        } else {
589 +
            None
590 +
        };
591 +
        let encoder = dict
592 +
            .as_ref()
593 +
            .map(|d| zstd::dict::EncoderDictionary::copy(d, level));
551 594
        {
552 595
            let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?);
553 596
            f.write_all(PACK_MAGIC)?;
…
14 unchanged lines
568 611
                    use rayon::prelude::*;
569 612
                    compressed_batch = read
570 613
                        .into_par_iter()
571 -
                        .map(|(r, ty, body, raw)| {
572 -
                            let packed = if raw {
573 -
                                None
574 -
                            } else {
575 -
                                zstd::bulk::compress(&body, level)
576 -
                                    .ok()
577 -
                                    .filter(|c| c.len() < body.len())
578 -
                            };
579 -
                            (r, ty, body, packed)
580 -
                        })
614 +
                        .map_init(
615 +
                            || {
616 +
                                encoder.as_ref().and_then(|e| {
617 +
                                    zstd::bulk::Compressor::with_prepared_dictionary(e).ok()
618 +
                                })
619 +
                            },
620 +
                            |with_dict, (r, ty, body, raw)| {
621 +
                                let packed = if raw {
622 +
                                    None
623 +
                                } else {
624 +
                                    // Small objects with the dictionary;
625 +
                                    // a large one has context enough.
626 +
                                    let dicted = with_dict
627 +
                                        .as_mut()
628 +
                                        .filter(|_| body.len() <= DICT_OBJECT_MAX)
629 +
                                        .and_then(|c| c.compress(&body).ok())
630 +
                                        .map(|c| (FLAG_ZSTD | FLAG_DICT, c));
631 +
                                    dicted
632 +
                                        .or_else(|| {
633 +
                                            zstd::bulk::compress(&body, level)
634 +
                                                .ok()
635 +
                                                .map(|c| (FLAG_ZSTD, c))
636 +
                                        })
637 +
                                        .filter(|(_, c)| c.len() < body.len())
638 +
                                };
639 +
                                (r, ty, body, packed)
640 +
                            },
641 +
                        )
581 642
                        .collect::<Vec<_>>();
582 643
                    compressed_batch.reverse();
583 644
                }
…
2 unchanged lines
586 647
                };
587 648
                let r = &r;
588 649
                let (flags, stored) = match packed {
589 -
                    Some(c) => (FLAG_ZSTD, c),
650 +
                    Some(c) => c,
590 651
                    None => (0, body.clone()),
591 652
                };
592 653
                let header_len = 1 + 1 + 4 + 4 + 32;
…
19 unchanged lines
612 673
            }
613 674
            f.flush()?;
614 675
            f.get_ref().sync_all()?;
676 +
        }
677 +
        // The dictionary before the pack that needs it becomes visible.
678 +
        if let Some(d) = &dict {
679 +
            crate::durable::write_atomic(&self.dir.join(format!("pack-{id}.dict")), d)?;
615 680
        }
616 681
        self.finish_pack_files(&id, &tmp_pack, &index)?;
617 682
        for p in old_packs {
618 -
            fs::remove_file(p)?;
619 -
            fs::remove_file(p.with_extension("idx"))?;
683 +
            remove_pack_files(p)?;
620 684
        }
621 685
        self.reload_packs()?;
622 686
        for path in loose_paths {
…
5 unchanged lines
628 692
        Ok(report)
629 693
    }
630 694
695 +
    /// A dictionary for these objects, trained on a sample of them (at
696 +
    /// most 10 000, 32 MB); `None` when training fails.
697 +
    fn train_dictionary(&self, refs: &[ObjectRef]) -> Option<Vec<u8>> {
698 +
        let step = (refs.len() / 10_000).max(1);
699 +
        let mut samples: Vec<Vec<u8>> = Vec::new();
700 +
        let mut total = 0usize;
701 +
        for r in refs.iter().step_by(step) {
702 +
            if total > 32 << 20 {
703 +
                break;
704 +
            }
705 +
            let Ok((ty, body)) = self.get(r) else {
706 +
                continue;
707 +
            };
708 +
            if matches!(
709 +
                ty,
710 +
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Change
711 +
            ) && !body.is_empty()
712 +
                && body.len() < 1 << 20
713 +
            {
714 +
                total += body.len();
715 +
                samples.push(body);
716 +
            }
717 +
        }
718 +
        zstd::dict::from_samples(&samples, DICT_BYTES).ok()
719 +
    }
720 +
631 721
    pub fn dir(&self) -> &Path {
632 722
        &self.dir
633 723
    }
…
106 unchanged lines
740 830
                        path: pack.tmp_path.clone(),
741 831
                        refs: vec![],
742 832
                        entries: vec![],
833 +
                        dict: None,
743 834
                    }
744 835
                    .read(r, e),
745 836
                    Err(err) => Err(err.into()),
…
662 unchanged lines
1408 1499
            refs[6].1.as_bytes(),
1409 1500
            "other objects are unaffected"
1410 1501
        );
1502 +
    }
1503 +
1504 +
    #[test]
1505 +
    fn a_full_repack_trains_a_dictionary_and_reads_back() {
1506 +
        let dir = tempfile::tempdir().unwrap();
1507 +
        let s = Store::open(dir.path()).unwrap();
1508 +
        // Many small, alike files: what a dictionary is for.
1509 +
        let bodies: Vec<Vec<u8>> = (0..1500)
1510 +
            .map(|i| {
1511 +
                format!(
1512 +
                    "// SPDX-License-Identifier: GPL-2.0\n#include <linux/module.h>\n\
1513 +
                     static int probe_{i}(struct device *dev)\n{{\n\treturn {i};\n}}\n"
1514 +
                )
1515 +
                .into_bytes()
1516 +
            })
1517 +
            .collect();
1518 +
        let refs: Vec<ObjectRef> = bodies
1519 +
            .iter()
1520 +
            .map(|b| s.put(ObjectType::Chunk, b).unwrap())
1521 +
            .collect();
1522 +
        s.repack_all(REPACK_ZSTD_LEVEL).unwrap();
1523 +
        let dicts = fs::read_dir(dir.path())
1524 +
            .unwrap()
1525 +
            .flatten()
1526 +
            .filter(|e| e.path().extension().is_some_and(|x| x == "dict"))
1527 +
            .count();
1528 +
        assert_eq!(dicts, 1, "the pack has a dictionary");
1529 +
        let with_dict = refs
1530 +
            .iter()
1531 +
            .filter(|r| s.find_packed(r).unwrap().1.flags & FLAG_DICT != 0)
1532 +
            .count();
1533 +
        assert!(with_dict > 1000, "most use it: {with_dict}");
1534 +
        let again = Store::open(dir.path()).unwrap();
1535 +
        for (r, b) in refs.iter().zip(&bodies) {
1536 +
            assert_eq!(&again.get(r).unwrap().1, b);
1537 +
        }
1538 +
        // A repack after that replaces the pack and its dictionary.
1539 +
        again.repack_all(REPACK_ZSTD_LEVEL).unwrap();
1540 +
        let files: Vec<String> = fs::read_dir(dir.path())
1541 +
            .unwrap()
1542 +
            .flatten()
1543 +
            .map(|e| e.file_name().to_string_lossy().into_owned())
1544 +
            .filter(|n| n.starts_with("pack-"))
1545 +
            .collect();
1546 +
        assert_eq!(files.len(), 3, "one pack, index and dictionary: {files:?}");
1547 +
        assert_eq!(&again.get(&refs[7]).unwrap().1, &bodies[7]);
1411 1548
    }
1412 1549
1413 1550
    #[test]
…
472 unchanged lines
1886 2023
        for (r, b) in refs.iter().zip(&objects) {
1887 2024
            assert_eq!(again.get(r).unwrap().1, b.1);
1888 2025
        }
2026 +
    }
2027 +
}
2028 +
2029 +
/// Remove a pack with its index and dictionary.
2030 +
fn remove_pack_files(pack: &Path) -> io::Result<()> {
2031 +
    fs::remove_file(pack)?;
2032 +
    fs::remove_file(pack.with_extension("idx"))?;
2033 +
    match fs::remove_file(pack.with_extension("dict")) {
2034 +
        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
2035 +
        _ => Ok(()),
1889 2036
    }
1890 2037
}