Skip to main content

structured_zstd/dictionary/
fastcover.rs

1use alloc::collections::BTreeSet;
2use alloc::vec;
3use alloc::vec::Vec;
4
5#[derive(Debug, Clone, Copy)]
6pub struct FastCoverParams {
7    pub k: usize,
8    pub d: usize,
9    pub f: u32,
10    pub accel: usize,
11}
12
13#[derive(Debug, Clone, Copy)]
14pub struct FastCoverTuned {
15    pub k: usize,
16    pub d: usize,
17    pub f: u32,
18    pub accel: usize,
19    pub score: usize,
20}
21
22pub const DEFAULT_K_CANDIDATES: &[usize] = &[64, 128, 256, 512, 1024, 2048];
23pub const DEFAULT_D_CANDIDATES: &[usize] = &[6, 8, 12, 16];
24pub const DEFAULT_F_CANDIDATES: &[u32] = &[16, 18, 20];
25
26// Upstream zstd multiplicative hash primes (`ZSTD_hashXPtr` family,
27// `zstd/lib/common/zstd_internal.h`): one unaligned read + one multiply per
28// dmer instead of a per-byte FNV loop.
29const PRIME_4_BYTES: u32 = 2_654_435_761;
30const PRIME_5_BYTES: u64 = 889_523_592_379;
31const PRIME_6_BYTES: u64 = 227_718_039_650_203;
32const PRIME_7_BYTES: u64 = 58_295_818_150_454_627;
33const PRIME_8_BYTES: u64 = 0xCF1B_BCDC_B7A5_6463;
34
35/// Bytes a dmer hash reads at a position: the hash covers the first
36/// `min(d, 8)` bytes but the wide read is always 8 (upstream zstd
37/// `readLength = MAX(d, 8)`), except the pure 4-byte hash.
38#[inline]
39fn dmer_read_len(d: usize) -> usize {
40    d.max(8)
41}
42
43/// Upstream zstd `FASTCOVER_hashPtrToIndex`: hash the first `min(d, 8)` bytes of the
44/// dmer at `pos` into an `f`-bit table index. Caller guarantees
45/// `pos + dmer_read_len(d) <= sample.len()`.
46#[inline]
47fn hash_dmer_index(sample: &[u8], pos: usize, f: u32, d: usize) -> usize {
48    if d.min(8) == 4 {
49        let v = u32::from_le_bytes(sample[pos..pos + 4].try_into().unwrap());
50        return (v.wrapping_mul(PRIME_4_BYTES) >> (32 - f)) as usize;
51    }
52    let v = u64::from_le_bytes(sample[pos..pos + 8].try_into().unwrap());
53    let h = match d.min(8) {
54        5 => (v << 24).wrapping_mul(PRIME_5_BYTES),
55        6 => (v << 16).wrapping_mul(PRIME_6_BYTES),
56        7 => (v << 8).wrapping_mul(PRIME_7_BYTES),
57        _ => v.wrapping_mul(PRIME_8_BYTES),
58    };
59    (h >> (64 - f)) as usize
60}
61
62/// The frequency-table width, in the range upstream zstd's FastCOVER takes
63/// (`FASTCOVER_MAX_F`, fastcover.c). A width outside it is brought to its
64/// nearest end rather than refused; inside it, the width is used as given, so
65/// memory grows as `2^f` exactly as the caller chose.
66fn clamp_table_bits(f: u32) -> u32 {
67    f.clamp(1, 31)
68}
69
70/// A count table that does not fit in memory: larger than this target can lay
71/// out, or refused by the allocator.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) struct TableTooLarge {
74    pub(crate) entries: usize,
75}
76
77/// `len` zeroed counts, allocated as `vec![0; len]` is (zero pages the
78/// allocator hands out lazily, so a wide table costs only what is touched),
79/// but reporting a table that does not fit rather than panicking on the layout
80/// or aborting on a refused allocation.
81fn zeroed_counts<C: WindowCount>(len: usize) -> Result<Vec<C>, TableTooLarge> {
82    let too_large = TableTooLarge { entries: len };
83    let layout = core::alloc::Layout::array::<C>(len).map_err(|_| too_large)?;
84    if layout.size() == 0 {
85        return Ok(Vec::new());
86    }
87    // SAFETY: the layout has a non-zero size, checked above.
88    let pointer = unsafe { alloc::alloc::alloc_zeroed(layout) };
89    if pointer.is_null() {
90        return Err(too_large);
91    }
92    // SAFETY: `pointer` comes from the global allocator with the layout of
93    // `[C; len]`, which is what `Vec<C>` with capacity `len` frees it with, and
94    // every element is initialised: all-zero bytes are the value 0 of the
95    // integer counts `WindowCount` is implemented for (`u16`, `u32`).
96    Ok(unsafe { Vec::from_raw_parts(pointer.cast::<C>(), len, len) })
97}
98
99pub(crate) fn normalize_fastcover_params(mut params: FastCoverParams) -> FastCoverParams {
100    params.d = params.d.clamp(4, 32);
101    params.k = params.k.max(params.d).max(16);
102    params.f = clamp_table_bits(params.f);
103    params.accel = params.accel.clamp(1, 10);
104    params
105}
106
107fn build_frequency_table(
108    sample: &[u8],
109    d: usize,
110    f: u32,
111    accel: usize,
112) -> Result<Vec<u32>, TableTooLarge> {
113    let bits = clamp_table_bits(f);
114    let size = 1usize << bits;
115    // Upstream zstd accel table: `skip = accel - 1` dmers between counted dmers
116    // (`FASTCOVER_defaultAccelParameters`), i.e. a stride of `accel`.
117    let step = accel.max(1);
118    let mut table = zeroed_counts::<u32>(size)?;
119
120    let read_len = dmer_read_len(d);
121    if sample.len() < read_len {
122        return Ok(table);
123    }
124
125    let mut i = 0usize;
126    while i + read_len <= sample.len() {
127        // A count is bounded by the dmer count (`sample.len()`), far below
128        // `u32::MAX` for any trainable corpus — plain increment.
129        table[hash_dmer_index(sample, i, bits, d)] += 1;
130        i += step;
131    }
132    Ok(table)
133}
134
135fn build_raw_dict(
136    sample: &[u8],
137    dict_size: usize,
138    params: FastCoverParams,
139) -> Result<Vec<u8>, TableTooLarge> {
140    if sample.is_empty() || dict_size == 0 {
141        return Ok(Vec::new());
142    }
143
144    let params = normalize_fastcover_params(params);
145    let k = params.k;
146    let d = params.d;
147    let f = clamp_table_bits(params.f);
148    let read_len = dmer_read_len(d);
149    if sample.len() < read_len {
150        // Too short for even one wide-read dmer: no trainable content.
151        // Callers treat an empty raw dict as "sample too small".
152        return Ok(Vec::new());
153    }
154
155    // Upstream zstd `FASTCOVER_buildDictionary` epoch model: split the corpus into
156    // epochs of dmers and round-robin them, taking the best k-byte segment
157    // per visit. A segment's score is the sum of frequencies of its DISTINCT
158    // dmers, maintained incrementally while the candidate window slides
159    // (O(1) per position via the `segment_freqs` occurrence counts), and a
160    // chosen segment's dmer frequencies are zeroed so later picks value only
161    // new coverage. This replaced a global greedy set-cover with a per-
162    // segment inverted index (`BTreeMap` per segment + slot lists): that
163    // shape allocated millions of map nodes on a 1 MiB corpus and ran an
164    // order of magnitude slower than the reference trainer at equal
165    // coverage quality.
166    let nb_dmers = sample.len() - read_len + 1;
167    let mut freqs = build_frequency_table(sample, d, f, params.accel)?;
168    let dmers_in_k = k - d + 1; // `normalize` guarantees k >= d
169
170    // Upstream zstd `COVER_computeEpochs` (passes = 1): target one selection per
171    // epoch, with a floor so epochs stay large enough to contain useful
172    // segments.
173    // The floor only matters up to the corpus it is capped at, so a product
174    // past `usize` (a `k` near the top of it) is that cap, not an overflow.
175    let min_epoch_size = k
176        .checked_mul(10)
177        .map_or(nb_dmers, |floor| floor.min(nb_dmers));
178    let mut epoch_count = (dict_size / k).max(1);
179    let mut epoch_size = nb_dmers / epoch_count;
180    if epoch_size < min_epoch_size {
181        epoch_size = min_epoch_size;
182        epoch_count = (nb_dmers / epoch_size).max(1);
183    }
184
185    let layout = EpochLayout {
186        dmers_in_k,
187        epoch_size,
188        epoch_count,
189    };
190    // A window holds at most `dmers_in_k + 1` occurrences of one index (one
191    // past the segment before the oldest leaves). Upstream zstd keeps them in
192    // `u16` for any `k`; a longer segment than that counts in `u32`.
193    if dmers_in_k < usize::from(u16::MAX) {
194        select_segments::<u16>(sample, dict_size, f, d, &mut freqs, layout)
195    } else {
196        select_segments::<u32>(sample, dict_size, f, d, &mut freqs, layout)
197    }
198}
199
200/// How the corpus is walked: the dmers a segment spans, and the epochs it is
201/// split into.
202#[derive(Clone, Copy)]
203struct EpochLayout {
204    dmers_in_k: usize,
205    epoch_size: usize,
206    epoch_count: usize,
207}
208
209/// A dmer's occurrence count in the candidate window.
210trait WindowCount: Copy + PartialEq + core::ops::AddAssign + core::ops::SubAssign + From<u8> {}
211impl WindowCount for u16 {}
212impl WindowCount for u32 {}
213
214/// Pick a segment per epoch visit until `dict_size` bytes are filled, and
215/// return them as the dictionary.
216fn select_segments<C: WindowCount>(
217    sample: &[u8],
218    dict_size: usize,
219    f: u32,
220    d: usize,
221    freqs: &mut [u32],
222    layout: EpochLayout,
223) -> Result<Vec<u8>, TableTooLarge> {
224    let EpochLayout {
225        dmers_in_k,
226        epoch_size,
227        epoch_count,
228    } = layout;
229    let zero = C::from(0);
230    let one = C::from(1);
231    // Per-window dmer occurrence counts (upstream zstd `segmentFreqs`).
232    let mut segment_freqs = zeroed_counts::<C>(1usize << f)?;
233    // Fill from the back (upstream zstd layout) so the best segments sit at the end
234    // of the dictionary and get referenced with the smallest offsets.
235    let mut out = vec![0u8; dict_size];
236    let mut tail = dict_size;
237    const MAX_ZERO_SCORE_RUN: usize = 10;
238    let mut zero_score_run = 0usize;
239    let mut epoch = 0usize;
240
241    while tail > 0 {
242        let epoch_begin = epoch * epoch_size;
243        let epoch_end = epoch_begin + epoch_size;
244        epoch = (epoch + 1) % epoch_count;
245
246        // Slide the candidate window across the epoch, tracking the best
247        // segment (upstream zstd `FASTCOVER_selectSegment`).
248        let mut best_begin = 0usize;
249        let mut best_end = 0usize;
250        let mut best_score = 0u64;
251        let mut active_begin = epoch_begin;
252        let mut active_end = epoch_begin;
253        let mut active_score = 0u64;
254        while active_end < epoch_end {
255            let idx = hash_dmer_index(sample, active_end, f, d);
256            if segment_freqs[idx] == zero {
257                active_score += u64::from(freqs[idx]);
258            }
259            active_end += 1;
260            segment_freqs[idx] += one;
261            if active_end - active_begin == dmers_in_k + 1 {
262                let del = hash_dmer_index(sample, active_begin, f, d);
263                segment_freqs[del] -= one;
264                if segment_freqs[del] == zero {
265                    active_score -= u64::from(freqs[del]);
266                }
267                active_begin += 1;
268            }
269            if active_score > best_score {
270                best_begin = active_begin;
271                best_end = active_end;
272                best_score = active_score;
273            }
274        }
275        // Reset the window counts for the next epoch.
276        while active_begin < epoch_end {
277            let del = hash_dmer_index(sample, active_begin, f, d);
278            segment_freqs[del] -= one;
279            active_begin += 1;
280        }
281        // Zero the chosen segment's frequencies: its dmers are covered.
282        for pos in best_begin..best_end {
283            freqs[hash_dmer_index(sample, pos, f, d)] = 0;
284        }
285
286        if best_score == 0 {
287            // This epoch has no uncovered content left; other epochs may.
288            // Give up after a run of empty epochs (upstream zstd `maxZeroScoreRun`).
289            zero_score_run += 1;
290            if zero_score_run >= MAX_ZERO_SCORE_RUN {
291                break;
292            }
293            continue;
294        }
295        zero_score_run = 0;
296
297        let segment_size = (best_end - best_begin + d - 1).min(tail);
298        if segment_size < d {
299            break;
300        }
301        tail -= segment_size;
302        out[tail..tail + segment_size]
303            .copy_from_slice(&sample[best_begin..best_begin + segment_size]);
304    }
305
306    out.drain(..tail);
307    Ok(out)
308}
309
310fn coverage_score(dict: &[u8], eval: &[u8], d: usize, accel: usize) -> usize {
311    let read_len = dmer_read_len(d);
312    if dict.len() < read_len || eval.len() < read_len || d == 0 {
313        return 0;
314    }
315    const COVERAGE_F: u32 = 20;
316    let mut seen = BTreeSet::new();
317    for i in 0..=(dict.len() - read_len) {
318        seen.insert(hash_dmer_index(dict, i, COVERAGE_F, d));
319    }
320
321    let mut hits = 0usize;
322    let step = accel.max(1);
323    let mut i = 0usize;
324    while i + read_len <= eval.len() {
325        if seen.contains(&hash_dmer_index(eval, i, COVERAGE_F, d)) {
326            hits += 1;
327        }
328        i += step;
329    }
330    hits
331}
332
333pub fn train_fastcover_raw(
334    sample: &[u8],
335    dict_size: usize,
336    params: FastCoverParams,
337) -> Result<Vec<u8>, TableTooLarge> {
338    build_raw_dict(sample, dict_size, params)
339}
340
341pub fn optimize_fastcover_raw(
342    sample: &[u8],
343    dict_size: usize,
344    split_point: f64,
345    accel: usize,
346    d_candidates: &[usize],
347    f_candidates: &[u32],
348    k_values: &[usize],
349) -> Result<(Vec<u8>, FastCoverTuned), TableTooLarge> {
350    let d_values = if d_candidates.is_empty() {
351        DEFAULT_D_CANDIDATES
352    } else {
353        d_candidates
354    };
355    let f_values = if f_candidates.is_empty() {
356        DEFAULT_F_CANDIDATES
357    } else {
358        f_candidates
359    };
360    let k_candidates = if k_values.is_empty() {
361        DEFAULT_K_CANDIDATES
362    } else {
363        k_values
364    };
365
366    if sample.len() < 2 {
367        let params = normalize_fastcover_params(FastCoverParams {
368            k: k_candidates[0],
369            d: d_values[0],
370            f: f_values[0],
371            accel,
372        });
373        let mut dict = build_raw_dict(sample, dict_size, params)?;
374        if dict.is_empty() && dict_size > 0 {
375            let take = sample.len().min(dict_size);
376            dict.extend_from_slice(&sample[..take]);
377        }
378        return Ok((
379            dict,
380            FastCoverTuned {
381                k: params.k,
382                d: params.d,
383                f: params.f,
384                accel: params.accel,
385                score: 0,
386            },
387        ));
388    }
389
390    // Upstream's split (fastcover.c, `FASTCOVER_ctx_init`): below 1 the corpus
391    // trains on its leading share and is scored on the rest; at 1 it trains
392    // and scores on all of it. A split that is not positive keeps the 0.75
393    // default. The index stays inside the corpus so neither half is empty.
394    let (train, eval) = if split_point >= 1.0 {
395        (sample, sample)
396    } else {
397        let split = if split_point > 0.0 { split_point } else { 0.75 };
398        let split_idx = ((sample.len() as f64) * split) as usize;
399        sample.split_at(split_idx.clamp(1, sample.len() - 1))
400    };
401
402    let mut best_dict = Vec::new();
403    let mut best = FastCoverTuned {
404        k: 0,
405        d: 0,
406        f: 0,
407        accel: accel.clamp(1, 10),
408        score: 0,
409    };
410
411    for &f in f_values {
412        for &d in d_values {
413            for &k in k_candidates {
414                let params = normalize_fastcover_params(FastCoverParams { k, d, f, accel });
415                let dict = build_raw_dict(train, dict_size, params)?;
416                let score = coverage_score(dict.as_slice(), eval, params.d, params.accel);
417                if best_dict.is_empty() || score > best.score {
418                    best.score = score;
419                    best.k = params.k;
420                    best.d = params.d;
421                    best.f = params.f;
422                    best.accel = params.accel;
423                    best_dict = dict;
424                }
425            }
426        }
427    }
428
429    Ok((best_dict, best))
430}
431
432#[cfg(test)]
433mod tests;