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}