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