Skip to main content

structured_zstd/encoding/levels/
config.rs

1//! Per-level compression tuning: the matcher config structs, the level
2//! parameter table, and the level → params resolution chain.
3//!
4//! Moved verbatim from `match_generator.rs` (no behaviour change): the
5//! `HcConfig` / `RowConfig` / `DfastConfig` / `FastConfig` knobs, `LevelParams`
6//! and `LEVEL_TABLE`, the public-parameter overrides, the source-size tiering,
7//! and the workspace estimators. `match_generator` imports this resolution API
8//! instead of carrying it inline. Encoding-level paths are written absolute
9//! (`crate::encoding::…`) so the module can live under `levels/` unchanged.
10
11use crate::encoding::CompressionLevel;
12use crate::encoding::match_generator::{HC_SEARCH_DEPTH, HC_TARGET_LEN, ROW_MIN_MATCH_LEN};
13#[cfg(test)]
14use crate::encoding::match_generator::{ROW_HASH_BITS, ROW_LOG, ROW_SEARCH_DEPTH, ROW_TARGET_LEN};
15#[cfg(test)]
16use crate::encoding::match_table::storage::{HC_CHAIN_LOG, HC_HASH_LOG};
17/// Bundled tuning knobs for the hash-chain matcher. Using a typed config
18/// instead of positional `usize` args eliminates parameter-order hazards.
19#[derive(Copy, Clone, PartialEq, Eq)]
20pub(crate) struct HcConfig {
21    pub(crate) hash_log: usize,
22    pub(crate) chain_log: usize,
23    pub(crate) search_depth: usize,
24    pub(crate) target_len: usize,
25    /// Binary-tree finder hash width. Upstream uses `mls = BOUNDED(3, minMatch, 6)`
26    /// (`ZSTD_selectBtGetAllMatches`, zstd_opt.c:896) — i.e. mls=3 on the
27    /// btultra/btultra2 levels (L18-22, minMatch=3) — and surfaces 3-byte matches
28    /// through a fallback-only HC3 finder (zstd_opt.c:691-720: distance < 256 KiB,
29    /// taken only when no longer/repcode match exists). Our optimal parser does
30    /// NOT yet replicate that 3-byte handling — it emits short matches C prices
31    /// out, breaking level-22 sequence parity — so the BT hash width is clamped UP
32    /// to 4 (`cp.min_match.clamp(4, 6)`) to keep the finder from surfacing those
33    /// 3-byte matches and so match C's output. This is a deliberate workaround,
34    /// NOT C's finder width; drop the clamp once the optimal parser is C-faithful
35    /// at minMatch 3 (tracked in #337). Carried explicitly per level so a
36    /// `target_length` override can't silently change the finder's hashing width.
37    /// Only the BT body reads it; HC / lazy levels keep it at 4.
38    pub(crate) search_mls: usize,
39}
40
41#[derive(Copy, Clone, PartialEq, Eq)]
42pub(crate) struct RowConfig {
43    pub(crate) hash_bits: usize,
44    pub(crate) row_log: usize,
45    pub(crate) search_depth: usize,
46    pub(crate) target_len: usize,
47    /// Upstream zstd `cParams.minMatch` for the row matcher: the regular-search
48    /// acceptance floor (a row candidate must extend to >= `mls` bytes).
49    /// The C-like advanced API surfaces this as the row min-match knob.
50    /// `ROW_MIN_MATCH_LEN` (5) is the default; the row hash key width stays
51    /// 4 bytes (an internal detail), so this only tunes the acceptance
52    /// floor, not the candidate hash distribution.
53    pub(crate) mls: usize,
54}
55
56// Only used as the default HashChain config when the test-only parse×search
57// override pairs a level with a backend its native row doesn't populate.
58#[cfg(test)]
59pub(crate) const HC_CONFIG: HcConfig = HcConfig {
60    hash_log: HC_HASH_LOG,
61    chain_log: HC_CHAIN_LOG,
62    search_depth: HC_SEARCH_DEPTH,
63    target_len: HC_TARGET_LEN,
64    search_mls: 4,
65};
66
67/// Base HashChain config synthesized when a public-parameter strategy
68/// override ([`crate::encoding::parameters`]) routes a level to the HC / BT
69/// backend whose native level row didn't populate `hc` (e.g. forcing
70/// `Strategy::Lazy2` onto a level the table resolves to Fast). Mirrors
71/// the mid-band lazy defaults; the per-knob overrides then refine it.
72pub(crate) const HC_OVERRIDE_DEFAULT: HcConfig = HcConfig {
73    hash_log: crate::encoding::match_table::storage::HC_HASH_LOG,
74    chain_log: crate::encoding::match_table::storage::HC_CHAIN_LOG,
75    search_depth: HC_SEARCH_DEPTH,
76    target_len: HC_TARGET_LEN,
77    search_mls: 4,
78};
79
80// Default Row config: only used by tests and the test-only parse×search
81// override (production greedy L5 carries its own `ROW_L5`).
82#[cfg(test)]
83pub(crate) const ROW_CONFIG: RowConfig = RowConfig {
84    hash_bits: ROW_HASH_BITS,
85    row_log: ROW_LOG,
86    search_depth: ROW_SEARCH_DEPTH,
87    target_len: ROW_TARGET_LEN,
88    mls: ROW_MIN_MATCH_LEN,
89};
90
91// Level-5 greedy is the ONLY strategy routed to the Row backend
92// (`StrategyTag::backend`: greedy -> Row; lazy / btopt / btultra* ->
93// HashChain), so it is the only level whose `row:` field is read. The upstream zstd
94// `clevels.h` default row (srcSize > 256 KB) for level 5 is searchLog=3,
95// targetLength=2, from which the row matcher derives:
96//   rowLog       = clamp(searchLog, 4, 6) = 4
97//   search_depth = 1 << min(searchLog, rowLog) = 8   (= nbAttempts)
98//   target_len   = targetLength = 2                  (nice-match early-out)
99// The shared `ROW_CONFIG` (row_log=5, search_depth=16, target_len=48) ran a
100// level-12-grade search here: 16 slots per row, never early-exiting until a
101// 48-byte match. That exhaustive walk was the dominant cost in greedy L5's
102// encode-speed regression vs FFI. `hash_bits` matches upstream zstd's
103// `ZSTD_getCParams(5, .., 0).hashLog` = 19 (verified via
104// `cparams_check 5`), so the row table is the same width as upstream's
105// (2^19 slots); the previous `ROW_HASH_BITS` (20) doubled both row tables vs
106// upstream, the dominant peak-memory excess on the greedy band.
107pub(crate) const ROW_L5: RowConfig = RowConfig {
108    hash_bits: 19,
109    row_log: 4,
110    search_depth: 8,
111    target_len: 2,
112    mls: ROW_MIN_MATCH_LEN,
113};
114
115/// Per-level Double-Fast hash sizing, mirroring the upstream zstd `clevels.h` columns
116/// (config-driven, not a hardcoded constant): `long_hash_log` =
117/// `cParams.hashLog` (the long 8-byte hash table), `short_hash_log` =
118/// `cParams.chainLog` (the short hash table dfast repurposes as its
119/// secondary index). Only the Dfast backend reads it, so non-dfast level
120/// rows carry `dfast: None`. `minMatch` stays the upstream zstd-fixed `5`
121/// (`DFAST_MIN_MATCH_LEN`, used in const contexts).
122#[derive(Copy, Clone, PartialEq, Eq)]
123pub(crate) struct DfastConfig {
124    pub(crate) long_hash_log: u8,
125    pub(crate) short_hash_log: u8,
126}
127
128// Upstream zstd clevels.h default row (srcSize > 256 KB): L3 {hashLog 17, chainLog 16}.
129pub(crate) const DFAST_L3: DfastConfig = DfastConfig {
130    long_hash_log: 17,
131    short_hash_log: 16,
132};
133
134/// Per-level Fast-strategy tuning, only consumed by the `FastKernelMatcher`
135/// (Simple backend): `hash_log` = upstream zstd `cParams.hashLog`, `mls` = upstream zstd
136/// `cParams.minMatch` (4..=8), `step_size` = upstream zstd `stepSize`. Carried as
137/// `LevelParams.fast` (`Some` only on Fast level rows; `None` elsewhere).
138#[derive(Copy, Clone, PartialEq, Eq)]
139pub(crate) struct FastConfig {
140    pub(crate) hash_log: u32,
141    pub(crate) mls: u32,
142    pub(crate) step_size: usize,
143}
144
145pub(crate) const FAST_L1: FastConfig = FastConfig {
146    hash_log: 14,
147    // Tier-0 (srcSize > 256 KiB) `cParams.minMatch`. Upstream zstd selects the
148    // Level-1 row from a 4-way srcSize-tiered table (`ZSTD_getCParams_internal`
149    // → `ZSTD_defaultCParameters[tableID][1]`), and minMatch shrinks for
150    // smaller inputs: 7 (>256 KiB) / 6 (16..256 KiB) / 5 (<=16 KiB). The base
151    // here is the tier-0 value; `fast_l1_mls_for_source_size` lowers it per the
152    // tier in `adjust_params_for_source_size`.
153    mls: 7,
154    step_size: 2,
155};
156
157/// Resolved tuning parameters for a compression level. The
158/// [`StrategyTag`] is the single source of truth for the backend
159/// family and the compile-time strategy consts; the runtime
160/// [`BackendTag`] used by the driver dispatcher is derived via
161/// [`StrategyTag::backend`] so the two cannot drift.
162#[derive(Copy, Clone, PartialEq, Eq)]
163pub(crate) struct LevelParams {
164    pub(crate) strategy_tag: crate::encoding::strategy::StrategyTag,
165    /// Decoupled search-method axis. Independent of `strategy_tag`'s
166    /// parse half: a level can pair any parse (greedy / lazy depth via
167    /// `lazy_depth`) with any search backend here. Defaults to the
168    /// historical pairing (`strategy_tag.search()`) but is overridable
169    /// per level so the parse×search matrix can be swept and tuned.
170    pub(crate) search: crate::encoding::strategy::SearchMethod,
171    pub(crate) window_log: u8,
172    pub(crate) lazy_depth: u8,
173    /// Per-strategy tuning. Exactly one is `Some` on each level row, matching
174    /// `strategy_tag`'s backend, so the table self-documents which knobs a
175    /// level actually consumes (the others are `None`, not dead placeholders):
176    /// `fast` for the Fast/Simple backend, `dfast` for Double-Fast, `hc` for
177    /// the HashChain (lazy / btopt / btultra*) backend, `row` for the Row
178    /// (greedy L5) backend.
179    pub(crate) fast: Option<FastConfig>,
180    pub(crate) dfast: Option<DfastConfig>,
181    pub(crate) hc: Option<HcConfig>,
182    pub(crate) row: Option<RowConfig>,
183}
184
185impl LevelParams {
186    /// Backend family (storage variant) for the driver dispatcher.
187    /// Derived from the decoupled `search` axis so a level can route to
188    /// a different search backend than its `strategy_tag` historically
189    /// implied.
190    pub(crate) fn backend(&self) -> crate::encoding::strategy::BackendTag {
191        self.search.backend()
192    }
193
194    /// Parse mode derived from the decoupled `search` axis: the binary-tree
195    /// search path carries `ParseMode::Optimal`; every other search backend
196    /// derives greedy/lazy/lazy2 from `lazy_depth`. Reading `search` (not the
197    /// strategy tag) keeps the parse×search decoupling complete even when a
198    /// level whose tag is `Bt*` is overridden to a non-BT search backend.
199    pub(crate) fn parse(&self) -> crate::encoding::strategy::ParseMode {
200        match self.search {
201            crate::encoding::strategy::SearchMethod::BinaryTree => {
202                crate::encoding::strategy::ParseMode::Optimal
203            }
204            _ => crate::encoding::strategy::ParseMode::from_lazy_depth(self.lazy_depth),
205        }
206    }
207
208    /// Cheap fingerprint pre-splitter level (the C-like `blockSplitterLevel`):
209    /// the EFFECTIVE upstream `ZSTD_splitBlock` level that
210    /// `ZSTD_optimalBlockSize` dispatches, i.e. `splitLevels[strategy] - 2`
211    /// (clamped at 0), NOT the raw `splitLevels[]` value. `split_level == 0`
212    /// routes to the cheap from-borders heuristic; `1..=4` to byChunks with
213    /// internal sampling level `split_level - 1`. See the body for the
214    /// per-strategy tier table and why the raw-table mapping was wrong.
215    pub(crate) fn pre_split(&self) -> Option<u8> {
216        use crate::encoding::strategy::StrategyTag;
217        // Effective upstream `ZSTD_splitBlock` level = `splitLevels[strat] - 2`
218        // (clamped at 0). Upstream `splitLevels[] = {0,0,1,2,2,3,3,4,4,4}` then
219        // subtracts 2 before dispatch, so the byChunks sampling tier is two
220        // steps coarser than the raw table: greedy/lazy(d1)=0 (from-borders),
221        // lazy2/btlazy2=1 (byChunks rate 43), btopt+=2 (byChunks rate 11).
222        // An earlier version mirrored the RAW table AND bumped lazy2 to the
223        // rate-1 full scan (split 4) to dodge a periodic-input phantom-split —
224        // that ran the pre-splitter at up to 43x upstream's sampling cost
225        // (~87% of L9 encode time on the decode corpus). Per the drop-in
226        // contract ratio only needs to stay <= upstream, so matching upstream's
227        // sampling tier (and accepting upstream's identical over-split on
228        // periodic input) is the dominant large-input encode-speed win.
229        Some(match self.strategy_tag {
230            // splitLevels 0/1 -> 0: upstream does not pre-split fast/dfast at
231            // all; from-borders is the cheapest stand-in and rarely splits.
232            StrategyTag::Fast | StrategyTag::Dfast => 0,
233            // greedy / lazy(depth 1): splitLevels 2 -> 0 (from-borders).
234            StrategyTag::Greedy => 0,
235            StrategyTag::Lazy => {
236                if self.lazy_depth >= 2 {
237                    1 // lazy2: splitLevels 3 -> 1 (byChunks rate 43)
238                } else {
239                    0 // lazy depth 1: splitLevels 2 -> 0 (from-borders)
240                }
241            }
242            StrategyTag::Btlazy2 => 1, // splitLevels 3 -> 1 (byChunks rate 43)
243            StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2 => 2,
244        })
245    }
246}
247
248/// Apply the public-parameter per-knob overrides (#27) onto the
249/// level-resolved [`LevelParams`], in place. Runs in [`Matcher::reset`]
250/// after the level params are computed and before backend selection, so
251/// a strategy override re-routes the backend uniformly. An all-`None`
252/// override is a no-op the caller skips via
253/// [`crate::encoding::parameters::ParamOverrides::is_empty`], keeping the default
254/// level geometry byte-identical.
255pub(crate) fn apply_param_overrides(
256    params: &mut LevelParams,
257    ov: &crate::encoding::parameters::ParamOverrides,
258) {
259    use crate::encoding::strategy::SearchMethod;
260
261    // 1. Strategy override re-derives tag / search / lazy depth.
262    if let Some(strategy) = ov.strategy {
263        let tag = strategy.tag();
264        params.strategy_tag = tag;
265        params.search = tag.search();
266        params.lazy_depth = strategy.lazy_depth();
267    }
268
269    // 2. Ensure the active backend's config row exists (synthesize a
270    //    default when a strategy override moved off the native row).
271    match params.search {
272        SearchMethod::Fast => {
273            params.fast.get_or_insert(FAST_L1);
274        }
275        SearchMethod::DoubleFast => {
276            params.dfast.get_or_insert(DFAST_L3);
277        }
278        SearchMethod::RowHash => {
279            params.row.get_or_insert(ROW_L5);
280        }
281        SearchMethod::HashChain | SearchMethod::BinaryTree => {
282            // A `Btlazy2` strategy override moved off a non-HC row needs the
283            // BT 5-byte finder hash (upstream zstd minMatch 5); other synthesized HC
284            // rows keep the 4-byte default. An explicit `min_match` override
285            // below refines this further.
286            params.hc.get_or_insert(HcConfig {
287                search_mls: if matches!(
288                    params.strategy_tag,
289                    crate::encoding::strategy::StrategyTag::Btlazy2
290                ) {
291                    5
292                } else {
293                    HC_OVERRIDE_DEFAULT.search_mls
294                },
295                ..HC_OVERRIDE_DEFAULT
296            });
297        }
298    }
299
300    // 3. window_log (bounds-checked at <= 30 by the builder).
301    if let Some(window_log) = ov.window_log {
302        params.window_log = window_log;
303    }
304
305    // 4. Per-backend numeric knobs map into the active config, mirroring
306    //    the upstream zstd `cParams` -> matcher translation documented on each
307    //    config struct.
308    match params.search {
309        SearchMethod::Fast => {
310            if let Some(fast) = params.fast.as_mut() {
311                if let Some(hash_log) = ov.hash_log {
312                    fast.hash_log = hash_log;
313                }
314                if let Some(min_match) = ov.min_match {
315                    fast.mls = min_match;
316                }
317            }
318        }
319        SearchMethod::DoubleFast => {
320            if let Some(dfast) = params.dfast.as_mut() {
321                // hashLog -> long table, chainLog -> short table (the
322                // dfast secondary index). Both bounds-checked <= 30, so
323                // the `u8` casts are lossless.
324                if let Some(hash_log) = ov.hash_log {
325                    dfast.long_hash_log = hash_log as u8;
326                }
327                if let Some(chain_log) = ov.chain_log {
328                    dfast.short_hash_log = chain_log as u8;
329                }
330            }
331        }
332        SearchMethod::RowHash => {
333            if let Some(row) = params.row.as_mut() {
334                // Row hash-table width override (mirrors dfast `long_hash_log`
335                // / hc `hash_log`). Row has no separate chain table — the
336                // per-row depth comes from `search_log` below — so only
337                // `hash_log` maps here; `chain_log` has no Row analogue.
338                if let Some(hash_log) = ov.hash_log {
339                    row.hash_bits = hash_log as usize;
340                }
341                if let Some(search_log) = ov.search_log {
342                    // Upstream zstd: rowLog = clamp(searchLog, 4, 6);
343                    //        nbAttempts = 1 << min(searchLog, rowLog).
344                    let row_log = (search_log as usize).clamp(4, 6);
345                    row.row_log = row_log;
346                    row.search_depth = 1usize << (search_log as usize).min(row_log);
347                }
348                if let Some(target_length) = ov.target_length {
349                    row.target_len = target_length as usize;
350                }
351                if let Some(min_match) = ov.min_match {
352                    row.mls = min_match as usize;
353                }
354            }
355        }
356        SearchMethod::HashChain | SearchMethod::BinaryTree => {
357            if let Some(hc) = params.hc.as_mut() {
358                if let Some(hash_log) = ov.hash_log {
359                    hc.hash_log = hash_log as usize;
360                }
361                if let Some(chain_log) = ov.chain_log {
362                    hc.chain_log = chain_log as usize;
363                }
364                if let Some(search_log) = ov.search_log {
365                    hc.search_depth = 1usize << search_log;
366                }
367                if let Some(target_length) = ov.target_length {
368                    hc.target_len = target_length as usize;
369                }
370                if let Some(min_match) = ov.min_match {
371                    // BT finder hash width, derived from cParams.minMatch exactly
372                    // as upstream zstd: `mls = BOUNDED(3, cParams.minMatch, 6)`
373                    // (zstd_opt.c:896 ZSTD_selectBtGetAllMatches). minMatch=3
374                    // tiers hash on 3 bytes (btultra/btultra2 path). Only the BT
375                    // body reads `search_mls`; HC/lazy hash on 4 bytes regardless.
376                    hc.search_mls = (min_match as usize).clamp(3, 6);
377                }
378            }
379        }
380    }
381}
382
383/// Map the resolved runtime strategy to the upstream zstd LDM strategy ordinal
384/// (1..=9) that [`crate::encoding::ldm::params::LdmParams::adjust_for`] expects.
385/// The collapsed `Lazy` tag splits on `lazy_depth` (lazy = 4, lazy2 = 5).
386#[cfg(feature = "hash")]
387pub(crate) fn ldm_strategy_ordinal(
388    tag: crate::encoding::strategy::StrategyTag,
389    lazy_depth: u8,
390) -> u32 {
391    use crate::encoding::strategy::StrategyTag;
392    match tag {
393        StrategyTag::Fast => 1,
394        StrategyTag::Dfast => 2,
395        StrategyTag::Greedy => 3,
396        StrategyTag::Lazy => {
397            if lazy_depth >= 2 {
398                5
399            } else {
400                4
401            }
402        }
403        // Upstream zstd `ZSTD_btlazy2` ordinal.
404        StrategyTag::Btlazy2 => 6,
405        StrategyTag::BtOpt => 7,
406        StrategyTag::BtUltra => 8,
407        StrategyTag::BtUltra2 => 9,
408    }
409}
410
411/// `ceil(log2(size))` of a source-size hint, with a zero hint floored to
412/// [`MIN_WINDOW_LOG`]. This is the single quantization every hint-dependent
413/// matcher parameter is derived from: the window-log cap, the HC / Fast hash
414/// and chain widths, the Dfast / Row table widths, the L22 config buckets, and
415/// the Fast attach-vs-copy cutoff. Two hints sharing this value resolve to the
416/// identical matcher shape, which is why it (not the raw byte count) keys the
417/// primed-dictionary snapshot — see [`PrimedKey`]. Operates on the full `u64`
418/// so callers comparing a hint against a cutoff get the same bucketed decision
419/// here and at the driver, with no `as usize` truncation on 32-bit targets.
420pub(crate) fn source_size_ceil_log(size: u64) -> u8 {
421    if size == 0 {
422        MIN_WINDOW_LOG
423    } else {
424        (64 - (size - 1).leading_zeros()) as u8
425    }
426}
427
428/// Attach-vs-copy cutoff for the Fast strategy, as a ceil-log bucket: a hint at
429/// or below `2^this` (or unknown, `None`) ATTACHES the dictionary (a separate
430/// immutable table scanned in place via the borrowed dual-base kernel); a larger
431/// hint would COPY it into the live table.
432///
433/// We set this to `31` so every dictionary source up to 2 GiB attaches,
434/// diverging from upstream zstd's 8 KiB `ZSTD_shouldAttachDict` cutoff ON
435/// PURPOSE: upstream copy mode copies the small CDict TABLES into the cctx and
436/// still scans the input in place, but our flat-history copy path memmoves the
437/// whole INPUT into history every frame (profiled at 30% `__memmove` + 14%
438/// `__memset` on a reused 1 MiB dict encode). Attach mode scans the caller's
439/// input in place with the dict as a separate prefix base, so it is strictly
440/// faster for every frame size here (measured: 1 MiB dict frame 167 us -> 52 us,
441/// 0.42x of C; 10 KiB 20.4 us -> 4.4 us, 0.17x of C). The dual-base kernel
442/// carries `window_low`, so over-window inputs stay in-window and C-decodable.
443///
444/// `31` is also the largest bucket the borrowed kernel can attach: it stores
445/// virtual positions as `u32` (`cur_abs as u32`), so the maximum attached source
446/// `1 << 31` (plus the dict prefix) stays below `u32::MAX`; the next bucket `32`
447/// (4 GiB) would wrap that arithmetic. Sources past 2 GiB therefore fall back to
448/// copy mode — rare in practice, and the relative copy cost shrinks as the
449/// source grows. Per the drop-in-not-binary-parity contract, we make this match
450/// decision ourselves.
451/// Shared by `reset` (records the mode in the primed-snapshot key) and
452/// `prime_with_dictionary` (acts on it).
453pub(crate) const FAST_ATTACH_DICT_CUTOFF_LOG: u8 = 31;
454
455/// Largest dictionary region (bytes) the Fast attach path can index. The tagged
456/// dict table packs each position into `32 - DICT_TAG_BITS` (= 24) bits, so a
457/// region past `2^24` (16 MiB) would overflow the packed position. Dictionaries
458/// this large fall back to COPY mode, whose live table stores full `u32`
459/// positions and handles them. The size hint set on dict load equals the actual
460/// dict content length, so the attach-vs-copy decision (and the matching
461/// snapshot-key / epoch bits) can gate on it consistently at reset time.
462pub(crate) const MAX_FAST_ATTACH_DICT_REGION: usize = 1 << 24;
463
464/// Dfast counterpart of [`FAST_ATTACH_DICT_CUTOFF_LOG`]: upstream zstd
465/// `ZSTD_dictMatchState` attach cutoff for the double-fast strategy is 16 KiB
466/// (`2^14`), so small / unknown-size inputs ATTACH (separate immutable dict
467/// long+short tables + dual-probe in `start_matching_fast_loop`) and larger
468/// known-size inputs COPY (re-prime the dict into the live tables, where the
469/// dense scan matches it as window history). The attach build also self-gates
470/// on `use_fast_loop` inside `skip_matching_for_dict_attach` — only the
471/// fast-loop levels (L3 / Default / L0) carry the dual-probe.
472pub(crate) const DFAST_ATTACH_DICT_CUTOFF_LOG: u8 = 14;
473
474/// `ZSTD_dictMatchState` attach cutoff for the Row (greedy/lazy) strategy is
475/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs`): small / unknown-size inputs
476/// ATTACH the dict into the separate immutable row index (bounded dual-probe in
477/// `row_candidate_rl`), larger known-size inputs dense-COPY into the live rows.
478pub(crate) const ROW_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
479
480/// 32 KiB (`2^15`, upstream zstd `attachDictSizeCutoffs[ZSTD_lazy2]`): small /
481/// unknown-size inputs ATTACH the dict as a separate hash-chain dms (the dual
482/// search in `find_best_match` walks the live input chain + the dms), larger
483/// known-size inputs dense-COPY (merge the dict into the live chain and search
484/// the one combined chain).
485pub(crate) const HC_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
486
487/// BT/optimal attach cutoff for `btlazy2` + `btopt`: 32 KiB (`2^15`, upstream
488/// zstd `attachDictSizeCutoffs[ZSTD_btlazy2]` == `[ZSTD_btopt]`). Small /
489/// unknown-size inputs ATTACH the dict as a separate DUBT dms; larger known-size
490/// inputs COPY the dict into the LIVE binary tree (upstream zstd
491/// `ZSTD_resetCCtx_byCopyingCDict`).
492pub(crate) const BT_OPT_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
493
494/// BT/optimal attach cutoff for `btultra` + `btultra2`: 8 KiB (`2^13`, upstream
495/// zstd `attachDictSizeCutoffs[ZSTD_btultra]` == `[ZSTD_btultra2]`). The deepest
496/// parses copy the dict into the live tree past a much smaller source than the
497/// `btopt` tier, matching upstream's per-strategy cutoff table.
498pub(crate) const BT_ULTRA_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
499
500// Source-size cap for the dfast hash bits when a size hint is present: a tiny
501// input needs no larger hash than its window. The upstream zstd `cParams.hashLog` /
502// `chainLog` (from `DfastConfig`) caps it from above at the call site.
503pub(crate) fn dfast_hash_bits_for_window(max_window_size: usize) -> usize {
504    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
505    window_log.max(MIN_WINDOW_LOG as usize)
506}
507
508pub(crate) fn row_hash_bits_for_window(max_window_size: usize) -> usize {
509    // Upstream zstd `ZSTD_adjustCParams_internal` cap: `hashLog <= windowLog + 1`.
510    // The `+ 1` is load-bearing for L12, whose upstream zstd hashLog (23) exceeds
511    // its windowLog (22) — a plain `windowLog` cap would shrink the L12
512    // table on EVERY hinted reset and split primed snapshots between
513    // hinted and unhinted frames that resolve to the identical geometry.
514    // No constant upper clamp: the old `ROW_HASH_BITS` (20) ceiling
515    // predates the lazy band moving onto Row (L9-12 carry upstream zstd hashLog
516    // 21-23).
517    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
518    (window_log + 1).max(MIN_WINDOW_LOG as usize)
519}
520
521/// `floor(log2(window))` for the HashChain table-log cap (upstream zstd
522/// `ZSTD_adjustCParams_internal`). The caller clamps the level's `hash_log` /
523/// `chain_log` from above with this so a small hinted input doesn't allocate the
524/// full level's tables.
525pub(crate) fn hc_hash_bits_for_window(max_window_size: usize) -> usize {
526    let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
527    window_log.max(MIN_WINDOW_LOG as usize)
528}
529
530/// Upstream `ZSTD_createCDict` table geometry: the `(hash_log, chain_log)` a
531/// dictionary's prepared match-finder tables get. Thin adapter over the single
532/// cParams source [`crate::encoding::cparams::create_cdict_table_logs`], which mirrors
533/// `ZSTD_adjustCParams_internal` under `ZSTD_cpm_createCDict`. `window_log` is
534/// the resolved compress window; `hash_log` / `chain_log` are the level's own
535/// widths; `uses_bt` selects the binary-tree `cycleLog` (`chainLog - 1`).
536pub(crate) fn cdict_table_logs(
537    window_log: u8,
538    hash_log: usize,
539    chain_log: usize,
540    uses_bt: bool,
541    dict_size: usize,
542) -> (usize, usize) {
543    let (h, c) = crate::encoding::cparams::create_cdict_table_logs(
544        window_log,
545        hash_log as u32,
546        chain_log as u32,
547        uses_bt,
548        dict_size,
549    );
550    (h as usize, c as usize)
551}
552
553/// Smallest window_log the encoder will use regardless of source size.
554pub(crate) const MIN_WINDOW_LOG: u8 = 10;
555
556/// Translate a verbatim upstream `ZSTD_defaultCParameters[tier][level]` row
557/// (`cparams::CParams`) into our resolved [`LevelParams`], reproducing
558/// upstream's cParams -> matcher-config derivation so the encoder follows C's
559/// source-size-tiered STRATEGY + table widths rather than a single hand-tuned
560/// `LEVEL_TABLE`. Derivation (verified against L6/L16/L22 vs clevels.h):
561/// `search_depth = 1 << searchLog`; row `row_log = clamp(searchLog, 4, 6)`; the
562/// per-strategy sub-config carries the verbatim `hashLog` / `chainLog` /
563/// `targetLength` / `minMatch`. Strategy numbers are upstream `ZSTD_strategy`
564/// (fast=1, dfast=2, greedy=3, lazy=4, lazy2=5, btlazy2=6, btopt=7, btultra=8,
565/// btultra2=9).
566fn level_params_from_cparams(cp: crate::encoding::cparams::CParams) -> LevelParams {
567    use crate::encoding::strategy::{SearchMethod, StrategyTag};
568    let window_log = cp.window_log as u8;
569    let search_depth = 1usize << cp.search_log;
570    let target_len = cp.target_length as usize;
571    let hc = HcConfig {
572        hash_log: cp.hash_log as usize,
573        chain_log: cp.chain_log as usize,
574        search_depth,
575        target_len,
576        // Clamp UP to 4: C's BT finder uses mls=3 on L18-22, but our optimal
577        // parser diverges on the resulting 3-byte matches (breaks level-22
578        // sequence parity), so we keep the finder at >=4 as a workaround until
579        // the parser is C-faithful at minMatch 3. See `HcConfig::search_mls` (#337).
580        search_mls: cp.min_match.clamp(4, 6) as usize,
581    };
582    let row = RowConfig {
583        hash_bits: cp.hash_log as usize,
584        row_log: cp.search_log.clamp(4, 6) as usize,
585        search_depth,
586        target_len,
587        mls: cp.min_match as usize,
588    };
589    let bt = |tag| LevelParams {
590        strategy_tag: tag,
591        search: SearchMethod::BinaryTree,
592        window_log,
593        lazy_depth: 2,
594        fast: None,
595        dfast: None,
596        hc: Some(hc),
597        row: None,
598    };
599    let row_lvl = |tag, lazy_depth| LevelParams {
600        strategy_tag: tag,
601        search: SearchMethod::RowHash,
602        window_log,
603        lazy_depth,
604        fast: None,
605        dfast: None,
606        hc: None,
607        row: Some(row),
608    };
609    match cp.strategy {
610        1 => LevelParams {
611            strategy_tag: StrategyTag::Fast,
612            search: SearchMethod::Fast,
613            window_log,
614            lazy_depth: 0,
615            // Upstream fast `stepSize`: `targetLength + 1` (0 -> 1, so step 2).
616            fast: Some(FastConfig {
617                hash_log: cp.hash_log,
618                mls: cp.min_match,
619                step_size: target_len.max(1) + 1,
620            }),
621            dfast: None,
622            hc: None,
623            row: None,
624        },
625        2 => LevelParams {
626            strategy_tag: StrategyTag::Dfast,
627            search: SearchMethod::DoubleFast,
628            window_log,
629            lazy_depth: 1,
630            fast: None,
631            dfast: Some(DfastConfig {
632                long_hash_log: cp.hash_log as u8,
633                short_hash_log: cp.chain_log as u8,
634            }),
635            hc: None,
636            row: None,
637        },
638        3 => row_lvl(StrategyTag::Greedy, 0),
639        4 => row_lvl(StrategyTag::Lazy, 1),
640        5 => row_lvl(StrategyTag::Lazy, 2),
641        6 => bt(StrategyTag::Btlazy2),
642        7 => bt(StrategyTag::BtOpt),
643        8 => bt(StrategyTag::BtUltra),
644        _ => bt(StrategyTag::BtUltra2),
645    }
646}
647
648/// Down-size a synthesized backend's window / hash / chain logs for a known
649/// source size by routing through the single C-faithful adjuster
650/// [`adjust_cparams`](crate::encoding::cparams::adjust_cparams)
651/// (`ZSTD_adjustCParams_internal`).
652///
653/// Used only on the param-override re-cap path: [`apply_param_overrides`]
654/// synthesizes a backend's full-size default config when a strategy override
655/// moves off the native level row, and this re-applies the source-size cap
656/// C-faithfully — the SAME adjuster the `get_cparams` main path uses, so the
657/// override path and the level path now down-size identically (no extra hinted
658/// 16 KiB window floor, no per-backend headroom that diverged from C). The
659/// Dfast backend self-sizes its tables in `reset`, so only its window is capped.
660pub(crate) fn adjust_params_for_source_size(mut params: LevelParams, src_size: u64) -> LevelParams {
661    use crate::encoding::cparams::{CParams, adjust_cparams};
662    use crate::encoding::strategy::{BackendTag, StrategyTag};
663
664    let backend = params.backend();
665    // Lift the active backend's source-cappable logs into a flat CParams. Dfast
666    // contributes none (window-only); its table widths self-size in `reset`.
667    let (hash_log, chain_log): (u32, u32) = match backend {
668        BackendTag::Simple => (params.fast.as_ref().map_or(0, |f| f.hash_log), 0),
669        BackendTag::HashChain => params
670            .hc
671            .as_ref()
672            .map_or((0, 0), |h| (h.hash_log as u32, h.chain_log as u32)),
673        BackendTag::Row => (params.row.as_ref().map_or(0, |r| r.hash_bits as u32), 0),
674        BackendTag::Dfast => (0, 0),
675    };
676    // The chain cap (`ZSTD_cycleLog`) reads only `strategy >= btlazy2(6)`, so a
677    // coarse 6/3 split is exact for it; `adjust_cparams`'s other strategy use
678    // (`cdict_indices_are_tagged`) is gated on `create_cdict = false` here.
679    let strategy = if matches!(
680        params.strategy_tag,
681        StrategyTag::Btlazy2 | StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
682    ) {
683        6
684    } else {
685        3
686    };
687    let adj = adjust_cparams(
688        CParams {
689            window_log: u32::from(params.window_log),
690            chain_log,
691            hash_log,
692            search_log: 1,
693            min_match: 4,
694            target_length: 0,
695            strategy,
696        },
697        src_size,
698        0,
699        false,
700    );
701    params.window_log = adj.window_log as u8;
702    match backend {
703        BackendTag::Simple => {
704            if let Some(f) = params.fast.as_mut() {
705                f.hash_log = adj.hash_log;
706            }
707        }
708        BackendTag::HashChain => {
709            if let Some(h) = params.hc.as_mut() {
710                h.hash_log = adj.hash_log as usize;
711                h.chain_log = adj.chain_log as usize;
712            }
713        }
714        BackendTag::Row => {
715            if let Some(r) = params.row.as_mut() {
716                r.hash_bits = adj.hash_log as usize;
717            }
718        }
719        BackendTag::Dfast => {}
720    }
721    params
722}
723
724/// Estimated steady-state heap footprint of a one-shot compression context
725/// at `level` (window history + match-finder tables + block staging), in
726/// bytes. Computed from the same per-level tuning table the encoder
727/// resolves at frame start, so the estimate tracks the real allocations;
728/// it is an upper-bound style budget figure, not an exact accounting.
729pub fn estimated_compression_workspace_bytes(level: CompressionLevel) -> usize {
730    use crate::encoding::strategy::StrategyTag;
731    let params = resolve_level_params(level, None);
732    let window = 1usize << params.window_log;
733    // Mirror `configure()`: the HC3 short-match side table exists only on
734    // the btultra/btultra2 tags (minMatch 3), capped by the window log; the
735    // BT pointer-pair layout fits inside the `4 << chain_log` chain term
736    // (pairs over `chain_log - 1` nodes).
737    let wants_hash3 = matches!(
738        params.strategy_tag,
739        StrategyTag::BtUltra | StrategyTag::BtUltra2
740    );
741    let uses_bt = matches!(
742        params.strategy_tag,
743        StrategyTag::Btlazy2 | StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
744    );
745    let tables = params.fast.map(|f| 4usize << f.hash_log).unwrap_or(0)
746        + params
747            .dfast
748            .map(|d| (4usize << d.long_hash_log) + (4usize << d.short_hash_log))
749            .unwrap_or(0)
750        + params
751            .hc
752            .map(|h| {
753                let hash3 = if wants_hash3 {
754                    4usize
755                        << crate::encoding::match_table::storage::HC3_HASH_LOG
756                            .min(params.window_log as usize)
757                } else {
758                    0
759                };
760                (4usize << h.hash_log) + (4usize << h.chain_log) + hash3
761            })
762            .unwrap_or(0)
763        + params
764            .row
765            .map(|r| (4usize << r.hash_bits) + (2usize << r.hash_bits))
766            .unwrap_or(0);
767    // BT modes box a `BtMatcher`; its retained scratch layout is budgeted
768    // next to the struct so estimator and allocator evolve together.
769    let bt = if uses_bt {
770        crate::encoding::bt::BtMatcher::estimated_workspace_bytes()
771    } else {
772        0
773    };
774    // Block staging: literal + sequence buffers plus the compressed-block
775    // scratch, each bounded by the 128 KiB block size.
776    let staging = 3 * (128 * 1024);
777    window + tables + bt + staging
778}
779
780/// Extra steady-state workspace the binary-tree strategies (ordinals 6..=9,
781/// btlazy2..btultra2) retain beyond the hash/chain tables: the boxed matcher
782/// plus its scratch arenas, and the HC3 short-match side table for
783/// btultra/btultra2 (capped by the window log). 0 for non-BT ordinals.
784pub fn estimated_bt_strategy_extra_bytes(strategy_ordinal: u32, window_log: u32) -> usize {
785    if !(6..=9).contains(&strategy_ordinal) {
786        return 0;
787    }
788    let hash3 = if matches!(strategy_ordinal, 8 | 9) {
789        4usize << crate::encoding::match_table::storage::HC3_HASH_LOG.min(window_log as usize)
790    } else {
791        0
792    };
793    crate::encoding::bt::BtMatcher::estimated_workspace_bytes() + hash3
794}
795
796/// Resolve a [`CompressionLevel`] (+ optional source-size hint) to the
797/// concrete [`LevelParams`] the matcher runs: strategy tag, search method
798/// (match-finder), window log, and per-backend config.
799///
800/// ## CRITICAL: input size changes the match-finder (and can change strategy)
801///
802/// The resolved geometry is a function of the SOURCE SIZE, not the level
803/// alone. This is the easy-to-miss part (so read this before assuming a level
804/// maps to one fixed match-finder). It mirrors three upstream zstd stages:
805///
806/// 1. [`LEVEL_TABLE`] holds the tier-0 (source > 256 KiB) base row per level
807///    (upstream `ZSTD_defaultCParameters[0]`). L6-L12 carry
808///    `SearchMethod::RowHash` (the Row match-finder), like upstream's
809///    greedy/lazy default.
810/// 2. [`apply_cparams_tier`] overrides the table-shaping widths for the
811///    smaller source tiers (upstream `ZSTD_getCParams_internal` tier table).
812///    NOTE: upstream ALSO switches STRATEGY in some tiers (L2 → dfast, L4 →
813///    greedy on small sources); those backend switches are NOT yet replicated,
814///    so those levels keep their base strategy on small inputs.
815/// 3. [`adjust_params_for_source_size`] caps `window_log` to
816///    ~`ceil_log2(source_size)` (upstream `ZSTD_adjustCParams_internal`).
817///
818/// THEN, in the matcher `reset`, the greedy/lazy band falls back from
819/// `RowHash` to `SearchMethod::HashChain` when the resolved `window_log <= 14`
820/// — exactly upstream's `ZSTD_resolveRowMatchFinderMode` (the Row match-finder
821/// is used for greedy/lazy/lazy2 ONLY when `windowLog > 14`). Net effect for
822/// the SAME level:
823///
824/// * small input (e.g. a 10 KiB fixture → `window_log` 14) → **HashChain**
825///   (`ZSTD_HcFindBestMatch`, scalar chain walk);
826/// * large input (e.g. 1 MiB → `window_log` 20) → **RowHash** (the SIMD-tag
827///   row match-finder).
828///
829/// A dictionary does NOT change the match-finder: it only downsizes the
830/// prepared tables (`cdict_table_logs`, mirroring `ZSTD_createCDict`'s
831/// small-source assumption), while `window_log` stays source-derived. So
832/// `(L6, 10 KiB, +dict)` is HashChain and `(L6, 1 MiB, +dict)` is RowHash,
833/// both matching upstream. When comparing against C on a fixture, resolve the
834/// match-finder from the fixture's size first, or you may optimise/benchmark a
835/// path C does not even take for that input.
836pub(crate) fn resolve_level_params(
837    level: CompressionLevel,
838    source_size: Option<u64>,
839) -> LevelParams {
840    // Uncompressed = raw blocks, no match-finder. Not a cParams level, so it is
841    // the one row resolved by hand rather than through `get_cparams`.
842    if matches!(level, CompressionLevel::Uncompressed) {
843        return LevelParams {
844            strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
845            search: crate::encoding::strategy::SearchMethod::Fast,
846            // Raw frames emit literal blocks and never reference history;
847            // advertising a wider window only inflates the decoder-side buffer
848            // reservation, so clamp to 17 (128 KiB) regardless of input size.
849            window_log: 17,
850            lazy_depth: 0,
851            // Beyond-upstream: hash_log=14 (vs upstream's row-0 13) for ~2× fewer
852            // collisions on structured corpora; mls=6 / step_size=2 mirror the
853            // upstream "base for negative" row (targetLength=1 -> step 2).
854            fast: Some(FastConfig {
855                hash_log: 14,
856                mls: 6,
857                step_size: 2,
858            }),
859            dfast: None,
860            hc: None,
861            row: None,
862        };
863    }
864    // Every other level resolves through the SINGLE C-faithful cParams source,
865    // `cparams::get_cparams` (the port of `ZSTD_getCParams`). One place selects
866    // strategy + table widths + the negative-level acceleration per
867    // (level, srcSize) + the source-size window/hash down-clamp, so the encoder
868    // never re-derives parameters from a parallel hand-tuned path. Named presets
869    // map to their numeric level; the cParams source clamps out-of-range levels
870    // (>22 to 22, negatives to MIN_CLEVEL) itself.
871    let numeric: i32 = match level {
872        CompressionLevel::Uncompressed => unreachable!("handled above"),
873        // Fastest = upstream level 1 (fast strategy, smallest real-compression
874        // tables).
875        CompressionLevel::Fastest => 1,
876        // Default = upstream level 3 (the libzstd default).
877        CompressionLevel::Default => CompressionLevel::DEFAULT_LEVEL,
878        // Better = level 7: the lazy2 band — clearly above the fast/dfast levels
879        // on ratio while still well under the binary-tree cost cliff.
880        CompressionLevel::Better => 7,
881        // Best = level 13: the first point of the deep binary-tree band that
882        // strictly dominates every level below it on ratio (lower levels can tie
883        // on window-bound corpora), so the alias sits on a config that always
884        // wins rather than on a hair-thin margin.
885        CompressionLevel::Best => 13,
886        CompressionLevel::Level(n) => n,
887    };
888    let src = source_size.unwrap_or(crate::encoding::cparams::CONTENTSIZE_UNKNOWN);
889    level_params_from_cparams(crate::encoding::cparams::get_cparams(numeric, src, 0))
890}
891
892/// The cheap fingerprint pre-splitter level for a compression level (the
893/// C-like `blockSplitterLevel`), resolved through the same per-level
894/// `LevelParams` table as every other tuning knob. `None` keeps the whole
895/// 128 KiB block. The frame loop reads this instead of hardcoding the
896/// level→split mapping at the call site.
897pub(crate) fn level_pre_split(level: CompressionLevel) -> Option<usize> {
898    // Resolve through `resolve_level_params` directly — NOT via the legacy
899    // `numeric_level()` alias — so named presets read the SAME table row as
900    // every other tuning knob (`Best` maps to its own row there, which is
901    // not the row its numeric alias points at). `Uncompressed` (raw
902    // blocks) never splits.
903    if matches!(level, CompressionLevel::Uncompressed) {
904        return None;
905    }
906    resolve_level_params(level, None)
907        .pre_split()
908        .map(usize::from)
909}