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 | } |