Skip to main content

limnifs_write/chunker/
parallel.rs

1//! Boundary-identical parallel FastCDC (two-phase scan + replay).
2//!
3//! The scalar [`FastCDC`] is a serial gear-hash roll: one core walks
4//! every byte of every large file. This strategy parallelises that
5//! walk WITHOUT changing a single boundary — images pack bit-for-bit
6//! identically to the scalar chunker, so it needs no format bump, no
7//! feature flag, and no opt-in.
8//!
9//! ## Where the fingerprint is (and isn't) positional
10//!
11//! FastCDC resets its fingerprint at each chunk start and skips the
12//! min-size region WITHOUT hashing — the fold begins at
13//! `chunk_start + min_size`, not at `chunk_start`. Write `w` for the
14//! number of folded bytes at a tested position (`w = pos -
15//! fold_start + 1`):
16//!
17//! - `w < 64`: the value depends on the fold start, i.e. on where
18//!   the PREVIOUS boundary landed — inherently sequential.
19//! - `w >= 64`: the gear hash `fp = (fp << 1) + gear[b]` over `u64`
20//!   has an effective window of 64 bytes, so the value equals the
21//!   fold over the trailing 64 bytes of the stream alone.
22//!
23//! That split drives the whole design: the sequential prefix region
24//! is tiny (at most 63 positions per chunk), while the windowed
25//! region — the vast majority of every large file — parallelises
26//! exactly.
27//!
28//! ## Two phases (MECE: discovery vs decision)
29//!
30//! - **Phase A — candidate scan (parallel, memory-bound).** Lanes
31//!   roll the exact windowed fingerprint over disjoint regions
32//!   (priming each lane 64 bytes before its start) and record
33//!   `(position, fp)` pairs where `fp & probe_mask == 0`, with
34//!   `probe_mask = mask1 & mask2`. Both real fire conditions imply
35//!   the probe, so the candidate list is a strict superset of every
36//!   boundary in the windowed region. With the default sizes the
37//!   probe is 17 bits — roughly one candidate per 128 KiB.
38//! - **Phase B — boundary replay (serial).** For each chunk: a
39//!   ≤63-byte micro-scan recomputes the prefix-dependent folds from
40//!   `chunk_start + min_size` (the sequential region), then the
41//!   exact FastCDC decision rule — min-size skip, mask1/mask2 split
42//!   by average, forced max — walks the candidates, consulting
43//!   fingerprints instead of recomputing them.
44//!
45//! ## Scheduling
46//!
47//! Lanes run as nested rayon work on the global pool — the same
48//! work-stealing shape the writer already uses for per-chunk BLAKE3
49//! hashing inside pipeline workers. Safe here because the streaming
50//! producer thread is pool-free (see `write_directory_streaming`).
51
52use rayon::prelude::*;
53
54use super::{Chunker, FastCDC};
55
56/// The gear hash's effective window: each byte shifts out of the
57/// u64 fingerprint after this many successors.
58const GEAR_WINDOW: usize = 64;
59
60/// Inputs at or above this size take the two-phase path; smaller
61/// ones delegate to the scalar roll (lane setup would cost more
62/// than the scan saves). With the default 256 KiB average chunk
63/// this is ~32 chunks — enough lanes to matter.
64const DEFAULT_PARALLEL_THRESHOLD: usize = 8 * 1024 * 1024;
65
66/// [`FastCDC`] with a parallel, boundary-identical `chunk_slice`.
67///
68/// Owns the scalar chunker as its reference implementation and
69/// small-input fallback; the parallel path is purely a throughput
70/// overlay with byte-identical output (pinned by tests here and by
71/// the writer's pack-twice determinism suite).
72#[derive(Clone, Debug)]
73pub struct ParallelFastCDC {
74    scalar: FastCDC,
75    threshold: usize,
76}
77
78impl ParallelFastCDC {
79    /// Construct with explicit size parameters and the default
80    /// parallel threshold.
81    ///
82    /// # Errors
83    ///
84    /// Same contract as [`FastCDC::new`].
85    pub fn new(min_size: usize, avg_size: usize, max_size: usize) -> Result<Self, &'static str> {
86        Ok(Self {
87            scalar: FastCDC::new(min_size, avg_size, max_size)?,
88            threshold: DEFAULT_PARALLEL_THRESHOLD,
89        })
90    }
91
92    /// Wrap an existing scalar chunker, taking large inputs to the
93    /// two-phase path once `data.len() >= threshold`.
94    #[must_use]
95    pub fn with_scalar(scalar: FastCDC, threshold: usize) -> Self {
96        Self { scalar, threshold }
97    }
98
99    /// The scalar reference implementation this strategy wraps.
100    #[must_use]
101    pub fn scalar(&self) -> &FastCDC {
102        &self.scalar
103    }
104
105    /// Phase A: lanes over disjoint regions record probe hits.
106    ///
107    /// `lanes` is a parameter (not read from the environment) so the
108    /// thread-determinism test can pin output equality across lane
109    /// counts; callers pass the pool width.
110    fn scan_candidates(&self, data: &[u8], lanes: usize) -> Vec<(usize, u64)> {
111        let lane_len = data.len().div_ceil(lanes);
112        let probe = self.scalar.mask1 & self.scalar.mask2;
113        let gear = &self.scalar.gear.bytes;
114        (0..lanes)
115            .into_par_iter()
116            .map(|lane| {
117                let r0 = lane * lane_len;
118                let r1 = (r0 + lane_len).min(data.len());
119                let mut hits = Vec::new();
120                if r0 >= r1 {
121                    return hits;
122                }
123                // Prime from r0 - 64 so the fingerprint at r0 is
124                // already the exact windowed value (see module docs).
125                // Lane 0 primes from the true stream start, which is
126                // the windowed value for its region as well.
127                let prime_start = r0.saturating_sub(GEAR_WINDOW);
128                let mut fp = 0u64;
129                for i in prime_start..r1 {
130                    fp = (fp << 1).wrapping_add(gear[usize::from(data[i])]);
131                    if i >= r0 && fp & probe == 0 {
132                        hits.push((i, fp));
133                    }
134                }
135                hits
136            })
137            .collect::<Vec<Vec<(usize, u64)>>>()
138            .concat()
139    }
140
141    /// Phase B: serial replay of the exact FastCDC decision rule.
142    /// Mirrors `FastCDC::find_boundary` state-for-state: min-size
143    /// skip (which also skips hashing), mask split at avg, forced
144    /// boundary at max, short tail.
145    fn replay_boundaries(&self, data: &[u8], candidates: &[(usize, u64)]) -> Vec<(usize, usize)> {
146        let (min_size, avg_size, max_size, mask1, mask2) = (
147            self.scalar.min_size,
148            self.scalar.avg_size,
149            self.scalar.max_size,
150            self.scalar.mask1,
151            self.scalar.mask2,
152        );
153        let gear = &self.scalar.gear.bytes;
154        let mut chunks = Vec::new();
155        let mut start = 0usize;
156        let mut cursor = 0usize;
157        while start < data.len() {
158            let max_end = (start + max_size).min(data.len());
159            if max_end - start <= min_size {
160                chunks.push((start, max_end));
161                start = max_end;
162                continue;
163            }
164            let fold_start = start + min_size;
165            // Micro-scan: positions with fewer than 64 folded bytes
166            // depend on the fold start, so recompute them here — at
167            // most 63 gear steps per chunk.
168            let warm_end = (fold_start + GEAR_WINDOW - 1).min(max_end);
169            let mut end = max_end;
170            let mut fired = false;
171            let mut fp = 0u64;
172            let mut pos = fold_start;
173            while pos < warm_end {
174                fp = (fp << 1).wrapping_add(gear[usize::from(data[pos])]);
175                if fp & mask_for_pos(pos - start, avg_size, mask1, mask2) == 0 {
176                    end = pos + 1;
177                    fired = true;
178                    break;
179                }
180                pos += 1;
181            }
182            if !fired {
183                // Windowed region: lane fingerprints are exact from
184                // fold_start + 63 (>= 64 folded bytes) onward.
185                let candidate_from = fold_start + GEAR_WINDOW - 1;
186                for &(cpos, cfp) in &candidates[cursor..] {
187                    if cpos >= max_end {
188                        break;
189                    }
190                    if cpos < candidate_from {
191                        continue;
192                    }
193                    if cfp & mask_for_pos(cpos - start, avg_size, mask1, mask2) == 0 {
194                        end = cpos + 1;
195                        break;
196                    }
197                }
198            }
199            chunks.push((start, end));
200            // Every candidate before the new boundary is below the
201            // next chunk's windowed region — retire them for good.
202            while cursor < candidates.len() && candidates[cursor].0 < end {
203                cursor += 1;
204            }
205            start = end;
206        }
207        chunks
208    }
209
210    /// Two-phase split for inputs above the threshold. Exposed with
211    /// an explicit lane count for the determinism test.
212    fn chunk_slice_with_lanes<'a>(&self, data: &'a [u8], lanes: usize) -> Vec<&'a [u8]> {
213        let candidates = self.scan_candidates(data, lanes);
214        self.replay_boundaries(data, &candidates)
215            .into_iter()
216            .map(|(s, e)| &data[s..e])
217            .collect()
218    }
219}
220
221/// The FastCDC mask for a tested position: level-1 before the
222/// average size, level-2 after.
223fn mask_for_pos(pos_in_chunk: usize, avg_size: usize, mask1: u64, mask2: u64) -> u64 {
224    if pos_in_chunk < avg_size {
225        mask1
226    } else {
227        mask2
228    }
229}
230
231impl Default for ParallelFastCDC {
232    fn default() -> Self {
233        Self {
234            scalar: FastCDC::default(),
235            threshold: DEFAULT_PARALLEL_THRESHOLD,
236        }
237    }
238}
239
240impl Chunker for ParallelFastCDC {
241    fn chunk_slice<'a>(&self, data: &'a [u8]) -> Vec<&'a [u8]> {
242        if data.len() < self.threshold || rayon::current_num_threads() < 2 {
243            return self.scalar.chunk_slice(data);
244        }
245        self.chunk_slice_with_lanes(data, rayon::current_num_threads())
246    }
247
248    fn chunk_reader(&self, reader: &mut dyn std::io::Read) -> std::io::Result<Vec<Vec<u8>>> {
249        // Streaming keeps its constant-memory contract: the
250        // two-phase path needs the whole buffer for random lane
251        // access, so pipes stay on the scalar roll.
252        self.scalar.chunk_reader(reader)
253    }
254
255    fn avg_chunk_size(&self) -> usize {
256        self.scalar.avg_size
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
265        let mut state = seed;
266        let mut out = Vec::with_capacity(count);
267        for _ in 0..count {
268            state = state
269                .wrapping_mul(6_364_136_223_846_793_005)
270                .wrapping_add(1_442_695_040_888_963_407);
271            out.push(u8::try_from(state >> 56).expect("fits u8"));
272        }
273        out
274    }
275
276    fn boundaries(chunks: &[&[u8]]) -> Vec<usize> {
277        let mut out = Vec::with_capacity(chunks.len() + 1);
278        out.push(0);
279        let mut acc = 0;
280        for c in chunks {
281            acc += c.len();
282            out.push(acc);
283        }
284        out
285    }
286
287    /// Small sizes with a tiny threshold exercise both phases
288    /// densely; equality with the scalar roll is the whole contract.
289    fn subject(threshold: usize) -> ParallelFastCDC {
290        ParallelFastCDC::with_scalar(FastCDC::new(64, 256, 1024).expect("valid sizes"), threshold)
291    }
292
293    #[test]
294    fn parallel_matches_scalar_on_random_input() {
295        let chunker = subject(1024);
296        let data = pseudo_random_bytes(0xBEEF, 512 * 1024);
297        for lanes in [1usize, 2, 3, 5, 8, 16] {
298            assert_eq!(
299                boundaries(&chunker.chunk_slice_with_lanes(&data, lanes)),
300                boundaries(&chunker.scalar().chunk_slice(&data)),
301                "lane count {lanes} must be boundary-identical"
302            );
303        }
304    }
305
306    #[test]
307    fn parallel_matches_scalar_on_structured_input() {
308        let chunker = subject(1024);
309        // Low-entropy + periodic content: forces long mask2 regions
310        // and forced-max boundaries the replay must reproduce.
311        let mut data = Vec::with_capacity(400 * 1024);
312        for block in 0..400 {
313            match block % 3 {
314                0 => data.extend(std::iter::repeat(0x00).take(1024)),
315                1 => data.extend(std::iter::repeat(0xFF).take(1024)),
316                _ => data.extend((0..1024u32).map(|i| u8::try_from(i & 0xFF).expect("fits"))),
317            }
318        }
319        for lanes in [2usize, 4, 7] {
320            assert_eq!(
321                boundaries(&chunker.chunk_slice_with_lanes(&data, lanes)),
322                boundaries(&chunker.scalar().chunk_slice(&data)),
323                "structured input, lanes {lanes}"
324            );
325        }
326    }
327
328    #[test]
329    fn parallel_matches_scalar_with_narrow_min_size() {
330        // min_size < 64: chunk folds start closer together, so the
331        // prefix-dependent region dominates more chunks — the
332        // micro-scan must still reproduce every boundary.
333        let chunker =
334            ParallelFastCDC::with_scalar(FastCDC::new(8, 64, 256).expect("valid sizes"), 1);
335        let data = pseudo_random_bytes(21, 128 * 1024);
336        for lanes in [2usize, 5, 9] {
337            assert_eq!(
338                boundaries(&chunker.chunk_slice_with_lanes(&data, lanes)),
339                boundaries(&chunker.scalar().chunk_slice(&data)),
340                "narrow min, lanes {lanes}"
341            );
342        }
343    }
344
345    #[test]
346    fn parallel_matches_scalar_at_default_sizes() {
347        // The shipped configuration: 64 KiB / 256 KiB / 1 MiB.
348        let chunker = ParallelFastCDC::with_scalar(FastCDC::default(), 1024);
349        let data = pseudo_random_bytes(0x5EED, 6 * 1024 * 1024);
350        assert_eq!(
351            boundaries(&chunker.chunk_slice_with_lanes(&data, 4)),
352            boundaries(&chunker.scalar().chunk_slice(&data)),
353            "default sizes must be boundary-identical"
354        );
355    }
356
357    #[test]
358    fn parallel_covers_input_exactly() {
359        let chunker = subject(1024);
360        let data = pseudo_random_bytes(7, 300 * 1024);
361        let chunks = chunker.chunk_slice_with_lanes(&data, 4);
362        let total: usize = chunks.iter().map(|c| c.len()).sum();
363        assert_eq!(total, data.len());
364        assert_eq!(chunks.first().map(|c| c.as_ptr()), Some(data.as_ptr()));
365    }
366
367    #[test]
368    fn below_threshold_delegates_to_scalar() {
369        let chunker = subject(1024 * 1024);
370        let data = pseudo_random_bytes(3, 4096);
371        assert_eq!(
372            boundaries(&chunker.chunk_slice(&data)),
373            boundaries(&chunker.scalar().chunk_slice(&data))
374        );
375    }
376
377    #[test]
378    fn empty_and_short_inputs_match_scalar() {
379        let chunker = subject(1);
380        assert!(chunker.chunk_slice(&[]).is_empty());
381        let data = vec![0xAB; 50];
382        let chunks = chunker.chunk_slice(&data);
383        assert_eq!(chunks.len(), 1);
384        assert_eq!(chunks[0].len(), 50);
385    }
386
387    #[test]
388    fn chunk_reader_stays_streaming_and_matches() {
389        use std::io::Cursor;
390        let chunker = subject(1);
391        let data = pseudo_random_bytes(99, 100 * 1024);
392        let mut cursor = Cursor::new(&data);
393        let streamed = chunker.chunk_reader(&mut cursor).expect("read succeeds");
394        let sliced: Vec<Vec<u8>> = chunker
395            .chunk_slice(&data)
396            .into_iter()
397            .map(Vec::from)
398            .collect();
399        assert_eq!(streamed, sliced);
400    }
401}