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 /// Key width the binary tree hashes. A level's own `minMatch` is clamped to
26 /// `4..=6`: upstream templates its finder on `mls = BOUNDED(3, minMatch, 6)`
27 /// (`ZSTD_selectBtGetAllMatches`, zstd_opt.c), but its `ZSTD_hashPtr` has no
28 /// 3-byte case and hashes `mls = 3` on four bytes (the `default` arm,
29 /// zstd_compress_internal.h), taking 3-byte matches from the hash3 table.
30 /// A `minMatch` of 3 set explicitly keys the tree on three bytes instead:
31 /// here only btultra and btultra2 search the hash3 table, and on btopt a
32 /// 4-byte key would leave no source for the short matches the knob asks for
33 /// (z000033 at level 16 with `minMatch` 3: 443,830 bytes against 428,164).
34 /// Only the BT body reads it; HC / lazy levels keep it at 4.
35 pub(crate) search_mls: usize,
36}
37
38#[derive(Copy, Clone, PartialEq, Eq)]
39pub(crate) struct RowConfig {
40 pub(crate) hash_bits: usize,
41 pub(crate) row_log: usize,
42 pub(crate) search_depth: usize,
43 pub(crate) target_len: usize,
44 /// Upstream zstd `cParams.minMatch` for the row matcher: the regular-search
45 /// acceptance floor (a row candidate must extend to >= `mls` bytes).
46 /// The C-like advanced API surfaces this as the row min-match knob.
47 /// `ROW_MIN_MATCH_LEN` (5) is the default; the row hash key width stays
48 /// 4 bytes (an internal detail), so this only tunes the acceptance
49 /// floor, not the candidate hash distribution.
50 pub(crate) mls: usize,
51 /// Upstream `cParams.chainLog`: the hash-chain table the greedy/lazy
52 /// parse searches instead of rows when the window is 2^14 or smaller
53 /// (upstream `ZSTD_resolveRowMatchFinderMode`), or the binary tree
54 /// (`2^(chainLog-1)` nodes) of a btlazy2 level.
55 pub(crate) chain_log: usize,
56 /// Upstream `ZSTD_btlazy2`: the lazy parse searches the lazily-sorted
57 /// binary tree (`ZSTD_BtFindBestMatch`) instead of rows / the chain.
58 pub(crate) bt: bool,
59 /// Rows or the hash chain, when not the tree (`ZSTD_c_useRowMatchFinder`).
60 pub(crate) match_finder: crate::encoding::parameters::RowMatchFinderMode,
61}
62
63impl RowConfig {
64 /// Whether a frame without a dictionary searches rows with a `window`-byte
65 /// window (a dictionary frame takes its dictionary's answer).
66 pub(crate) const fn searches_rows(&self, window: usize) -> bool {
67 !self.bt && self.match_finder.searches_rows(window)
68 }
69}
70
71// Only used as the default HashChain config when the test-only parse×search
72// override pairs a level with a backend its native row doesn't populate.
73#[cfg(test)]
74pub(crate) const HC_CONFIG: HcConfig = HcConfig {
75 hash_log: HC_HASH_LOG,
76 chain_log: HC_CHAIN_LOG,
77 search_depth: HC_SEARCH_DEPTH,
78 target_len: HC_TARGET_LEN,
79 search_mls: 4,
80};
81
82/// Base HashChain config synthesized when a public-parameter strategy
83/// override ([`crate::encoding::parameters`]) routes a level to the HC / BT
84/// backend whose native level row didn't populate `hc` (e.g. forcing
85/// `Strategy::Lazy2` onto a level the table resolves to Fast). Mirrors
86/// the mid-band lazy defaults; the per-knob overrides then refine it.
87pub(crate) const HC_OVERRIDE_DEFAULT: HcConfig = HcConfig {
88 hash_log: crate::encoding::match_table::storage::HC_HASH_LOG,
89 chain_log: crate::encoding::match_table::storage::HC_CHAIN_LOG,
90 search_depth: HC_SEARCH_DEPTH,
91 target_len: HC_TARGET_LEN,
92 search_mls: 4,
93};
94
95// Default Row config: only used by tests and the test-only parse×search
96// override (production greedy L5 carries its own `ROW_L5`).
97#[cfg(test)]
98pub(crate) const ROW_CONFIG: RowConfig = RowConfig {
99 hash_bits: ROW_HASH_BITS,
100 row_log: ROW_LOG,
101 search_depth: ROW_SEARCH_DEPTH,
102 target_len: ROW_TARGET_LEN,
103 mls: ROW_MIN_MATCH_LEN,
104 chain_log: ROW_HASH_BITS,
105 bt: false,
106 match_finder: crate::encoding::parameters::RowMatchFinderMode::Auto,
107};
108
109// Level-5 greedy is the ONLY strategy routed to the Row backend
110// (`StrategyTag::backend`: greedy -> Row; lazy / btopt / btultra* ->
111// HashChain), so it is the only level whose `row:` field is read. The upstream zstd
112// `clevels.h` default row (srcSize > 256 KB) for level 5 is searchLog=3,
113// targetLength=2, from which the row matcher derives:
114// rowLog = clamp(searchLog, 4, 6) = 4
115// search_depth = 1 << min(searchLog, rowLog) = 8 (= nbAttempts)
116// target_len = targetLength = 2 (nice-match early-out)
117// The shared `ROW_CONFIG` (row_log=5, search_depth=16, target_len=48) ran a
118// level-12-grade search here: 16 slots per row, never early-exiting until a
119// 48-byte match. That exhaustive walk was the dominant cost in greedy L5's
120// encode-speed regression vs FFI. `hash_bits` matches upstream zstd's
121// `ZSTD_getCParams(5, .., 0).hashLog` = 19 (verified via
122// `cparams_check 5`), so the row table is the same width as upstream's
123// (2^19 slots); the previous `ROW_HASH_BITS` (20) doubled both row tables vs
124// upstream, the dominant peak-memory excess on the greedy band.
125pub(crate) const ROW_L5: RowConfig = RowConfig {
126 hash_bits: 19,
127 row_log: 4,
128 search_depth: 8,
129 target_len: 2,
130 mls: ROW_MIN_MATCH_LEN,
131 chain_log: 18,
132 bt: false,
133 match_finder: crate::encoding::parameters::RowMatchFinderMode::Auto,
134};
135
136/// Per-level Double-Fast hash sizing, mirroring the upstream zstd `clevels.h` columns
137/// (config-driven, not a hardcoded constant): `long_hash_log` =
138/// `cParams.hashLog` (the long 8-byte hash table), `short_hash_log` =
139/// `cParams.chainLog` (the short hash table dfast repurposes as its
140/// secondary index). Only the Dfast backend reads it, so non-dfast level
141/// rows carry `dfast: None`. `minMatch` stays the upstream zstd-fixed `5`
142/// (`DFAST_MIN_MATCH_LEN`, used in const contexts).
143#[derive(Copy, Clone, PartialEq, Eq)]
144pub(crate) struct DfastConfig {
145 pub(crate) long_hash_log: u8,
146 pub(crate) short_hash_log: u8,
147}
148
149// Upstream zstd clevels.h default row (srcSize > 256 KB): L3 {hashLog 17, chainLog 16}.
150pub(crate) const DFAST_L3: DfastConfig = DfastConfig {
151 long_hash_log: 17,
152 short_hash_log: 16,
153};
154
155/// Per-level Fast-strategy tuning, only consumed by the `FastKernelMatcher`
156/// (Simple backend): `hash_log` = upstream zstd `cParams.hashLog`, `mls` = upstream zstd
157/// `cParams.minMatch` (4..=8), `step_size` = upstream zstd `stepSize`. Carried as
158/// `LevelParams.fast` (`Some` only on Fast level rows; `None` elsewhere).
159#[derive(Copy, Clone, PartialEq, Eq)]
160pub(crate) struct FastConfig {
161 pub(crate) hash_log: u32,
162 pub(crate) mls: u32,
163 pub(crate) step_size: usize,
164}
165
166pub(crate) const FAST_L1: FastConfig = FastConfig {
167 hash_log: 14,
168 // Tier-0 (srcSize > 256 KiB) `cParams.minMatch`. Upstream zstd selects the
169 // Level-1 row from a 4-way srcSize-tiered table (`ZSTD_getCParams_internal`
170 // → `ZSTD_defaultCParameters[tableID][1]`), and minMatch shrinks for
171 // smaller inputs: 7 (>256 KiB) / 6 (16..256 KiB) / 5 (<=16 KiB). The base
172 // here is the tier-0 value; `fast_l1_mls_for_source_size` lowers it per the
173 // tier in `adjust_params_for_source_size`.
174 mls: 7,
175 step_size: 2,
176};
177
178/// Resolved tuning parameters for a compression level and source size. The
179/// [`StrategyTag`] selects the compile-time strategy consts; the runtime
180/// [`BackendTag`] used by the driver dispatcher comes from the resolved
181/// search method ([`Self::backend`]), so an override or a size tier that
182/// changes the search changes the backend with it.
183///
184/// [`StrategyTag`]: crate::encoding::strategy::StrategyTag
185/// [`BackendTag`]: crate::encoding::strategy::BackendTag
186#[derive(Copy, Clone, PartialEq, Eq)]
187pub(crate) struct LevelParams {
188 pub(crate) strategy_tag: crate::encoding::strategy::StrategyTag,
189 /// Decoupled search-method axis. Independent of `strategy_tag`'s
190 /// parse half: a level can pair any parse (greedy / lazy depth via
191 /// `lazy_depth`) with any search backend here. Defaults to the
192 /// historical pairing (`strategy_tag.search()`) but is overridable
193 /// per level so the parse×search matrix can be swept and tuned.
194 pub(crate) search: crate::encoding::strategy::SearchMethod,
195 pub(crate) window_log: u8,
196 pub(crate) lazy_depth: u8,
197 /// Per-strategy tuning. Exactly one is `Some` on each level row, matching
198 /// `strategy_tag`'s backend, so the table self-documents which knobs a
199 /// level actually consumes (the others are `None`, not dead placeholders):
200 /// `fast` for the Fast/Simple backend, `dfast` for Double-Fast, `hc` for
201 /// the HashChain (lazy / btopt / btultra*) backend, `row` for the Row
202 /// (greedy L5) backend.
203 pub(crate) fast: Option<FastConfig>,
204 pub(crate) dfast: Option<DfastConfig>,
205 pub(crate) hc: Option<HcConfig>,
206 pub(crate) row: Option<RowConfig>,
207}
208
209impl LevelParams {
210 /// Backend family (storage variant) for the driver dispatcher.
211 /// Derived from the decoupled `search` axis so a level can route to
212 /// a different search backend than its `strategy_tag` historically
213 /// implied.
214 pub(crate) fn backend(&self) -> crate::encoding::strategy::BackendTag {
215 self.search.backend()
216 }
217
218 /// Parse mode derived from the decoupled `search` axis: the binary-tree
219 /// search path carries `ParseMode::Optimal`; every other search backend
220 /// derives greedy/lazy/lazy2 from `lazy_depth`. Reading `search` (not the
221 /// strategy tag) keeps the parse×search decoupling complete even when a
222 /// level whose tag is `Bt*` is overridden to a non-BT search backend.
223 pub(crate) fn parse(&self) -> crate::encoding::strategy::ParseMode {
224 match self.search {
225 crate::encoding::strategy::SearchMethod::BinaryTree => {
226 crate::encoding::strategy::ParseMode::Optimal
227 }
228 _ => crate::encoding::strategy::ParseMode::from_lazy_depth(self.lazy_depth),
229 }
230 }
231
232 /// Cheap fingerprint pre-splitter level (the `ZSTD_splitBlock` level;
233 /// `0` = from-borders heuristic, `1..=4` = byChunks with sampling tier
234 /// `level - 1`, rates 43 / 11 / 5 / 1). See [`pre_split_for`].
235 pub(crate) fn pre_split(&self) -> Option<u8> {
236 Some(pre_split_for(self.strategy_tag, self.lazy_depth))
237 }
238
239 /// `log2` of how much of a dictionary's end the match-finder tables index
240 /// when it is loaded: a longer dictionary is indexed only that far from its
241 /// end, since tables this size could not keep more of it anyway (upstream
242 /// `ZSTD_loadDictionaryContent`, `zstd_compress.c:4987`:
243 /// `MIN(MAX(hashLog + 3, chainLog + 1), 31)`). Every byte of it stays in
244 /// the window.
245 pub(crate) fn dictionary_reach_log(&self) -> u32 {
246 use crate::encoding::strategy::SearchMethod;
247 // The active backend's config: an override may leave the level row's
248 // own beside it.
249 let logs = match self.search {
250 SearchMethod::Fast => self.fast.map(|fast| (fast.hash_log as usize, 0)),
251 SearchMethod::DoubleFast => self
252 .dfast
253 .map(|dfast| (dfast.long_hash_log as usize, dfast.short_hash_log as usize)),
254 SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
255 self.row.map(|row| (row.hash_bits, row.chain_log))
256 }
257 SearchMethod::BinaryTree => self.hc.map(|hc| (hc.hash_log, hc.chain_log)),
258 };
259 // The reset synthesises the active config when a row lacks it, so a
260 // missing one only means a table-less level: nothing to index.
261 let Some((hash_log, chain_log)) = logs else {
262 return 0;
263 };
264 (hash_log + 3).max(chain_log + 1).min(31) as u32
265 }
266
267 /// The level's upstream `cParams.minMatch`: the shortest match its search
268 /// accepts, and so the shortest piece of a long-distance match a block cut
269 /// leaves worth keeping (`maybeSplitSequence`, `zstd_ldm.c:644`).
270 #[cfg(feature = "ldm")]
271 pub(crate) fn min_match(&self) -> usize {
272 use crate::encoding::strategy::SearchMethod;
273 let configured = match self.search {
274 SearchMethod::Fast => self.fast.map(|fast| fast.mls as usize),
275 SearchMethod::DoubleFast => Some(crate::encoding::match_generator::DFAST_MIN_MATCH_LEN),
276 SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => self.row.map(|row| row.mls),
277 SearchMethod::BinaryTree => self.hc.map(|hc| hc.search_mls),
278 };
279 configured.unwrap_or(crate::encoding::cost_model::HC_FORMAT_MINMATCH)
280 }
281}
282
283/// The pre-splitter level for an effective strategy (the tag plus, for the
284/// collapsed `Lazy` tag, its lazy depth).
285///
286/// Upstream's default is `splitLevels[strategy] = {0,0,1,2,2,3,3,4,4,4}`
287/// (zstd_compress.c:4552, used as is; only an explicit `blockSplitterLevel`
288/// is shifted down by 2). It is followed up to lazy depth 1 — the finer
289/// sampling is a real ratio win on mixed data (decodecorpus at greedy/lazy:
290/// the borders tier compressed 4.6-4.9 % WORSE than upstream, the upstream
291/// rate-11 tier <= upstream) — but the lazy2/btlazy2 rate-5 and optimal-band
292/// rate-1 tiers are DELIBERATELY kept two steps coarser: on periodic input
293/// they over-split every block into tiny pieces exactly like upstream does
294/// (100 MiB of repeated log lines at L8-L12: 140,625 bytes and 3.6x the
295/// time upstream-tier, 9,742 bytes coarse — 14x better than upstream) while
296/// on real data they buy under 0.1 % (decodecorpus L8-L12: 170 bytes of
297/// 483 KiB). The drop-in contract asks for ratio <= upstream, not for
298/// upstream's block boundaries.
299pub(crate) fn pre_split_for(tag: crate::encoding::strategy::StrategyTag, lazy_depth: u8) -> u8 {
300 use crate::encoding::strategy::StrategyTag;
301 match tag {
302 // Upstream tiers: borders / byChunks rate 43 / rate 11.
303 StrategyTag::Fast => 0,
304 StrategyTag::Dfast => 1,
305 StrategyTag::Greedy => 2,
306 // lazy (depth 1) = upstream rate 11; lazy2 coarsened to rate 43.
307 StrategyTag::Lazy => {
308 if lazy_depth >= 2 {
309 1
310 } else {
311 2
312 }
313 }
314 StrategyTag::Btlazy2 => 1,
315 // Coarsened to byChunks rate 11 (upstream: rate 1).
316 StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2 => 2,
317 }
318}
319
320/// Apply the public-parameter per-knob overrides onto the
321/// level-resolved [`LevelParams`], in place. Runs in
322/// [`Matcher::reset`](crate::encoding::Matcher::reset)
323/// after the level params are computed and before backend selection, so
324/// a strategy override re-routes the backend uniformly. An all-`None`
325/// override is a no-op the caller skips via
326/// [`crate::encoding::parameters::ParamOverrides::is_empty`], keeping the default
327/// level geometry byte-identical.
328pub(crate) fn apply_param_overrides(
329 params: &mut LevelParams,
330 ov: &crate::encoding::parameters::ParamOverrides,
331) {
332 use crate::encoding::strategy::SearchMethod;
333
334 // 1. Strategy override re-derives tag / search / lazy depth.
335 if let Some(strategy) = ov.strategy {
336 let tag = strategy.tag();
337 params.strategy_tag = tag;
338 params.search = tag.search();
339 params.lazy_depth = strategy.lazy_depth();
340 }
341
342 // 2. Ensure the active backend's config row exists (synthesize a
343 // default when a strategy override moved off the native row).
344 match params.search {
345 SearchMethod::Fast => {
346 params.fast.get_or_insert(FAST_L1);
347 }
348 SearchMethod::DoubleFast => {
349 params.dfast.get_or_insert(DFAST_L3);
350 }
351 SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
352 // `ROW_L5` already carries `mls = ROW_MIN_MATCH_LEN = 5`, the
353 // upstream `minMatch` of the whole greedy..btlazy2 band at the
354 // >256 KiB tier, so a synthesized btlazy2 override hashes at the
355 // same width as a native btlazy2 row.
356 let row = params.row.get_or_insert(ROW_L5);
357 row.bt = matches!(params.search, SearchMethod::BinaryTreeLazy);
358 }
359 SearchMethod::BinaryTree => {
360 params.hc.get_or_insert(HC_OVERRIDE_DEFAULT);
361 }
362 }
363
364 // 3. window_log (bounds-checked at <= 30 by the builder).
365 if let Some(window_log) = ov.window_log {
366 params.window_log = window_log;
367 }
368
369 // 4. Per-backend numeric knobs map into the active config, mirroring
370 // the upstream zstd `cParams` -> matcher translation documented on each
371 // config struct.
372 match params.search {
373 SearchMethod::Fast => {
374 if let Some(fast) = params.fast.as_mut() {
375 if let Some(hash_log) = ov.hash_log {
376 fast.hash_log = hash_log;
377 }
378 if let Some(min_match) = ov.min_match {
379 fast.mls = fast_key_len(min_match);
380 }
381 // targetLength is the Fast strategy's step, as it is on the
382 // level's own row: upstream zstd_fast.c `stepSize =
383 // targetLength + !targetLength + 1`.
384 if let Some(target_length) = ov.target_length {
385 fast.step_size = (target_length as usize).max(1) + 1;
386 }
387 }
388 }
389 SearchMethod::DoubleFast => {
390 if let Some(dfast) = params.dfast.as_mut() {
391 // hashLog -> long table, chainLog -> short table (the
392 // dfast secondary index). Both bounds-checked <= 30, so
393 // the `u8` casts are lossless.
394 if let Some(hash_log) = ov.hash_log {
395 dfast.long_hash_log = hash_log as u8;
396 }
397 if let Some(chain_log) = ov.chain_log {
398 dfast.short_hash_log = chain_log as u8;
399 }
400 }
401 }
402 SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => {
403 if let Some(row) = params.row.as_mut() {
404 // Row hash-table width override (mirrors dfast `long_hash_log`
405 // / hc `hash_log`); `chain_log` sizes the hash chain the same
406 // backend searches on a <= 2^14 window, or its binary tree.
407 if let Some(hash_log) = ov.hash_log {
408 row.hash_bits = hash_log as usize;
409 }
410 if let Some(chain_log) = ov.chain_log {
411 row.chain_log = chain_log as usize;
412 }
413 if let Some(search_log) = ov.search_log {
414 // Upstream zstd: rowLog = clamp(searchLog, 4, 6); the
415 // compare budget stays the FULL `1 << searchLog`
416 // (`nbAttempts`) — the chain walk and the tree walk
417 // consume it directly, and the row probe bounds its own
418 // budget by the row size at the search site.
419 row.row_log = (search_log as usize).clamp(4, 6);
420 row.search_depth = 1usize << search_log;
421 }
422 if let Some(target_length) = ov.target_length {
423 row.target_len = target_length as usize;
424 }
425 if let Some(min_match) = ov.min_match {
426 row.mls = min_match as usize;
427 }
428 row.match_finder = ov.row_match_finder;
429 }
430 }
431 SearchMethod::BinaryTree => {
432 if let Some(hc) = params.hc.as_mut() {
433 if let Some(hash_log) = ov.hash_log {
434 hc.hash_log = hash_log as usize;
435 }
436 if let Some(chain_log) = ov.chain_log {
437 hc.chain_log = chain_log as usize;
438 }
439 if let Some(search_log) = ov.search_log {
440 hc.search_depth = 1usize << search_log;
441 }
442 if let Some(target_length) = ov.target_length {
443 hc.target_len = target_length as usize;
444 }
445 if let Some(min_match) = ov.min_match {
446 // A `minMatch` of 3 asked for explicitly keys the tree on
447 // three bytes (see `HcConfig::search_mls`).
448 hc.search_mls = (min_match as usize).clamp(3, 6);
449 }
450 }
451 }
452 }
453}
454
455/// The key width the fast strategy hashes for a `minMatch`: upstream's fast
456/// block compressor takes 3 as 4 (zstd_fast.c, `ZSTD_compressBlock_fast`:
457/// `default: /* includes case 3 */`). A 3 reaches the fast strategy from the
458/// knob, or from an optimal level's CDict row a strategy knob moved onto it.
459fn fast_key_len(min_match: u32) -> u32 {
460 min_match.max(4)
461}
462
463/// Map the resolved runtime strategy to the upstream zstd LDM strategy ordinal
464/// (1..=9) that [`crate::encoding::ldm::params::LdmParams::adjust_for`] expects.
465/// The collapsed `Lazy` tag splits on `lazy_depth` (lazy = 4, lazy2 = 5).
466#[cfg(feature = "ldm")]
467pub(crate) fn ldm_strategy_ordinal(
468 tag: crate::encoding::strategy::StrategyTag,
469 lazy_depth: u8,
470) -> u32 {
471 use crate::encoding::strategy::StrategyTag;
472 match tag {
473 StrategyTag::Fast => 1,
474 StrategyTag::Dfast => 2,
475 StrategyTag::Greedy => 3,
476 StrategyTag::Lazy => {
477 if lazy_depth >= 2 {
478 5
479 } else {
480 4
481 }
482 }
483 // Upstream zstd `ZSTD_btlazy2` ordinal.
484 StrategyTag::Btlazy2 => 6,
485 StrategyTag::BtOpt => 7,
486 StrategyTag::BtUltra => 8,
487 StrategyTag::BtUltra2 => 9,
488 }
489}
490
491/// `ceil(log2(size))` of a source-size hint, with a zero hint floored to
492/// [`MIN_WINDOW_LOG`]. This is the single quantization every hint-dependent
493/// matcher parameter is derived from: the window-log cap, the HC / Fast hash
494/// and chain widths, the Dfast / Row table widths, the L22 config buckets, and
495/// the Fast attach-vs-copy cutoff. Two hints sharing this value resolve to the
496/// identical matcher shape, which is why it (not the raw byte count) keys the
497/// primed-dictionary snapshot — see `match_generator::PrimedKey`. Operates on the full `u64`
498/// so callers comparing a hint against a cutoff get the same bucketed decision
499/// here and at the driver, with no `as usize` truncation on 32-bit targets.
500pub(crate) fn source_size_ceil_log(size: u64) -> u8 {
501 if size == 0 {
502 MIN_WINDOW_LOG
503 } else {
504 (64 - (size - 1).leading_zeros()) as u8
505 }
506}
507
508/// Attach-vs-copy cutoff for the Fast strategy, as a ceil-log bucket: a hint at
509/// or below `2^this` (or unknown, `None`) ATTACHES the dictionary (a separate
510/// immutable table scanned in place via the borrowed dual-base kernel); a larger
511/// hint would COPY it into the live table.
512///
513/// `13` is upstream zstd's Fast cutoff (`attachDictSizeCutoffs[ZSTD_fast]` is
514/// 8 KB, zstd_compress.c:2296; `ZSTD_shouldAttachDict`, :2309). Above it the
515/// dictionary is COPIED, and the copy is what makes the dictionary pay on a
516/// larger source: the scan then runs over a table already holding the
517/// dictionary's positions, so ordinary NEAR matches improve everywhere. Attach
518/// mode starts with an empty table and reaches the dictionary only through the
519/// separate exact table, at the positions the step happens to land on.
520///
521/// This was `31` (attach every source up to 2 GiB) on a speed argument alone,
522/// and the missing byte column is where it went wrong. Per frame, `z000033`
523/// (1,022,035 B) and its leading 10 KiB, with a 16 KiB dictionary trained over
524/// its 10 KiB chunks, on the i9: two prebuilt binaries and libzstd alternated
525/// in one session, `perf stat -r 3`, three rounds, ranges non-overlapping.
526///
527/// | case | bytes attach | bytes copy | reference | cycles attach | cycles copy | insn attach | insn copy |
528/// |---|---|---|---|---|---|---|---|
529/// | 10 KiB, L1 | 6,976 | 7,122 | 7,122 | 236,355 (1.95x) | 169,116 (1.39x) | 633,127 (1.78x) | 478,066 (1.34x) |
530/// | 10 KiB, L-5 | 9,660 | 9,124 | 9,130 | 51,678 (1.22x) | 56,444 (1.33x) | 152,281 (1.14x) | 153,369 (1.15x) |
531/// | 1 MiB, L1 | 550,810 | 551,584 | 570,765 | 19.84M (1.76x) | 16.21M (1.44x) | 54.01M (1.91x) | 39.60M (1.40x) |
532/// | 1 MiB, L-5 | 689,127 | 647,735 | 669,826 | 8.80M (1.46x) | 10.67M (1.77x) | 21.62M (1.54x) | 22.81M (1.63x) |
533///
534/// Copy is the better arm on both axes at the positive levels: it takes 18-28%
535/// fewer cycles and 22-27% fewer instructions, and its bytes are the
536/// reference's exactly on the small frame and 3.4% under the reference on the
537/// large one. At the ultra-fast levels it buys ratio with time: 21% more cycles
538/// on the 1 MiB frame for 6.0% fewer bytes, 9% more on the small one for 5.5%
539/// fewer. That trade is what the cutoff is for. Attach put us 2.9% ABOVE the
540/// reference on the 1 MiB ultra-fast frame — the dictionary made our frame
541/// bigger than our own no-dict frame there, while it made the reference's
542/// smaller — because it found 21,897 sequences where the reference finds
543/// 27,546. Copy puts us 3.3% under it.
544///
545/// The remaining gap is now a same-mode one: the reference does this copy in
546/// 1.0x where we take 1.3-1.8x, which is a target with an apples-to-apples
547/// reference rather than a mode the reference never runs.
548///
549/// The borrowed attach kernel stores virtual positions as `u32`
550/// (`cur_abs as u32`), so it could not attach past bucket `31` regardless; that
551/// ceiling is now far above the cutoff and no longer the binding constraint.
552/// Shared by `reset` (records the mode in the primed-snapshot key) and
553/// `prime_with_dictionary` (acts on it).
554pub(crate) const FAST_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
555
556/// Whether a dictionary frame in size bucket `size_log` reuses the captured
557/// primed snapshot rather than re-priming: only a known size above the
558/// strategy's attach cutoff `cutoff_log`, the upstream zstd copy regime
559/// (`ZSTD_shouldAttachDict`). The frame compressor restores on this and the
560/// Fast reset keeps its table only on it, so the two cannot disagree.
561pub(crate) fn restores_primed_snapshot(size_log: Option<u8>, cutoff_log: u8) -> bool {
562 size_log.is_some_and(|log| log > cutoff_log)
563}
564
565/// Largest dictionary region (bytes) the Fast attach path can index. The tagged
566/// dict table packs each position into `32 - DICT_TAG_BITS` (= 24) bits, so a
567/// region past `2^24` (16 MiB) would overflow the packed position. Dictionaries
568/// this large fall back to COPY mode, whose live table stores full `u32`
569/// positions and handles them. The size hint set on dict load equals the actual
570/// dict content length, so the attach-vs-copy decision (and the matching
571/// snapshot-key / epoch bits) can gate on it consistently at reset time.
572pub(crate) const MAX_FAST_ATTACH_DICT_REGION: usize = 1 << 24;
573
574/// Dfast counterpart of [`FAST_ATTACH_DICT_CUTOFF_LOG`]: upstream zstd
575/// `ZSTD_dictMatchState` attach cutoff for the double-fast strategy is 16 KiB
576/// (`2^14`), so small / unknown-size inputs ATTACH (separate immutable dict
577/// long+short tables + dual-probe in `start_matching_fast_loop`) and larger
578/// known-size inputs COPY (re-prime the dict into the live tables, where the
579/// dense scan matches it as window history). The attach build also self-gates
580/// on `use_fast_loop` inside `skip_matching_for_dict_attach` — only the
581/// fast-loop levels (L3 / Default / L0) carry the dual-probe.
582pub(crate) const DFAST_ATTACH_DICT_CUTOFF_LOG: u8 = 14;
583
584/// BT/optimal attach cutoff for `btlazy2` + `btopt`: 32 KiB (`2^15`, upstream
585/// zstd `attachDictSizeCutoffs[ZSTD_btlazy2]` == `[ZSTD_btopt]`). Small /
586/// unknown-size inputs ATTACH the dict as a separate DUBT dms; larger known-size
587/// inputs COPY the dict into the LIVE binary tree (upstream zstd
588/// `ZSTD_resetCCtx_byCopyingCDict`).
589pub(crate) const BT_OPT_ATTACH_DICT_CUTOFF_LOG: u8 = 15;
590
591/// BT/optimal attach cutoff for `btultra` + `btultra2`: 8 KiB (`2^13`, upstream
592/// zstd `attachDictSizeCutoffs[ZSTD_btultra]` == `[ZSTD_btultra2]`). The deepest
593/// parses copy the dict into the live tree past a much smaller source than the
594/// `btopt` tier, matching upstream's per-strategy cutoff table.
595pub(crate) const BT_ULTRA_ATTACH_DICT_CUTOFF_LOG: u8 = 13;
596
597// Source-size cap for the dfast hash bits when a size hint is present: a tiny
598// input needs no larger hash than its window. The upstream zstd `cParams.hashLog` /
599// `chainLog` (from `DfastConfig`) caps it from above at the call site.
600pub(crate) fn dfast_hash_bits_for_window(max_window_size: usize) -> usize {
601 let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
602 window_log.max(MIN_WINDOW_LOG as usize)
603}
604
605pub(crate) fn row_hash_bits_for_window(max_window_size: usize) -> usize {
606 // Upstream zstd `ZSTD_adjustCParams_internal` cap: `hashLog <= windowLog + 1`.
607 // The `+ 1` is load-bearing for L12, whose upstream zstd hashLog (23) exceeds
608 // its windowLog (22) — a plain `windowLog` cap would shrink the L12
609 // table on EVERY hinted reset and split primed snapshots between
610 // hinted and unhinted frames that resolve to the identical geometry.
611 // No constant upper clamp: the old `ROW_HASH_BITS` (20) ceiling
612 // predates the lazy band moving onto Row (L9-12 carry upstream zstd hashLog
613 // 21-23).
614 let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
615 (window_log + 1).max(MIN_WINDOW_LOG as usize)
616}
617
618/// `floor(log2(window))` for the HashChain table-log cap (upstream zstd
619/// `ZSTD_adjustCParams_internal`). The caller clamps the level's `hash_log` /
620/// `chain_log` from above with this so a small hinted input doesn't allocate the
621/// full level's tables.
622pub(crate) fn hc_hash_bits_for_window(max_window_size: usize) -> usize {
623 let window_log = (usize::BITS - 1 - max_window_size.leading_zeros()) as usize;
624 window_log.max(MIN_WINDOW_LOG as usize)
625}
626
627/// Smallest window_log the encoder will use regardless of source size.
628pub(crate) const MIN_WINDOW_LOG: u8 = 10;
629
630/// Largest window_log the public parameter API accepts
631/// ([`CParameter::WindowLog`](crate::encoding::CParameter)'s upper bound), and
632/// so the largest a workspace estimate can be asked to describe. The estimate
633/// answers for whatever it is given, and shifting by more than the width of a
634/// `usize` is undefined, so a request beyond this is answered with this.
635pub(crate) const MAX_ESTIMATED_WINDOW_LOG: u8 = 30;
636
637/// Translate a verbatim upstream `ZSTD_defaultCParameters[tier][level]` row
638/// (`cparams::CParams`) into our resolved [`LevelParams`], reproducing
639/// upstream's cParams -> matcher-config derivation so the encoder follows C's
640/// source-size-tiered STRATEGY + table widths rather than a single hand-tuned
641/// `LEVEL_TABLE`. Derivation (verified against L6/L16/L22 vs clevels.h):
642/// `search_depth = 1 << searchLog`; row `row_log = clamp(searchLog, 4, 6)`; the
643/// per-strategy sub-config carries the verbatim `hashLog` / `chainLog` /
644/// `targetLength` / `minMatch`. Strategy numbers are upstream `ZSTD_strategy`
645/// (fast=1, dfast=2, greedy=3, lazy=4, lazy2=5, btlazy2=6, btopt=7, btultra=8,
646/// btultra2=9).
647fn level_params_from_cparams(cp: crate::encoding::cparams::CParams) -> LevelParams {
648 use crate::encoding::strategy::{SearchMethod, StrategyTag};
649 let window_log = cp.window_log as u8;
650 let search_depth = 1usize << cp.search_log;
651 let target_len = cp.target_length as usize;
652 let hc = HcConfig {
653 hash_log: cp.hash_log as usize,
654 chain_log: cp.chain_log as usize,
655 search_depth,
656 target_len,
657 // See `HcConfig::search_mls`: a `minMatch` of 3 hashes four bytes.
658 search_mls: cp.min_match.clamp(4, 6) as usize,
659 };
660 let row = RowConfig {
661 hash_bits: cp.hash_log as usize,
662 row_log: cp.search_log.clamp(4, 6) as usize,
663 search_depth,
664 target_len,
665 mls: cp.min_match as usize,
666 chain_log: cp.chain_log as usize,
667 // Upstream `ZSTD_btlazy2` (strategy 6).
668 bt: cp.strategy == 6,
669 match_finder: crate::encoding::parameters::RowMatchFinderMode::Auto,
670 };
671 let bt = |tag| LevelParams {
672 strategy_tag: tag,
673 search: SearchMethod::BinaryTree,
674 window_log,
675 lazy_depth: 2,
676 fast: None,
677 dfast: None,
678 hc: Some(hc),
679 row: None,
680 };
681 let row_lvl = |tag, search, lazy_depth| LevelParams {
682 strategy_tag: tag,
683 search,
684 window_log,
685 lazy_depth,
686 fast: None,
687 dfast: None,
688 hc: None,
689 row: Some(row),
690 };
691 match cp.strategy {
692 1 => LevelParams {
693 strategy_tag: StrategyTag::Fast,
694 search: SearchMethod::Fast,
695 window_log,
696 lazy_depth: 0,
697 // Upstream fast `stepSize`: `targetLength + 1` (0 -> 1, so step 2).
698 fast: Some(FastConfig {
699 hash_log: cp.hash_log,
700 mls: fast_key_len(cp.min_match),
701 step_size: target_len.max(1) + 1,
702 }),
703 dfast: None,
704 hc: None,
705 row: None,
706 },
707 2 => LevelParams {
708 strategy_tag: StrategyTag::Dfast,
709 search: SearchMethod::DoubleFast,
710 window_log,
711 lazy_depth: 1,
712 fast: None,
713 dfast: Some(DfastConfig {
714 long_hash_log: cp.hash_log as u8,
715 short_hash_log: cp.chain_log as u8,
716 }),
717 hc: None,
718 row: None,
719 },
720 3 => row_lvl(StrategyTag::Greedy, SearchMethod::RowHash, 0),
721 4 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 1),
722 5 => row_lvl(StrategyTag::Lazy, SearchMethod::RowHash, 2),
723 // btlazy2: the same lazy2 parse over the lazily-sorted binary tree.
724 6 => row_lvl(StrategyTag::Btlazy2, SearchMethod::BinaryTreeLazy, 2),
725 7 => bt(StrategyTag::BtOpt),
726 8 => bt(StrategyTag::BtUltra),
727 _ => bt(StrategyTag::BtUltra2),
728 }
729}
730
731/// Down-size a synthesized backend's window / hash / chain logs for a known
732/// source size by routing through the single C-faithful adjuster
733/// [`adjust_cparams`](crate::encoding::cparams::adjust_cparams)
734/// (`ZSTD_adjustCParams_internal`).
735///
736/// Used only on the param-override re-cap path: [`apply_param_overrides`]
737/// synthesizes a backend's full-size default config when a strategy override
738/// moves off the native level row, and this re-applies the source-size cap
739/// C-faithfully — the SAME adjuster the `get_cparams` main path uses, so the
740/// override path and the level path now down-size identically (no extra hinted
741/// 16 KiB window floor, no per-backend headroom that diverged from C). The
742/// Dfast backend self-sizes its tables in `reset`, so only its window is capped.
743pub(crate) fn adjust_params_for_source_size(mut params: LevelParams, src_size: u64) -> LevelParams {
744 use crate::encoding::cparams::{CParams, adjust_cparams};
745 use crate::encoding::strategy::{BackendTag, StrategyTag};
746
747 let backend = params.backend();
748 // Lift the active backend's source-cappable logs into a flat CParams. Dfast
749 // contributes none (window-only); its table widths self-size in `reset`.
750 let (hash_log, chain_log): (u32, u32) = match backend {
751 BackendTag::Simple => (params.fast.as_ref().map_or(0, |f| f.hash_log), 0),
752 BackendTag::HashChain => params
753 .hc
754 .as_ref()
755 .map_or((0, 0), |h| (h.hash_log as u32, h.chain_log as u32)),
756 BackendTag::Row => params
757 .row
758 .as_ref()
759 .map_or((0, 0), |r| (r.hash_bits as u32, r.chain_log as u32)),
760 BackendTag::Dfast => (0, 0),
761 };
762 // `adjust_cparams` reads the strategy for the chain cap (`ZSTD_cycleLog`,
763 // btlazy2 and up) and for the row-hash cap (greedy to lazy2, with the
764 // search log as the row log), so it gets the real class and search log;
765 // `Lazy` stands for lazy and lazy2, which both caps treat alike.
766 let strategy = match params.strategy_tag {
767 StrategyTag::Fast => 1,
768 StrategyTag::Dfast => 2,
769 StrategyTag::Greedy => 3,
770 StrategyTag::Lazy => 4,
771 StrategyTag::Btlazy2 => 6,
772 StrategyTag::BtOpt => 7,
773 StrategyTag::BtUltra => 8,
774 StrategyTag::BtUltra2 => 9,
775 };
776 // `search_depth` is `1 << searchLog` on both backends that search.
777 let search_log = match backend {
778 BackendTag::HashChain => params
779 .hc
780 .as_ref()
781 .map_or(1, |h| h.search_depth.trailing_zeros()),
782 BackendTag::Row => params
783 .row
784 .as_ref()
785 .map_or(1, |r| r.search_depth.trailing_zeros()),
786 BackendTag::Simple | BackendTag::Dfast => 1,
787 };
788 let adj = adjust_cparams(
789 CParams {
790 window_log: u32::from(params.window_log),
791 chain_log,
792 hash_log,
793 search_log,
794 min_match: 4,
795 target_length: 0,
796 strategy,
797 },
798 src_size,
799 0,
800 false,
801 );
802 params.window_log = adj.window_log as u8;
803 match backend {
804 BackendTag::Simple => {
805 if let Some(f) = params.fast.as_mut() {
806 f.hash_log = adj.hash_log;
807 }
808 }
809 BackendTag::HashChain => {
810 if let Some(h) = params.hc.as_mut() {
811 h.hash_log = adj.hash_log as usize;
812 h.chain_log = adj.chain_log as usize;
813 }
814 }
815 BackendTag::Row => {
816 if let Some(r) = params.row.as_mut() {
817 r.hash_bits = adj.hash_log as usize;
818 r.chain_log = adj.chain_log as usize;
819 }
820 }
821 BackendTag::Dfast => {}
822 }
823 params
824}
825
826/// Apply the caller's parameter overrides to the level params a frame
827/// resolved to: the step the matcher's reset takes after the level (and a
828/// dictionary's CDict tier) is resolved, kept in one place so the workspace
829/// estimate builds exactly what the encoder does. An all-`None` set leaves the
830/// params untouched, which keeps plain level-based geometry byte-identical.
831pub(crate) fn apply_frame_overrides(
832 params: &mut LevelParams,
833 ov: &crate::encoding::parameters::ParamOverrides,
834 dictionary_frame: bool,
835 hint: Option<u64>,
836) {
837 if ov.is_empty() {
838 return;
839 }
840 if dictionary_frame {
841 // A dictionary frame runs the CDict's cParams (upstream
842 // `ZSTD_resetCCtx_byAttachingCDict` / `byCopyingCDict`), and the
843 // search knobs are already part of them
844 // (`resolve_level_params_with_dict`); the frame's own knob is the
845 // window.
846 //
847 // The window still answers to the source, as it does for every
848 // other frame: `ZSTD_adjustCParams_internal` caps it by the source
849 // and dictionary extent, and a window neither can fill only makes
850 // decoders reserve memory the frame never uses. Capped here rather
851 // than through the full adjuster, which would reshape the search.
852 if let Some(window_log) = ov.window_log {
853 params.window_log = match hint {
854 // The source caps the window even here, and even with an
855 // explicit request: the reference command declares 2 KiB
856 // for `--ultra -22 --long=27 -D dict` on a 2 KiB file, and
857 // a window the content cannot fill only makes every decoder
858 // reserve memory the frame never uses. The floor that
859 // travels with the cap in `adjust_cparams` applies too, or
860 // a hint of a few dozen bytes asks for a window smaller
861 // than the format's smallest.
862 //
863 // The dictionary's own size is NOT part of that cap: the
864 // reference declares the same 2 KiB whether the dictionary
865 // is 4 KiB or 256 KiB, because a small window does not put
866 // the dictionary out of reach: sequences may reference it
867 // at offsets beyond the window while the output so far is
868 // within it (RFC 8878, Dictionary_Content). Counting it
869 // made our frames ask decoders for up to 256x what the
870 // reference asks.
871 Some(src) => {
872 (crate::encoding::cparams::adjusted_window_log(u32::from(window_log), src, 0)
873 as u8)
874 .max(MIN_WINDOW_LOG)
875 }
876 None => window_log,
877 };
878 }
879 } else {
880 apply_param_overrides(params, ov);
881 // The level's own resolution applied the source-size cap for the
882 // LEVEL's native backend. If a strategy override moved the frame
883 // onto a different backend, `apply_param_overrides` synthesized that
884 // backend's DEFAULT config (FAST_L1 / HC_OVERRIDE_DEFAULT) with
885 // full-size table logs AFTER that cap ran. Re-apply the hint cap so a
886 // tiny hinted frame doesn't allocate the new backend's full-size
887 // tables.
888 //
889 // The cap covers an explicit `window_log` too, as
890 // `ZSTD_adjustCParams_internal` does upstream: the window is a
891 // promise about the memory decoding will need, and a source that
892 // cannot fill it makes that promise for nothing: every decoder
893 // opening the frame would reserve the whole declared window to read
894 // a few bytes. The override still raises the window as far as the
895 // source can use.
896 if let Some(hint_size) = hint {
897 *params = adjust_params_for_source_size(*params, hint_size);
898 }
899 }
900}
901
902/// The long-distance matcher's parameters for a frame: the caller-pinned knobs
903/// seeded first, then the upstream derivation fills the rest so the set stays
904/// consistent (`hash_rate_log = window_log - hash_log`, and so on); clobbering
905/// after the derivation would hand the producer an inconsistent set. Shared by
906/// the matcher's reset and the workspace estimate.
907#[cfg(feature = "ldm")]
908pub(crate) fn frame_ldm_params(
909 params: &LevelParams,
910 ldm: &crate::encoding::parameters::LdmOverride,
911) -> crate::encoding::ldm::params::LdmParams {
912 let seed = crate::encoding::ldm::params::LdmParams {
913 window_log: params.window_log as u32,
914 hash_log: ldm.hash_log.unwrap_or(0),
915 hash_rate_log: ldm.hash_rate_log.unwrap_or(0),
916 min_match_length: ldm.min_match.unwrap_or(0),
917 bucket_size_log: ldm.bucket_size_log.unwrap_or(0),
918 };
919 seed.derive(ldm_strategy_ordinal(params.strategy_tag, params.lazy_depth))
920}
921
922/// Estimated steady-state heap footprint of a one-shot compression context
923/// at `level` (window history + match-finder tables + block staging), in
924/// bytes. Computed from the same per-level tuning table the encoder
925/// resolves at frame start, so the estimate tracks the real allocations;
926/// it is an upper-bound style budget figure, not an exact accounting.
927pub fn estimated_compression_workspace_bytes(level: CompressionLevel) -> usize {
928 estimated_compression_workspace_bytes_for_source(level, None)
929}
930
931/// The same estimate for a source whose size is known.
932///
933/// The window and the match-finder tables are capped by the source, so a level
934/// that would reserve hundreds of MiB for an arbitrary stream reserves a
935/// fraction of that for a small one — and a caller budgeting memory for a
936/// compression it is about to run knows which. `None` is the arbitrary-stream
937/// figure that [`estimated_compression_workspace_bytes`] reports.
938pub fn estimated_compression_workspace_bytes_for_source(
939 level: CompressionLevel,
940 src_size_hint: Option<u64>,
941) -> usize {
942 estimated_compression_workspace_bytes_for_run(level, src_size_hint, None, false, None)
943}
944
945/// The same estimate for a run whose window, long-distance matching and
946/// dictionary are what the caller has actually asked for.
947///
948/// A `window_log` override enlarges the history the frame keeps, and
949/// long-distance matching adds a hash table of its own on top — neither of them
950/// visible in the level's own preset. A dictionary goes further than adding to
951/// the figure: it *decides* it. The frame runs the dictionary's own compression
952/// parameters, so a small source compressed against a large dictionary builds
953/// tables sized for the dictionary, which at the higher levels is the
954/// difference between tens of KiB and hundreds of MiB. `None`, `false` and
955/// `None` give the preset, which is what
956/// [`estimated_compression_workspace_bytes_for_source`] reports.
957///
958/// `dictionary` is what a caller weighing a run before it parses the blob can
959/// answer with [`DictionarySizes::raw_content`] on the blob's own length: the
960/// content of a trained dictionary is smaller than the blob it came in, and
961/// overstating it can only move the estimate toward the copy-mode geometry,
962/// which is the larger of the two.
963///
964/// [`DictionarySizes::raw_content`]: crate::encoding::DictionarySizes::raw_content
965pub fn estimated_compression_workspace_bytes_for_run(
966 level: CompressionLevel,
967 src_size_hint: Option<u64>,
968 window_log: Option<u8>,
969 long_distance_matching: bool,
970 dictionary: Option<crate::encoding::DictionarySizes>,
971) -> usize {
972 let mut params = match dictionary.filter(|sizes| sizes.content != 0) {
973 Some(sizes) => {
974 resolve_level_params_with_dict(
975 level,
976 src_size_hint,
977 sizes,
978 &crate::encoding::parameters::ParamOverrides::default(),
979 )
980 .0
981 }
982 None => resolve_level_params(level, src_size_hint),
983 };
984 // The override is what the frame will keep, but never below the floor the
985 // format sets or above what the source can fill — the same two bounds the
986 // encoder applies to it.
987 if let Some(requested) = window_log {
988 // Bounded to what the encoder itself accepts before anything shifts by
989 // it. This answers for whatever it is asked, and a shift past the width
990 // of the type is undefined rather than merely large: the largest window
991 // there is, is the honest answer to a request beyond it.
992 let requested = requested.min(MAX_ESTIMATED_WINDOW_LOG);
993 let capped = match src_size_hint {
994 Some(src) => {
995 crate::encoding::cparams::adjusted_window_log(u32::from(requested), src, 0) as u8
996 }
997 None => requested,
998 };
999 params.window_log = capped.clamp(MIN_WINDOW_LOG, MAX_ESTIMATED_WINDOW_LOG);
1000 }
1001 // The long-distance matcher's own table, sized from the window it searches
1002 // (upstream `ZSTD_ldm_adjustParameters`). Only the `ldm` build has one.
1003 #[cfg(feature = "ldm")]
1004 let ldm = if long_distance_matching {
1005 let strategy = ldm_strategy_ordinal(params.strategy_tag, params.lazy_depth);
1006 crate::encoding::ldm::estimated_workspace_bytes(
1007 &crate::encoding::ldm::params::LdmParams::adjust_for(
1008 u32::from(params.window_log),
1009 strategy,
1010 ),
1011 )
1012 } else {
1013 0
1014 };
1015 #[cfg(not(feature = "ldm"))]
1016 let ldm = {
1017 let _ = long_distance_matching;
1018 0
1019 };
1020 workspace_bytes(¶ms, ldm)
1021}
1022
1023/// The level params a frame resolves to under `parameters`, for a source of
1024/// `src_size_hint` bytes and an optional dictionary: the level (or the
1025/// dictionary's tier), then the caller's overrides, as the matcher's reset
1026/// resolves them.
1027pub(crate) fn frame_level_params(
1028 parameters: &crate::encoding::CompressionParameters,
1029 src_size_hint: Option<u64>,
1030 dictionary: Option<crate::encoding::DictionarySizes>,
1031) -> LevelParams {
1032 let level = parameters.level();
1033 let overrides = parameters.overrides();
1034 let dictionary = dictionary.filter(|sizes| sizes.content != 0);
1035 let mut params = match dictionary {
1036 Some(sizes) => resolve_level_params_with_dict(level, src_size_hint, sizes, &overrides).0,
1037 None => resolve_level_params(level, src_size_hint),
1038 };
1039 apply_frame_overrides(&mut params, &overrides, dictionary.is_some(), src_size_hint);
1040 params
1041}
1042
1043/// The workspace estimate for a frame run under `parameters`: the level, every
1044/// knob that overrides it, the source size and the dictionary, resolved the way
1045/// the encoder resolves them at frame start.
1046///
1047/// [`estimated_compression_workspace_bytes_for_run`] answers for a level with a
1048/// window and long-distance matching on top; a caller that also sets `hashLog`,
1049/// `chainLog`, a strategy or the long-distance matcher's own table sizes needs
1050/// this one, since each of those resizes what the frame allocates. A dictionary
1051/// frame runs the geometry the dictionary is prepared with, the knobs included,
1052/// as the encoder does.
1053///
1054/// # Examples
1055///
1056/// ```
1057/// use structured_zstd::encoding::{
1058/// estimated_compression_workspace_bytes_for_parameters, CompressionLevel,
1059/// CompressionParameters,
1060/// };
1061///
1062/// let level = CompressionParameters::builder(CompressionLevel::Level(3)).build().unwrap();
1063/// let wide = CompressionParameters::builder(CompressionLevel::Level(3))
1064/// .window_log(27)
1065/// .hash_log(24)
1066/// .build()
1067/// .unwrap();
1068/// let source = Some(512 << 20);
1069/// assert!(
1070/// estimated_compression_workspace_bytes_for_parameters(&wide, source, None)
1071/// > estimated_compression_workspace_bytes_for_parameters(&level, source, None)
1072/// );
1073/// ```
1074pub fn estimated_compression_workspace_bytes_for_parameters(
1075 parameters: &crate::encoding::CompressionParameters,
1076 src_size_hint: Option<u64>,
1077 dictionary: Option<crate::encoding::DictionarySizes>,
1078) -> usize {
1079 let params = frame_level_params(parameters, src_size_hint, dictionary);
1080 #[cfg(feature = "ldm")]
1081 let ldm = parameters.overrides().ldm.map_or(0, |ldm| {
1082 crate::encoding::ldm::estimated_workspace_bytes(&frame_ldm_params(¶ms, &ldm))
1083 });
1084 #[cfg(not(feature = "ldm"))]
1085 let ldm = 0;
1086 workspace_bytes(¶ms, ldm)
1087}
1088
1089/// `entry` bytes for each of `1 << log` slots, pinned at `usize::MAX` where
1090/// the table is more than a `usize` counts: the estimate is a budget, and a
1091/// figure that wrapped would call an impossible table affordable.
1092fn table_bytes(entry: usize, log: usize) -> usize {
1093 u32::try_from(log)
1094 .ok()
1095 .and_then(|log| 1usize.checked_shl(log))
1096 .and_then(|slots| slots.checked_mul(entry))
1097 .unwrap_or(usize::MAX)
1098}
1099
1100/// Everything a compressor keeps for a frame resolved to `params`, plus `ldm`
1101/// bytes of long-distance table: the workspace (history, match-finder tables
1102/// and block buffers, laid out by the same functions the reset uses), and
1103/// what lives beside it (optimal-parser scratch, entropy tables, the block
1104/// splitter's state, the window's block deque).
1105fn workspace_bytes(params: &LevelParams, ldm: usize) -> usize {
1106 use crate::encoding::strategy::{SearchMethod, StrategyTag};
1107 use crate::encoding::workspace::{ALIGN, region_bytes};
1108 // A 30-bit window is a gibibyte, which a 32-bit `usize` cannot count: the
1109 // widest window is more memory than such a machine has, so the figure is
1110 // pinned rather than wrapped.
1111 let window = 1usize
1112 .checked_shl(u32::from(params.window_log))
1113 .unwrap_or(usize::MAX);
1114 let block = (crate::common::MAX_BLOCK_SIZE as usize).min(window);
1115 let backend = params.backend();
1116 let history = crate::encoding::match_generator::history_ceiling(backend, window, 0, block);
1117 // Mirror `configure()`: the HC3 short-match side table exists only on
1118 // the btultra/btultra2 tags (minMatch 3), capped by the window log; the
1119 // BT pointer-pair layout fits inside the `4 << chain_log` chain term
1120 // (pairs over `chain_log - 1` nodes).
1121 let wants_hash3 = matches!(
1122 params.strategy_tag,
1123 StrategyTag::BtUltra | StrategyTag::BtUltra2
1124 );
1125 let uses_bt = matches!(
1126 params.strategy_tag,
1127 StrategyTag::BtOpt | StrategyTag::BtUltra | StrategyTag::BtUltra2
1128 );
1129 // Only the backend `params.search` selects is built: `reset` swaps in one
1130 // matcher storage per frame. A strategy override leaves the level's own
1131 // row in place beside the one it synthesized, so summing every populated
1132 // config would charge a frame for tables it never allocates.
1133 // Every term goes through `table_bytes` and every sum saturates: an
1134 // override can ask for a table past what a 32-bit `usize` counts, and a
1135 // shift that dropped its high bits would report that table as free.
1136 let tables = match params.search {
1137 SearchMethod::Fast => params
1138 .fast
1139 .map_or(0, |f| table_bytes(4, f.hash_log as usize)),
1140 SearchMethod::DoubleFast => params.dfast.map_or(0, |d| {
1141 table_bytes(4, usize::from(d.long_hash_log))
1142 .saturating_add(table_bytes(4, usize::from(d.short_hash_log)))
1143 }),
1144 // The lazy backend's chain / tree finders use a plain hash table
1145 // (`4 << hash_bits`) plus the chain / tree table (`4 << chain_log`)
1146 // instead of the row tables.
1147 SearchMethod::RowHash | SearchMethod::BinaryTreeLazy => params.row.map_or(0, |r| {
1148 if !r.searches_rows(window) {
1149 table_bytes(4, r.hash_bits).saturating_add(table_bytes(4, r.chain_log))
1150 } else {
1151 table_bytes(4, r.hash_bits).saturating_add(table_bytes(2, r.hash_bits))
1152 }
1153 }),
1154 SearchMethod::BinaryTree => params.hc.map_or(0, |h| {
1155 let hash3 = if wants_hash3 {
1156 table_bytes(
1157 4,
1158 crate::encoding::match_table::storage::HC3_HASH_LOG
1159 .min(params.window_log as usize),
1160 )
1161 } else {
1162 0
1163 };
1164 table_bytes(4, h.hash_log)
1165 .saturating_add(table_bytes(4, h.chain_log))
1166 .saturating_add(hash3)
1167 }),
1168 };
1169 // BT modes box a `BtMatcher`; its retained scratch layout is budgeted
1170 // next to the struct so estimator and allocator evolve together.
1171 let bt = if uses_bt {
1172 crate::encoding::bt::BtMatcher::estimated_workspace_bytes()
1173 } else {
1174 0
1175 };
1176 // One allocation: each table region and the history rounded to `ALIGN`,
1177 // the block buffers behind them, and the padding that aligns its start.
1178 let laid_out = tables
1179 .saturating_add(3 * ALIGN)
1180 .saturating_add(region_bytes::<u8>(history))
1181 .saturating_add(crate::encoding::blocks::CompressedBlockScratch::workspace_bytes(block))
1182 .saturating_add(ALIGN - 1);
1183 // The backends that slide their window by blocks keep one length per
1184 // block in it, in a deque that grows by doubling. Plain arithmetic here
1185 // and in `beside`: `history / block` is at most the window over the
1186 // smallest block plus a few, and every term of `beside` is a constant or
1187 // bounded by the 128 KiB block, so neither comes near `usize::MAX`.
1188 let block_deque = if matches!(backend, crate::encoding::strategy::BackendTag::Simple) {
1189 0
1190 } else {
1191 2 * (history / block + 2) * core::mem::size_of::<usize>()
1192 };
1193 let splits =
1194 crate::encoding::levels::fastest::post_split_enabled(params.strategy_tag, window as u64);
1195 let beside = crate::encoding::frame_compressor::FseTables::max_heap_size()
1196 + crate::encoding::blocks::CompressedBlockScratch::max_retained_heap_size(block, splits)
1197 + 2 * crate::huff0::huff0_encoder::HuffmanTable::max_heap_size()
1198 + crate::huff0::huff0_encoder::WeightScratch::max_heap_size();
1199 // Saturating: the parts are each bounded, but their sum at the widest
1200 // window is more than a 32-bit `usize` counts, and a total that wrapped
1201 // would report a run as fitting a limit it cannot.
1202 laid_out
1203 .saturating_add(bt)
1204 .saturating_add(block_deque)
1205 .saturating_add(beside)
1206 .saturating_add(ldm)
1207}
1208
1209/// Extra steady-state workspace the optimal strategies (ordinals 7..=9,
1210/// btopt..btultra2) retain beyond the hash/chain tables: the boxed matcher
1211/// plus its scratch arenas, and the HC3 short-match side table for
1212/// btultra/btultra2 (capped by the window log). 0 for the other ordinals
1213/// (btlazy2's tree lives in the lazy backend's chain table).
1214pub fn estimated_bt_strategy_extra_bytes(strategy_ordinal: u32, window_log: u32) -> usize {
1215 if !(7..=9).contains(&strategy_ordinal) {
1216 return 0;
1217 }
1218 let hash3 = if matches!(strategy_ordinal, 8 | 9) {
1219 4usize << crate::encoding::match_table::storage::HC3_HASH_LOG.min(window_log as usize)
1220 } else {
1221 0
1222 };
1223 crate::encoding::bt::BtMatcher::estimated_workspace_bytes() + hash3
1224}
1225
1226/// Resolve a [`CompressionLevel`] (+ optional source-size hint) to the
1227/// concrete [`LevelParams`] the matcher runs: strategy tag, search method
1228/// (match-finder), window log, and per-backend config.
1229///
1230/// ## CRITICAL: input size changes the match-finder (and can change strategy)
1231///
1232/// The resolved geometry is a function of the SOURCE SIZE, not the level
1233/// alone. This is the easy-to-miss part (so read this before assuming a level
1234/// maps to one fixed match-finder). Every level except `Uncompressed` goes
1235/// through [`get_cparams`](crate::encoding::cparams::get_cparams), the port of
1236/// upstream zstd `ZSTD_getCParams_internal`, which runs its two stages:
1237///
1238/// 1. the `clevels.h` row for the (level, source-size tier) pair. The tier
1239/// changes the STRATEGY as well as the table widths: level 11 is lazy2 on
1240/// a large source and btopt on one of 16 KiB or less;
1241/// 2. `ZSTD_adjustCParams_internal`, which caps `window_log` to
1242/// ~`ceil_log2(source_size)` and shrinks the hash and chain logs to match.
1243///
1244/// THEN, inside the Row backend, the greedy/lazy band searches a hash chain
1245/// instead of rows when the resolved `window_log <= 14`
1246/// (`RowMatchGenerator::finder`) — exactly upstream's
1247/// `ZSTD_resolveRowMatchFinderMode` (the Row match-finder is used for
1248/// greedy/lazy/lazy2 ONLY when `windowLog > 14`) — and a btlazy2 level
1249/// searches the lazily-sorted binary tree; the parse itself is the same
1250/// `lazy_generic` body in all three cases. Net effect for the SAME level:
1251///
1252/// * small input (e.g. a 10 KiB fixture → `window_log` 14) → hash chain
1253/// (`ZSTD_HcFindBestMatch`, scalar chain walk);
1254/// * large input (e.g. 1 MiB → `window_log` 20) → rows (the SIMD-tag
1255/// row match-finder).
1256///
1257/// A dictionary frame resolves through [`resolve_level_params_with_dict`]
1258/// instead: the CDict's cParams (its own size tier) with the frame's
1259/// source-derived `window_log`. When comparing against C on a fixture,
1260/// resolve the match-finder from the fixture's size (and dictionary) first,
1261/// or you may optimise/benchmark a path C does not even take for that input.
1262pub(crate) fn resolve_level_params(
1263 level: CompressionLevel,
1264 source_size: Option<u64>,
1265) -> LevelParams {
1266 // Uncompressed = raw blocks, no match-finder. Not a cParams level, so it is
1267 // the one row resolved by hand rather than through `get_cparams`.
1268 if matches!(level, CompressionLevel::Uncompressed) {
1269 return LevelParams {
1270 strategy_tag: crate::encoding::strategy::StrategyTag::Fast,
1271 search: crate::encoding::strategy::SearchMethod::Fast,
1272 // Raw frames emit literal blocks and never reference history;
1273 // advertising a wider window only inflates the decoder-side buffer
1274 // reservation, so clamp to 17 (128 KiB) regardless of input size.
1275 window_log: 17,
1276 lazy_depth: 0,
1277 // Beyond-upstream: hash_log=14 (vs upstream's row-0 13) for ~2× fewer
1278 // collisions on structured corpora; mls=6 / step_size=2 mirror the
1279 // upstream "base for negative" row (targetLength=1 -> step 2).
1280 fast: Some(FastConfig {
1281 hash_log: 14,
1282 mls: 6,
1283 step_size: 2,
1284 }),
1285 dfast: None,
1286 hc: None,
1287 row: None,
1288 };
1289 }
1290 // Every other level resolves through the SINGLE C-faithful cParams source,
1291 // `cparams::get_cparams` (the port of `ZSTD_getCParams`). One place selects
1292 // strategy + table widths + the negative-level acceleration per
1293 // (level, srcSize) + the source-size window/hash down-clamp, so the encoder
1294 // never re-derives parameters from a parallel hand-tuned path. Named presets
1295 // map to their numeric level; the cParams source clamps out-of-range levels
1296 // (>22 to 22, negatives to MIN_CLEVEL) itself.
1297 let numeric = numeric_level(level);
1298 let src = source_size.unwrap_or(crate::encoding::cparams::CONTENTSIZE_UNKNOWN);
1299 level_params_from_cparams(crate::encoding::cparams::get_cparams(numeric, src, 0))
1300}
1301
1302/// The upstream numeric level a preset maps to.
1303pub(crate) fn numeric_level(level: CompressionLevel) -> i32 {
1304 match level {
1305 CompressionLevel::Uncompressed => unreachable!("raw frames resolve no cParams"),
1306 // Fastest = upstream level 1 (fast strategy, smallest real-compression
1307 // tables).
1308 CompressionLevel::Fastest => 1,
1309 // Default = upstream level 3 (the libzstd default).
1310 CompressionLevel::Default => CompressionLevel::DEFAULT_LEVEL,
1311 // Better = level 7: the lazy2 band — clearly above the fast/dfast levels
1312 // on ratio while still well under the binary-tree cost cliff.
1313 CompressionLevel::Better => 7,
1314 // Best = level 13: the first point of the deep binary-tree band that
1315 // strictly dominates every level below it on ratio (lower levels can tie
1316 // on window-bound corpora), so the alias sits on a config that always
1317 // wins rather than on a hair-thin margin.
1318 CompressionLevel::Best => 13,
1319 CompressionLevel::Level(n) => n,
1320 }
1321}
1322
1323/// How a greedy / lazy frame compressed with a dictionary takes its
1324/// match-finder from the dictionary's own cParams (upstream
1325/// `ZSTD_resetCCtx_usingCDict`).
1326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1327pub(crate) struct RowDictPlan {
1328 /// `ZSTD_shouldAttachDict`: the dictionary tables are searched in place
1329 /// (`dictMatchState`) instead of being copied into the frame's tables.
1330 pub(crate) attach: bool,
1331 /// The CDict's `useRowMatchFinder`, inherited by the frame.
1332 pub(crate) use_row: bool,
1333 /// The CDict's cParams: the geometry (`hash_log`, `chain_log`,
1334 /// `search_log` → row width) and key width its tables were built with.
1335 pub(crate) cdict: crate::encoding::cparams::CParams,
1336}
1337
1338/// [`resolve_level_params`] for a frame that compresses with a dictionary of
1339/// `dict_size` bytes. Upstream builds the CDict with
1340/// `ZSTD_getCParams(level, UNKNOWN, dictSize, createCDict)` and the frame
1341/// then runs the CDict's strategy / widths / search depth / match-finder:
1342/// re-adjusted to the source when the dictionary is attached
1343/// (`ZSTD_resetCCtx_byAttachingCDict`), verbatim when it is copied
1344/// (`ZSTD_resetCCtx_byCopyingCDict`); only the frame's own `windowLog` is
1345/// kept. The CDict's strategy is taken whatever backend family the plain
1346/// level resolved to; a lazy-band CDict (greedy..btlazy2) also carries the
1347/// lazy backend's [`RowDictPlan`]. The caller's `overrides` are part of the
1348/// CDict's cParams, as they are for a dictionary upstream loads into a context
1349/// that carries them.
1350pub(crate) fn resolve_level_params_with_dict(
1351 level: CompressionLevel,
1352 source_size: Option<u64>,
1353 sizes: crate::encoding::DictionarySizes,
1354 overrides: &crate::encoding::parameters::ParamOverrides,
1355) -> (LevelParams, Option<RowDictPlan>) {
1356 use crate::encoding::cparams::{
1357 CONTENTSIZE_UNKNOWN, attach_cparams, copy_cparams, get_cdict_cparams, should_attach_dict,
1358 uses_row_match_finder,
1359 };
1360 let base = resolve_level_params(level, source_size);
1361 // A raw frame searches nothing, so a dictionary has no tier to give it.
1362 if sizes.content == 0 || matches!(level, CompressionLevel::Uncompressed) {
1363 return (base, None);
1364 }
1365 // The CDict's cParams decide the frame's strategy REGARDLESS of the
1366 // backend family the plain level resolved to for this source size
1367 // (upstream takes them unconditionally): L13 on a 4 KiB source is
1368 // btopt, but a 300 KiB CDict is btlazy2 and the frame runs btlazy2; L4
1369 // on a 1 MiB source is dfast, but a 4 KiB CDict is greedy. Only the
1370 // frame's own `windowLog` is kept.
1371 let cdict = get_cdict_cparams(numeric_level(level), sizes.serialized, overrides);
1372 // `ZSTD_shouldAttachDict`, bounded by the backend's attach representability:
1373 // the Fast / Dfast attached tables pack the dict position next to a tag, so
1374 // they index at most 2^24 content bytes. A larger dictionary is primed in
1375 // COPY mode, and the frame must then run the CDict's verbatim table
1376 // geometry (`byCopyingCDict`) — copying it into source-capped attach-mode
1377 // tables would collide away its matches.
1378 let attach_fits = match cdict.strategy {
1379 1 => sizes.content <= MAX_FAST_ATTACH_DICT_REGION,
1380 2 => sizes.content <= crate::encoding::dfast::DFAST_ATTACH_DICT_MAX_LEN,
1381 _ => true,
1382 };
1383 let attach = should_attach_dict(&cdict, source_size) && attach_fits;
1384 let window_log = u32::from(base.window_log);
1385 let frame = if attach {
1386 attach_cparams(
1387 cdict,
1388 source_size.unwrap_or(CONTENTSIZE_UNKNOWN),
1389 window_log,
1390 )
1391 } else {
1392 copy_cparams(cdict, window_log)
1393 };
1394 let params = level_params_from_cparams(frame);
1395 if !(3..=6).contains(&cdict.strategy) {
1396 // Fast / dfast / optimal strategies: their backends prime the
1397 // dictionary themselves; no lazy-backend plan.
1398 return (params, None);
1399 }
1400 (
1401 params,
1402 Some(RowDictPlan {
1403 attach,
1404 use_row: uses_row_match_finder(&cdict, overrides.row_match_finder),
1405 cdict,
1406 }),
1407 )
1408}
1409
1410/// The cheap fingerprint pre-splitter level for a compression level (the
1411/// C-like `blockSplitterLevel`), resolved through the same per-level
1412/// `LevelParams` table as every other tuning knob. `None` keeps the whole
1413/// 128 KiB block. The frame loop reads this instead of hardcoding the
1414/// level→split mapping at the call site.
1415pub(crate) fn level_pre_split(level: CompressionLevel) -> Option<usize> {
1416 // Resolve through `resolve_level_params` directly — NOT via the legacy
1417 // `numeric_level()` alias — so named presets read the SAME table row as
1418 // every other tuning knob (`Best` maps to its own row there, which is
1419 // not the row its numeric alias points at). `Uncompressed` (raw
1420 // blocks) never splits.
1421 if matches!(level, CompressionLevel::Uncompressed) {
1422 return None;
1423 }
1424 resolve_level_params(level, None)
1425 .pre_split()
1426 .map(usize::from)
1427}
1428
1429#[cfg(test)]
1430mod tests;