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}