Skip to main content

limnifs_write/
chunker.rs

1//! Content-defined chunking via `FastCDC` (Xia et al., 2016).
2//!
3//! Splits a byte stream into variable-size chunks whose boundaries
4//! are determined by the content itself (via a gear hash), so
5//! identical regions produce identical boundaries. This is the
6//! dedup primitive: two files that share a long middle section will
7//! produce overlapping `DropId`s for that section, even if their
8//! prefixes and suffixes differ.
9//!
10//! ## Algorithm
11//!
12//! 1. Skip the first `min_size` bytes of each chunk — no boundary
13//!    is emitted in this region, so chunks cannot be smaller than
14//!    `min_size`.
15//! 2. Apply a level-1 mask (small, harder to trigger) over the next
16//!    `avg_size - min_size` bytes. Boundary fires when the rolling
17//!    hash ANDs to zero with the mask.
18//! 3. If no boundary has fired by `avg_size`, switch to a level-2
19//!    mask (larger, easier to trigger) and continue until `max_size`.
20//! 4. At `max_size`, force a boundary regardless of the hash.
21//!
22//! The two-mask split pushes the actual average toward `avg_size`
23//! while bounding the spread.
24//!
25//! ## Determinism
26//!
27//! The gear table is generated deterministically (splitmix64 with a
28//! fixed seed). Anyone running this chunker on the same input gets
29//! the same chunk boundaries — independent of platform, build, or
30//! RNG quality. This is required for content-addressed dedup.
31
32mod parallel;
33
34pub use parallel::ParallelFastCDC;
35
36use std::io::Read;
37
38/// Default minimum chunk size (64 KiB).
39pub const DEFAULT_MIN_SIZE: usize = 64 * 1024;
40/// Default average chunk size (256 KiB).
41pub const DEFAULT_AVG_SIZE: usize = 256 * 1024;
42/// Default maximum chunk size (1 MiB).
43pub const DEFAULT_MAX_SIZE: usize = 1024 * 1024;
44
45/// Read buffer size when pulling from a `Read`.
46const READ_BUFFER_SIZE: usize = 64 * 1024;
47
48/// Behaviour every content-defined chunker implements.
49///
50/// Adding a new chunker (Gear+SIMD, leap-based parallel CDC, etc.)
51/// is one `impl Chunker for ...` — the writer pipeline never
52/// changes (OCP). Today only [`FastCDC`] implements this; the
53/// trait exists so future variants slot in behind the same shape
54/// and so `WriteConfig::chunking.name` can dispatch at build time.
55///
56/// `Send + Sync` so the chunker can be shared across rayon workers.
57pub trait Chunker: Send + Sync {
58    /// Split `data` into content-defined chunks. The concatenation
59    /// of the returned slices equals `data`.
60    fn chunk_slice<'a>(&self, data: &'a [u8]) -> Vec<&'a [u8]>;
61
62    /// Split a `Read` stream into content-defined chunks, returning
63    /// each chunk as an owned `Vec<u8>`. Constant memory bounded by
64    /// the chunker's max chunk size plus one read buffer.
65    ///
66    /// Takes `&mut dyn Read` (not a generic `R`) so the trait stays
67    /// dyn-compatible — the pipeline dispatches chunkers as
68    /// `&dyn Chunker`.
69    ///
70    /// # Errors
71    ///
72    /// Returns the underlying `std::io::Error` if `reader` fails.
73    fn chunk_reader(&self, reader: &mut dyn Read) -> std::io::Result<Vec<Vec<u8>>>;
74
75    /// Target average chunk size.
76    fn avg_chunk_size(&self) -> usize;
77}
78
79/// A content-defined chunker that splits a byte stream at boundaries
80/// determined by the content itself.
81///
82/// Create via [`FastCDC::new`] or use the [`Default`] implementation
83/// (which uses the spec's default sizes). Then call [`chunk_reader`]
84/// or [`chunk_slice`] to produce chunks.
85///
86/// [`chunk_reader`]: Self::chunk_reader
87/// [`chunk_slice`]: Self::chunk_slice
88#[derive(Clone, Debug)]
89pub struct FastCDC {
90    min_size: usize,
91    avg_size: usize,
92    max_size: usize,
93    mask1: u64,
94    mask2: u64,
95    gear: GearTable,
96}
97
98impl Default for FastCDC {
99    fn default() -> Self {
100        Self::new(DEFAULT_MIN_SIZE, DEFAULT_AVG_SIZE, DEFAULT_MAX_SIZE)
101            .expect("default sizes are valid")
102    }
103}
104
105impl FastCDC {
106    /// Construct a chunker with explicit size parameters.
107    ///
108    /// # Errors
109    ///
110    /// Returns `&str` if the sizes are inconsistent:
111    /// - `min_size == 0`
112    /// - `min_size >= avg_size`
113    /// - `avg_size >= max_size`
114    pub fn new(min_size: usize, avg_size: usize, max_size: usize) -> Result<Self, &'static str> {
115        if min_size == 0 {
116            return Err("min_size must be > 0");
117        }
118        if min_size >= avg_size {
119            return Err("min_size must be < avg_size");
120        }
121        if avg_size >= max_size {
122            return Err("avg_size must be < max_size");
123        }
124        let mask1 = mask_for(avg_size - min_size);
125        let mask2 = mask_for(max_size - avg_size);
126        Ok(Self {
127            min_size,
128            avg_size,
129            max_size,
130            mask1,
131            mask2,
132            gear: GearTable::default(),
133        })
134    }
135
136    /// The minimum chunk size this chunker will produce.
137    #[must_use]
138    pub const fn min_size(&self) -> usize {
139        self.min_size
140    }
141
142    /// The target average chunk size.
143    #[must_use]
144    pub const fn avg_size(&self) -> usize {
145        self.avg_size
146    }
147
148    /// The maximum chunk size this chunker will produce.
149    #[must_use]
150    pub const fn max_size(&self) -> usize {
151        self.max_size
152    }
153
154    /// Split `data` into content-defined chunks.
155    ///
156    /// Returns a `Vec` of byte slices borrowing from `data`. The
157    /// concatenation of the slices equals `data`. The last chunk may
158    /// be smaller than `min_size` (if the input is short or the final
159    /// chunk happens to be the tail after the last boundary).
160    #[must_use]
161    pub fn chunk_slice<'a>(&self, data: &'a [u8]) -> Vec<&'a [u8]> {
162        let mut chunks = Vec::new();
163        let mut start = 0;
164        while start < data.len() {
165            let end = self.find_boundary(data, start);
166            chunks.push(&data[start..end]);
167            start = end;
168        }
169        chunks
170    }
171
172    /// Split a `Read` stream into content-defined chunks, returning
173    /// each chunk as an owned `Vec<u8>`. Constant memory bounded by
174    /// `max_size` plus one read buffer.
175    ///
176    /// # Errors
177    ///
178    /// Returns the underlying `std::io::Error` if `reader` fails.
179    pub fn chunk_reader<R: Read>(&self, mut reader: R) -> std::io::Result<Vec<Vec<u8>>> {
180        let mut buffer: Vec<u8> = Vec::with_capacity(self.max_size + READ_BUFFER_SIZE);
181        let mut read_buf = vec![0u8; READ_BUFFER_SIZE];
182        let mut fp: u64 = 0;
183        let mut chunk_start: usize = 0;
184        let mut i: usize = 0;
185        let mut chunks: Vec<Vec<u8>> = Vec::new();
186
187        loop {
188            let n = reader.read(&mut read_buf)?;
189            if n == 0 {
190                break;
191            }
192            buffer.extend_from_slice(&read_buf[..n]);
193
194            while i < buffer.len() {
195                let pos_in_chunk = i - chunk_start;
196                if pos_in_chunk >= self.max_size {
197                    chunks.push(buffer[chunk_start..i].to_vec());
198                    chunk_start = i;
199                    fp = 0;
200                    continue;
201                }
202                if pos_in_chunk < self.min_size {
203                    i += 1;
204                    continue;
205                }
206                fp = (fp << 1).wrapping_add(self.gear.bytes[usize::from(buffer[i])]);
207                let mask = if pos_in_chunk < self.avg_size {
208                    self.mask1
209                } else {
210                    self.mask2
211                };
212                if fp & mask == 0 {
213                    i += 1;
214                    chunks.push(buffer[chunk_start..i].to_vec());
215                    chunk_start = i;
216                    fp = 0;
217                    continue;
218                }
219                i += 1;
220            }
221        }
222
223        if chunk_start < buffer.len() {
224            chunks.push(buffer[chunk_start..].to_vec());
225        }
226        Ok(chunks)
227    }
228
229    /// Find the next chunk boundary starting at `start`. Always
230    /// returns an index in `start + min_size ..= min(start + max_size, data.len())`.
231    fn find_boundary(&self, data: &[u8], start: usize) -> usize {
232        let data_len = data.len();
233        if data_len <= start {
234            return start;
235        }
236        let max_end = (start + self.max_size).min(data_len);
237        if max_end - start <= self.min_size {
238            return max_end;
239        }
240
241        let mut fp: u64 = 0;
242        let avg_end = (start + self.avg_size).min(max_end);
243        let gear = &self.gear.bytes;
244
245        // Phase 1: scan from min_size to avg_end with mask1.
246        // 4-byte unrolled inner loop: the four gear lookups can be
247        // hoisted by the optimiser into a vectorised load, and the
248        // four mask checks become a single vectorised compare. The
249        // shift-and-add per byte stays sequential (it is a true
250        // loop-carried dependency — see the proposal at
251        // docs/fastcdc-simd-proposal.md for why full SIMD requires
252        // leap-based CDC, not `wide::u64x2`).
253        let mut i = start + self.min_size;
254        let unroll_end1 = i + ((avg_end - i) / 4) * 4;
255        while i < unroll_end1 {
256            let b0 = usize::from(data[i]);
257            let b1 = usize::from(data[i + 1]);
258            let b2 = usize::from(data[i + 2]);
259            let b3 = usize::from(data[i + 3]);
260            let g0 = gear[b0];
261            let g1 = gear[b1];
262            let g2 = gear[b2];
263            let g3 = gear[b3];
264            fp = (fp << 1).wrapping_add(g0);
265            if fp & self.mask1 == 0 {
266                return i + 1;
267            }
268            fp = (fp << 1).wrapping_add(g1);
269            if fp & self.mask1 == 0 {
270                return i + 2;
271            }
272            fp = (fp << 1).wrapping_add(g2);
273            if fp & self.mask1 == 0 {
274                return i + 3;
275            }
276            fp = (fp << 1).wrapping_add(g3);
277            if fp & self.mask1 == 0 {
278                return i + 4;
279            }
280            i += 4;
281        }
282        while i < avg_end {
283            fp = (fp << 1).wrapping_add(gear[usize::from(data[i])]);
284            if fp & self.mask1 == 0 {
285                return i + 1;
286            }
287            i += 1;
288        }
289
290        // Phase 2: scan from avg_end to max_end with mask2.
291        let unroll_end2 = i + ((max_end - i) / 4) * 4;
292        while i < unroll_end2 {
293            let b0 = usize::from(data[i]);
294            let b1 = usize::from(data[i + 1]);
295            let b2 = usize::from(data[i + 2]);
296            let b3 = usize::from(data[i + 3]);
297            let g0 = gear[b0];
298            let g1 = gear[b1];
299            let g2 = gear[b2];
300            let g3 = gear[b3];
301            fp = (fp << 1).wrapping_add(g0);
302            if fp & self.mask2 == 0 {
303                return i + 1;
304            }
305            fp = (fp << 1).wrapping_add(g1);
306            if fp & self.mask2 == 0 {
307                return i + 2;
308            }
309            fp = (fp << 1).wrapping_add(g2);
310            if fp & self.mask2 == 0 {
311                return i + 3;
312            }
313            fp = (fp << 1).wrapping_add(g3);
314            if fp & self.mask2 == 0 {
315                return i + 4;
316            }
317            i += 4;
318        }
319        while i < max_end {
320            fp = (fp << 1).wrapping_add(gear[usize::from(data[i])]);
321            if fp & self.mask2 == 0 {
322                return i + 1;
323            }
324            i += 1;
325        }
326        max_end
327    }
328}
329
330impl Chunker for FastCDC {
331    fn chunk_slice<'a>(&self, data: &'a [u8]) -> Vec<&'a [u8]> {
332        FastCDC::chunk_slice(self, data)
333    }
334
335    fn chunk_reader(&self, reader: &mut dyn Read) -> std::io::Result<Vec<Vec<u8>>> {
336        FastCDC::chunk_reader(self, reader)
337    }
338
339    fn avg_chunk_size(&self) -> usize {
340        self.avg_size
341    }
342}
343
344/// Compute the mask for the `FastCDC` normalization step.
345///
346/// The mask has `bits` set to 1 starting from the LSB. `bits` is
347/// `log2(range)` rounded down, which empirically produces chunk-size
348/// distributions that match the target average.
349fn mask_for(range: usize) -> u64 {
350    if range == 0 {
351        return 0;
352    }
353    let bits = (64 - range.leading_zeros()).saturating_sub(1).max(1);
354    (1u64 << bits) - 1
355}
356
357/// The 256-entry gear-hash table. Each entry is a random u64
358/// generated deterministically via splitmix64 with a fixed seed.
359#[derive(Clone, Debug)]
360struct GearTable {
361    bytes: [u64; 256],
362}
363
364impl Default for GearTable {
365    fn default() -> Self {
366        let mut table = [0u64; 256];
367        let mut state: u64 = 0x0123_4567_89AB_CDEF; // Fixed seed
368        for entry in &mut table {
369            state = splitmix64(state);
370            *entry = state;
371        }
372        Self { bytes: table }
373    }
374}
375
376/// splitmix64 — a deterministic PRNG with good distribution.
377fn splitmix64(mut state: u64) -> u64 {
378    state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
379    let mut z = state;
380    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
381    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
382    z ^ (z >> 31)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::collections::HashSet;
389    use std::io::Cursor;
390
391    fn chunk_sizes(chunks: &[&[u8]]) -> Vec<usize> {
392        chunks.iter().map(|c| c.len()).collect()
393    }
394
395    /// Pseudo-random byte generator for tests — deterministic,
396    /// good distribution, no external dependency.
397    fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
398        let mut state = seed;
399        let mut out = Vec::with_capacity(count);
400        for _ in 0..count {
401            state = state
402                .wrapping_mul(6_364_136_223_846_793_005)
403                .wrapping_add(1_442_695_040_888_963_407);
404            out.push(u8::try_from(state >> 56).expect("fits u8"));
405        }
406        out
407    }
408
409    #[test]
410    fn short_input_produces_one_chunk() {
411        let chunker = FastCDC::new(64, 256, 1024).expect("valid sizes");
412        let data = vec![0xAA; 50];
413        let chunks = chunker.chunk_slice(&data);
414        assert_eq!(chunks.len(), 1);
415        assert_eq!(chunks[0].len(), 50);
416    }
417
418    #[test]
419    fn chunks_cover_input_exactly() {
420        let chunker = FastCDC::new(64, 256, 1024).expect("valid sizes");
421        let data: Vec<u8> = (0..5000u32)
422            .map(|i| u8::try_from(i & 0xFF).expect("fits"))
423            .collect();
424        let chunks = chunker.chunk_slice(&data);
425        let total: usize = chunks.iter().map(|c| c.len()).sum();
426        assert_eq!(total, data.len());
427    }
428
429    #[test]
430    fn chunks_respect_min_and_max() {
431        let chunker = FastCDC::new(64, 256, 1024).expect("valid sizes");
432        let data = pseudo_random_bytes(1, 10_000);
433        let chunks = chunker.chunk_slice(&data);
434        for (i, chunk) in chunks.iter().enumerate() {
435            if i + 1 == chunks.len() {
436                continue; // final chunk can be short
437            }
438            assert!(chunk.len() >= 64, "chunk {i} size {} < min 64", chunk.len());
439            assert!(
440                chunk.len() <= 1024,
441                "chunk {i} size {} > max 1024",
442                chunk.len()
443            );
444        }
445    }
446
447    #[test]
448    fn boundary_shift_one_byte_insert_affects_few_chunks() {
449        // Inserting one byte at the start should only shift a small
450        // number of chunk boundaries before the chunks re-synchronise.
451        let chunker = FastCDC::new(64, 256, 1024).expect("valid sizes");
452        let base = pseudo_random_bytes(7, 10_000);
453        let mut shifted = Vec::with_capacity(base.len() + 1);
454        shifted.push(0xFF);
455        shifted.extend_from_slice(&base);
456
457        let base_chunks = chunker.chunk_slice(&base);
458        let shifted_chunks = chunker.chunk_slice(&shifted);
459
460        let base_starts: HashSet<usize> = std::iter::once(0)
461            .chain(base_chunks.iter().scan(0, |acc, c| {
462                *acc += c.len();
463                Some(*acc)
464            }))
465            .collect();
466        let shifted_starts: HashSet<usize> = std::iter::once(0)
467            .chain(shifted_chunks.iter().scan(0, |acc, c| {
468                *acc += c.len();
469                Some(*acc)
470            }))
471            .collect();
472
473        let shifted_count = shifted_starts
474            .iter()
475            .filter(|&&s| {
476                !base_starts.contains(&s) && !base_starts.contains(&(s.saturating_sub(1)))
477            })
478            .count();
479        let max_shifted_boundaries = 3;
480        assert!(
481            shifted_count <= max_shifted_boundaries,
482            "1-byte insert shifted {shifted_count} boundaries (expected ≤ {max_shifted_boundaries})"
483        );
484    }
485
486    #[test]
487    fn chunk_reader_matches_chunk_slice() {
488        let chunker = FastCDC::new(64, 256, 1024).expect("valid sizes");
489        let data = pseudo_random_bytes(99, 5000);
490        let slice_chunks: Vec<Vec<u8>> = chunker
491            .chunk_slice(&data)
492            .into_iter()
493            .map(Vec::from)
494            .collect();
495        let mut cursor = Cursor::new(&data);
496        let reader_chunks = chunker.chunk_reader(&mut cursor).expect("read succeeds");
497        assert_eq!(slice_chunks, reader_chunks);
498    }
499
500    #[test]
501    fn deterministic_across_instances() {
502        let chunker_a = FastCDC::new(64, 256, 1024).expect("valid");
503        let chunker_b = FastCDC::new(64, 256, 1024).expect("valid");
504        let data: Vec<u8> = (0..1000u32)
505            .map(|i| u8::try_from(i & 0xFF).expect("fits"))
506            .collect();
507        assert_eq!(
508            chunk_sizes(&chunker_a.chunk_slice(&data)),
509            chunk_sizes(&chunker_b.chunk_slice(&data))
510        );
511    }
512
513    #[test]
514    fn rejects_invalid_sizes() {
515        assert!(FastCDC::new(0, 256, 1024).is_err());
516        assert!(FastCDC::new(256, 256, 1024).is_err());
517        assert!(FastCDC::new(64, 64, 64).is_err());
518        assert!(FastCDC::new(64, 256, 100).is_err());
519    }
520
521    #[test]
522    fn default_sizes_match_spec() {
523        let chunker = FastCDC::default();
524        assert_eq!(chunker.min_size(), 64 * 1024);
525        assert_eq!(chunker.avg_size(), 256 * 1024);
526        assert_eq!(chunker.max_size(), 1024 * 1024);
527    }
528
529    #[test]
530    fn identical_substrings_produce_identical_chunks() {
531        // Two inputs sharing a long middle section should produce
532        // at least one identical chunk (dedup win).
533        let chunker = FastCDC::new(64, 256, 1024).expect("valid");
534        let shared: Vec<u8> = (0..2000u32)
535            .map(|i| u8::try_from(i & 0xFF).expect("fits"))
536            .collect();
537
538        let mut a = Vec::new();
539        a.extend_from_slice(&[0xAA; 100]);
540        a.extend_from_slice(&shared);
541        a.extend_from_slice(&[0xBB; 100]);
542
543        let mut b = Vec::new();
544        b.extend_from_slice(&[0xCC; 50]);
545        b.extend_from_slice(&shared);
546        b.extend_from_slice(&[0xDD; 200]);
547
548        let a_chunks = chunker.chunk_slice(&a);
549        let b_chunks = chunker.chunk_slice(&b);
550
551        let a_ids: HashSet<&[u8]> = a_chunks.iter().copied().collect();
552        let b_ids: HashSet<&[u8]> = b_chunks.iter().copied().collect();
553        let shared_chunks: Vec<&&[u8]> = a_ids.intersection(&b_ids).collect();
554        assert!(
555            !shared_chunks.is_empty(),
556            "expected at least one shared chunk between the two inputs"
557        );
558    }
559
560    #[test]
561    fn mask_for_handles_small_values() {
562        assert_eq!(mask_for(0), 0);
563        assert!(mask_for(1) > 0);
564        assert!(mask_for(256) > mask_for(64));
565    }
566
567    #[test]
568    fn empty_input_yields_no_chunks() {
569        let chunker = FastCDC::default();
570        let data: Vec<u8> = Vec::new();
571        let chunks = chunker.chunk_slice(&data);
572        assert!(chunks.is_empty());
573
574        let mut cursor = Cursor::new(&data);
575        let reader_chunks = chunker.chunk_reader(&mut cursor).expect("read succeeds");
576        assert!(reader_chunks.is_empty());
577    }
578}