Skip to content

Commit a2044af

Browse files
authored
perf(encode): cap HC/BT history mirror near the live window (#421)
* perf(encode): cap HC/BT history mirror near the live window Mirror the row (#418) and dfast (#420) history-buffer fix for the shared HashChain / binary-tree MatchTable (btlazy2 / btopt / btultra, L13-L22). On long streams the history mirror power-of-two doubled to ~2x the window (an 8 MiB window peaked at 16 MiB), inflating compress peak memory. Reserve window + window/4 + one block once eviction starts and drain the dead prefix at a quarter window so the Vec grows linearly and never reallocates again; reserve_history's ceiling is bumped to match. Small frames keep their tight buffer. Byte-identical (buffer management only). * refactor(encode): plain arithmetic for history cap, not saturating_add The reserve_history ceiling used saturating_add, which would silently clamp on overflow and mask an upstream window_log bound violation. The sum is max_window_size + max_window_size/4 + MAX_BLOCK_SIZE with max_window_size = 1 << window_log and window_log <= 31, so it is at most ~2^31 + 2^29 + 2^17 < usize::MAX even on 32-bit targets — overflow is unreachable. Use plain arithmetic with a bound comment, matching the add_data eviction reserve. * refactor: drop defensive saturating arithmetic where overflow is unreachable Codebase-wide audit of saturating_add/saturating_mul against the safe-arithmetic policy (saturating only for a deliberate min/max clamp, plain arithmetic when the bound provably holds, so a real overflow fails loudly at its cause instead of being silently clamped). Converted to plain arithmetic (with a bound comment each) where overflow is unreachable: huffman tree node counts and literal/symbol frequency counters (<= block size), pre-split fingerprint distance/threshold and per-block event merge, FSE/HUF cost estimates, dict+window log helper (matches upstream's plain u64 add), FASTCOVER epoch sizing, history pre-size hint, fast-matcher length/2x-window checks, eviction byte accounting. Kept (documented as deliberate, not masking): compress_bound and the decompressed-size upper bounds (saturate to the representable ceiling), the optimal-parser price comparison (base_cost can be the u32::MAX "unreachable" sentinel — saturating keeps the compare correct), the skip-step loop-break idiom (saturate then `next <= pos` detects the end), untrusted-frame size accumulation, and abs-position/index accumulators that rely on the rebase machinery. Behavior-identical; 838 tests pass. * test(encode): add regression for HC/BT reserve hint overflow driver_huge_source_hint_with_dict_does_not_overflow_hc_reserve sets a u64::MAX source-size hint (the unknown-size sentinel) plus a positive dictionary hint and resets at Level 16 (BtOpt → HashChain/BT storage arm). On current code `(src as usize) + dict_hint` overflows usize before reserve_history can clamp: debug panic / release wrap on 64-bit, src-truncation on 32-bit. Test fails (overflow panic) until the fix. * fix(encode): saturate HC/BT reserve hint instead of overflowing (src as usize) + dict_hint.unwrap_or(0) overflowed usize when src is the u64::MAX unknown-size sentinel plus a positive dictionary hint, and truncated src on 32-bit targets. Saturate src via usize::try_from and saturate the dict-hint addition via checked_add; reserve_history applies the tighter window ceiling. Fixes the panic in the regression test added in the preceding commit. * fix(encode): avoid eviction-check overflow on 32-bit; fix stale docs FastKernelMatcher eviction computed real_len + space.len() before the block-size assert; at window_log = 30 both terms approach cap = 2^31, so the sum overflows usize on 32-bit targets. Reorder to compute cap, assert the block bound, then compare via subtraction (real_len > cap - space.len()) which the assert proves cannot underflow. Correct the stale window_log <= 31 comment to the enforced <= 30, and refresh the compact_history doc to describe the quarter-window / half-mirror drain. * fix(encode): use saturating_add for HC/BT reserve hint (clippy) * test(encode): assert clamped HC reserve ceiling - Strengthen driver_huge_source_hint_with_dict_does_not_overflow_hc_reserve to assert the post-reset history capacity reaches the clamped ceiling (window + window/4 + block), not just no-panic; an under-reserved mirror would otherwise pass. - Correct the FastKernelMatcher eviction overflow-safety doc: the `* 2` is safe because dictionary priming caps max_window_size at MAX_PRIMED_WINDOW_SIZE = (u32::MAX - MAX_BLOCK_SIZE)/2 (so cap*2 < u32::MAX by construction), not because window_log <= 30 (priming widens beyond that).
1 parent f99c72a commit a2044af

10 files changed

Lines changed: 168 additions & 45 deletions

File tree

zstd/src/dictionary/mod.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ pub fn create_raw_dict_from_source<R: io::Read, W: io::Write>(
232232
// the lowest scoring items come first
233233
let mut pool: BinaryHeap<Reverse<Segment>> = BinaryHeap::new();
234234
let (num_epochs, epoch_size_kmers) = compute_epoch_info(&params, dict_size, source_size / K);
235-
let epoch_size = usize::max(K, epoch_size_kmers.saturating_mul(K));
235+
// Plain `*`/`+` throughout the epoch walk below: epochs partition the
236+
// training source, so `epoch_size_kmers * K`, `epoch_idx * epoch_size`, and
237+
// `start + epoch_size` are all bounded by the source length (<= isize::MAX)
238+
// and cannot overflow usize.
239+
let epoch_size = usize::max(K, epoch_size_kmers * K);
236240
vprintln!("create_dict: computed epoch info, using {num_epochs} epochs of {epoch_size} bytes");
237241
let mut epoch_counter = 0;
238242
let mut ctx = Context {
@@ -242,14 +246,14 @@ pub fn create_raw_dict_from_source<R: io::Read, W: io::Write>(
242246
// segment for the pool. Keep exactly `num_epochs` windows to avoid
243247
// emitting more segments than the requested dictionary budget allows.
244248
for epoch_idx in 0..num_epochs {
245-
let start = epoch_idx.saturating_mul(epoch_size);
249+
let start = epoch_idx * epoch_size;
246250
if start >= all.len() {
247251
break;
248252
}
249253
let end = if epoch_idx + 1 == num_epochs {
250254
all.len()
251255
} else {
252-
usize::min(start.saturating_add(epoch_size), all.len())
256+
usize::min(start + epoch_size, all.len())
253257
};
254258
let epoch = &all[start..end];
255259
epoch_counter += 1;

zstd/src/encoding/blocks/compressed.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1603,8 +1603,10 @@ fn choose_table_from_counts<'a>(
16031603
// wins. The estimate equals the built table's `table_header_bits()`
16041604
// exactly, so the selection is byte-identical.
16051605
let new_total_cost = (distinct_symbols > 1).then(|| {
1606+
// Plain `+`: both are bit-cost estimates bounded by the block size
1607+
// (<= MAX_BLOCK_SIZE * 8 bits), far under the integer's range.
16061608
fse_header_bits_for_counts(&counts[..=max_symbol], max_log, use_low_prob_count)
1607-
.saturating_add(entropy_cost(counts, max_symbol, total))
1609+
+ entropy_cost(counts, max_symbol, total)
16081610
});
16091611

16101612
let predefined_cost = cross_entropy_cost(counts, max_symbol, default_table);

zstd/src/encoding/cost_model/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,10 @@ impl HcOptState {
315315
self.lit_sum = Self::apply_seeded_table(&mut self.lit_freq, &seed.lit_bits, 11);
316316
} else if self.literals_compressed() {
317317
self.lit_freq.fill(0);
318+
// Plain `+`: lit_freq is u32 and a byte's count is bounded
319+
// by the block size (<= MAX_BLOCK_SIZE), far under u32::MAX.
318320
for &byte in src {
319-
self.lit_freq[byte as usize] =
320-
self.lit_freq[byte as usize].saturating_add(1);
321+
self.lit_freq[byte as usize] += 1;
321322
}
322323
self.lit_sum = Self::downscale_stats(&mut self.lit_freq, 8, false);
323324
if self.lit_sum == 0 {
@@ -363,9 +364,10 @@ impl HcOptState {
363364
} else {
364365
if self.literals_compressed() {
365366
self.lit_freq.fill(0);
367+
// Plain `+`: lit_freq is u32 and a byte's count is bounded
368+
// by the block size (<= MAX_BLOCK_SIZE), far under u32::MAX.
366369
for &byte in src {
367-
self.lit_freq[byte as usize] =
368-
self.lit_freq[byte as usize].saturating_add(1);
370+
self.lit_freq[byte as usize] += 1;
369371
}
370372
self.lit_sum = Self::downscale_stats(&mut self.lit_freq, 8, false);
371373
if self.lit_sum == 0 {

zstd/src/encoding/frame_compressor.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,10 @@ fn presplit_distance(lhs: &PreSplitFingerprint, rhs: &PreSplitFingerprint, hash_
445445
for idx in 0..slots {
446446
let left = lhs.events[idx] as i128 * rhs.nb_events as i128;
447447
let right = rhs.events[idx] as i128 * lhs.nb_events as i128;
448-
distance = distance.saturating_add(left.abs_diff(right) as u64);
448+
// Plain `+`: events/nb_events are per-block sample counts (<= block
449+
// size), so each |left-right| <= (2^17)^2 and the sum over <= 2^hash_log
450+
// slots stays far under u64::MAX — no overflow.
451+
distance += left.abs_diff(right) as u64;
449452
}
450453
distance
451454
}
@@ -460,16 +463,21 @@ fn presplit_fingerprints_differ(
460463
debug_assert!(new_fp.nb_events > 0);
461464
let p50 = reference.nb_events as u64 * new_fp.nb_events as u64;
462465
let deviation = presplit_distance(reference, new_fp, hash_log);
463-
let threshold = p50.saturating_mul(PRESPLIT_THRESHOLD_BASE + penalty as u64)
464-
/ PRESPLIT_THRESHOLD_PENALTY_RATE;
466+
// Plain `*`: p50 <= (block-sample-count)^2 and the (base+penalty) factor is
467+
// a small constant, so the product stays well under u64::MAX.
468+
let threshold =
469+
p50 * (PRESPLIT_THRESHOLD_BASE + penalty as u64) / PRESPLIT_THRESHOLD_PENALTY_RATE;
465470
deviation >= threshold
466471
}
467472

468473
fn presplit_merge_events(acc: &mut PreSplitFingerprint, new_fp: &PreSplitFingerprint) {
474+
// Plain `+`: `acc` accumulates only the chunks of a single block (caller
475+
// loops within one block, <= MAX_BLOCK_SIZE), so the merged sample counts
476+
// stay far under u32 / usize bounds — no overflow.
469477
for idx in 0..PRESPLIT_HASH_TABLE_SIZE {
470-
acc.events[idx] = acc.events[idx].saturating_add(new_fp.events[idx]);
478+
acc.events[idx] += new_fp.events[idx];
471479
}
472-
acc.nb_events = acc.nb_events.saturating_add(new_fp.nb_events);
480+
acc.nb_events += new_fp.nb_events;
473481
}
474482

475483
fn split_block_by_chunks(block: &[u8], level: usize) -> usize {

zstd/src/encoding/match_generator.rs

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -792,8 +792,11 @@ fn dict_and_window_log(window_log: u8, src_size: u64, dict_size: u64) -> u32 {
792792
return window_log as u32;
793793
}
794794
let window_size: u64 = 1u64 << window_log;
795-
let dict_and_window = dict_size.saturating_add(window_size);
796-
if window_size >= dict_size.saturating_add(src_size) {
795+
// Plain `+` (matches upstream zstd `ZSTD_dictAndWindowLog`): `window_size` is
796+
// `1 << window_log` (window_log <= 31) and dict/src are real data sizes
797+
// (<= isize::MAX), so these u64 sums cannot overflow in practice.
798+
let dict_and_window = dict_size + window_size;
799+
if window_size >= dict_size + src_size {
797800
// Window already covers source + dictionary.
798801
window_log as u32
799802
} else {
@@ -821,7 +824,9 @@ fn cdict_table_logs(
821824
// createCDict assumes a minSrcSize source when the real size is unknown.
822825
let src_size = DICT_MIN_SRC_SIZE;
823826
// Source-size window resize (donor caps windowLog by ceil_log2(src+dict)).
824-
let tsize = src_size.saturating_add(dict_size);
827+
// Plain `+`: src_size is the tiny DICT_MIN_SRC_SIZE constant and dict_size
828+
// is a real dictionary length, so the u64 sum cannot overflow.
829+
let tsize = src_size + dict_size;
825830
let resized_window_log = (window_log as u32)
826831
.min(source_size_ceil_log(tsize) as u32)
827832
.max(1);
@@ -2270,7 +2275,14 @@ impl Matcher for MatchGeneratorDriver {
22702275
// tables are dictionary-tier-small. Unhinted streams skip this
22712276
// and keep doubling growth.
22722277
if let Some(src) = hint {
2273-
let expected = (src as usize).saturating_add(dict_hint.unwrap_or(0));
2278+
// `src` is a u64 hint and may be the u64::MAX "unknown
2279+
// size" sentinel, which truncates under `as usize` on
2280+
// 32-bit targets and overflows when the dict hint is
2281+
// added. Saturate the source size, then saturate the
2282+
// dict-hint addition; `reserve_history` applies the
2283+
// tighter window ceiling to the result.
2284+
let src_hint = usize::try_from(src).unwrap_or(usize::MAX);
2285+
let expected = src_hint.saturating_add(dict_hint.unwrap_or(0));
22742286
hc.table.reserve_history(expected);
22752287
}
22762288
}
@@ -2779,7 +2791,9 @@ impl Matcher for MatchGeneratorDriver {
27792791
// when it later reuses the buffer.
27802792
vec_pool.push(data);
27812793
});
2782-
evicted_bytes += pre.saturating_add(space_len).saturating_sub(m.window_size);
2794+
// Plain `+` (the `saturating_sub` floors at 0): `pre` + one
2795+
// block are byte counts bounded by the window, no overflow.
2796+
evicted_bytes += (pre + space_len).saturating_sub(m.window_size);
27832797
}
27842798
MatcherStorage::Row(m) => {
27852799
// RowMatchGenerator::add_data recycles the *input* buffer
@@ -2799,7 +2813,9 @@ impl Matcher for MatchGeneratorDriver {
27992813
// `slice_size` on reuse).
28002814
vec_pool.push(data);
28012815
});
2802-
evicted_bytes += pre.saturating_add(space_len).saturating_sub(m.window_size);
2816+
// Plain `+` (the `saturating_sub` floors at 0): `pre` + one
2817+
// block are byte counts bounded by the window, no overflow.
2818+
evicted_bytes += (pre + space_len).saturating_sub(m.window_size);
28032819
}
28042820
MatcherStorage::HashChain(m) => {
28052821
// MatchTable::add_data now recycles the *incoming* buffer
@@ -2822,9 +2838,9 @@ impl Matcher for MatchGeneratorDriver {
28222838
// pool retains.
28232839
vec_pool.push(data);
28242840
});
2825-
evicted_bytes += pre
2826-
.saturating_add(space_len)
2827-
.saturating_sub(m.table.window_size);
2841+
// Plain `+` (the `saturating_sub` floors at 0): byte counts
2842+
// bounded by the window, no overflow.
2843+
evicted_bytes += (pre + space_len).saturating_sub(m.table.window_size);
28282844
}
28292845
}
28302846
// Gate the second backend trim pass on actual budget
@@ -3975,6 +3991,11 @@ macro_rules! build_optimal_plan_impl_body {
39753991
}
39763992

39773993
let next_price = unsafe { nodes.get_unchecked(pos + 1).price };
3994+
// `saturating_add` is REQUIRED here, not a masked bug: `base_cost`
3995+
// is a node price that can be the `u32::MAX` "unreachable" sentinel,
3996+
// and saturating keeps `base_cost + margin` pinned at MAX so the
3997+
// comparison stays correct. Plain `+` would wrap the sentinel and
3998+
// flip the abort decision (a ratio bug / debug overflow panic).
39783999
if abort_on_worse_match
39794000
&& next_price <= base_cost.saturating_add(HC_BITCOST_MULTIPLIER / 2)
39804001
{
@@ -8056,6 +8077,42 @@ fn driver_huge_source_hint_does_not_overflow_table_window_shift() {
80568077
);
80578078
}
80588079

8080+
#[test]
8081+
fn driver_huge_source_hint_with_dict_does_not_overflow_hc_reserve() {
8082+
// Regression: the HC/BT history-mirror pre-size adds the dictionary
8083+
// hint to the source-size hint before `reserve_history` clamps to the
8084+
// window ceiling. A `u64::MAX` pledged source size (the "unknown size"
8085+
// sentinel) plus any positive dictionary hint overflows `usize` in
8086+
// `(src as usize) + dict_hint` — debug panic / release wrap on 64-bit,
8087+
// and `src as usize` truncation on 32-bit targets. Level 16 (BtOpt)
8088+
// routes through the HashChain/BT storage arm that owns this reserve.
8089+
// Must size the mirror to the real window, never panic, wrap, or
8090+
// truncate.
8091+
let mut driver = MatchGeneratorDriver::new(32, 2);
8092+
driver.set_source_size_hint(u64::MAX);
8093+
driver.set_dictionary_size_hint(64 * 1024);
8094+
driver.reset(CompressionLevel::Level(16));
8095+
8096+
// The saturated `usize::MAX` reserve target must be clamped to the HC
8097+
// history ceiling, not reserved literally (which would OOM/panic). Level 16
8098+
// has window_log 22, so the ceiling is `window + window/4 + one block`
8099+
// (the `reserve_history` formula). Assert the reserve actually reached it —
8100+
// a no-panic-only check would also pass on an under-reserved mirror.
8101+
let window = 1usize << 22;
8102+
let expected_history_ceiling = window + (window >> 2) + crate::common::MAX_BLOCK_SIZE as usize;
8103+
assert!(
8104+
driver.hc_matcher().table.history.capacity() >= expected_history_ceiling,
8105+
"huge source + dict hint must reserve the clamped HC history ceiling, got {}",
8106+
driver.hc_matcher().table.history.capacity()
8107+
);
8108+
8109+
let mut space = driver.get_next_space();
8110+
space[..12].copy_from_slice(b"abcabcabcabc");
8111+
space.truncate(12);
8112+
driver.commit_space(space);
8113+
driver.skip_matching_with_hint(None);
8114+
}
8115+
80598116
#[test]
80608117
fn driver_small_source_hint_shrinks_row_hash_tables() {
80618118
let mut driver = MatchGeneratorDriver::new(32, 2);

zstd/src/encoding/match_table/storage.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -724,12 +724,19 @@ impl MatchTable {
724724
}
725725

726726
pub(crate) fn reserve_history(&mut self, expected_bytes: usize) {
727-
// Eviction keeps the live mirror within `max_window_size`; one pending
728-
// block can sit on top before `add_data` rolls it out, so the tightest
729-
// sufficient capacity is `max_window_size + MAX_BLOCK_SIZE`.
730-
let cap = self
731-
.max_window_size
732-
.saturating_add(crate::common::MAX_BLOCK_SIZE as usize);
727+
// Eviction keeps the live mirror within `max_window_size`; the dead
728+
// prefix is drained at a quarter window (see `compact_history`) and one
729+
// pending block can sit on top, so the steady-state ceiling is
730+
// `max_window_size + max_window_size/4 + MAX_BLOCK_SIZE` — matching the
731+
// `add_data` eviction reserve so the two never fight over capacity.
732+
// Plain arithmetic (not `saturating_*`): `max_window_size = 1 <<
733+
// window_log` with `window_log <= ZSTD_WINDOWLOG_MAX` (31), so the sum
734+
// is at most `2^31 + 2^29 + 2^17 < usize::MAX` even on 32-bit targets —
735+
// overflow is unreachable, and a silent saturation here would only mask
736+
// a window_log bound violation upstream.
737+
let cap = self.max_window_size
738+
+ (self.max_window_size >> 2)
739+
+ crate::common::MAX_BLOCK_SIZE as usize;
733740
let want = expected_bytes.min(cap);
734741
if want > self.history.capacity() {
735742
self.history.reserve_exact(want - self.history.len());
@@ -739,6 +746,20 @@ impl MatchTable {
739746
pub(crate) fn add_data(&mut self, data: Vec<u8>, mut reuse_space: impl FnMut(Vec<u8>)) {
740747
assert!(data.len() <= self.max_window_size);
741748
check_stream_abs_headroom(self.history_abs_start, self.window_size, data.len());
749+
if self.window_size + data.len() > self.max_window_size {
750+
// Cap the history mirror near the live window once eviction starts:
751+
// reserve exactly (window + window/4 + one block) so the Vec grows
752+
// linearly to that ceiling instead of power-of-two doubling to ~2x
753+
// window; `compact_history`'s quarter-window drain keeps len under
754+
// it, so the Vec never reallocates again. Small frames that never
755+
// fill the window keep their tight data-sized buffer.
756+
let target = self.max_window_size
757+
+ (self.max_window_size >> 2)
758+
+ crate::common::MAX_BLOCK_SIZE as usize;
759+
if target > self.history.len() && self.history.capacity() < target {
760+
self.history.reserve_exact(target - self.history.len());
761+
}
762+
}
742763
while self.window_size + data.len() > self.max_window_size {
743764
let removed_len = self.chunk_lens.pop_front().unwrap();
744765
self.window_size -= removed_len;
@@ -769,13 +790,17 @@ impl MatchTable {
769790
}
770791

771792
/// Drain the dead prefix of `history` (already-rolled-out bytes)
772-
/// when it has grown to at least half the live region. Keeps the
773-
/// contiguous mirror compact so reallocation costs stay amortised.
793+
/// once it reaches a quarter window, or when it accounts for at
794+
/// least half of the mirror. Keeps the contiguous mirror compact
795+
/// so reallocation costs stay amortised.
774796
pub(crate) fn compact_history(&mut self) {
775797
if self.history_start == 0 {
776798
return;
777799
}
778-
if self.history_start >= self.max_window_size
800+
// Drain the dead prefix at a quarter window (paired with the eviction
801+
// reserve in `add_data`) so the mirror stays near `window + window/4`
802+
// rather than doubling to ~2x window on long streams.
803+
if self.history_start >= (self.max_window_size >> 2)
779804
|| self.history_start * 2 >= self.history.len()
780805
{
781806
self.history.drain(..self.history_start);

zstd/src/encoding/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@ pub const fn compress_bound(src_size: usize) -> usize {
234234
} else {
235235
0
236236
};
237+
// Saturating is the correct UPPER-BOUND semantic here, not a masked bug:
238+
// this is a public API over an arbitrary `usize`, and the largest meaningful
239+
// bound is `usize::MAX`. A real slice is at most `isize::MAX` bytes, so the
240+
// `* 1.004 + margin` cannot overflow for genuine inputs; the saturation only
241+
// caps a pathological caller-supplied size at the representable ceiling.
237242
src_size
238243
.saturating_add(src_size >> 8)
239244
.saturating_add(margin)

zstd/src/encoding/sequence_capture.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ impl Matcher for CapturingMatcher {
113113
let tail_ll = self.inner.get_last_space().len() as u32;
114114
self.inner.skip_matching();
115115
self.block_tail_lengths.borrow_mut().push(tail_ll);
116-
self.current_block = self.current_block.saturating_add(1);
116+
// Plain `+`: diagnostic per-block index; the comparator runs on bench
117+
// fixtures whose block count is nowhere near u32::MAX.
118+
self.current_block += 1;
117119
}
118120

119121
fn skip_matching_with_hint(&mut self, incompressible_hint: Option<bool>) {
@@ -126,7 +128,9 @@ impl Matcher for CapturingMatcher {
126128
let tail_ll = self.inner.get_last_space().len() as u32;
127129
self.inner.skip_matching_with_hint(incompressible_hint);
128130
self.block_tail_lengths.borrow_mut().push(tail_ll);
129-
self.current_block = self.current_block.saturating_add(1);
131+
// Plain `+`: diagnostic per-block index; the comparator runs on bench
132+
// fixtures whose block count is nowhere near u32::MAX.
133+
self.current_block += 1;
130134
}
131135

132136
fn start_matching(&mut self, mut handle_sequence: impl for<'a> FnMut(Sequence<'a>)) {
@@ -164,7 +168,8 @@ impl Matcher for CapturingMatcher {
164168
of: *offset as u32,
165169
ml: *match_len as u32,
166170
});
167-
seq_in_block = seq_in_block.saturating_add(1);
171+
// Plain `+`: sequences per block <= MAX_BLOCK_SIZE, far under u32::MAX.
172+
seq_in_block += 1;
168173
}
169174
Sequence::Literals { literals } => {
170175
block_tail_ll = literals.len() as u32;
@@ -173,7 +178,9 @@ impl Matcher for CapturingMatcher {
173178
handle_sequence(seq);
174179
});
175180
self.block_tail_lengths.borrow_mut().push(block_tail_ll);
176-
self.current_block = self.current_block.saturating_add(1);
181+
// Plain `+`: diagnostic per-block index; the comparator runs on bench
182+
// fixtures whose block count is nowhere near u32::MAX.
183+
self.current_block += 1;
177184
}
178185

179186
fn reset(&mut self, level: CompressionLevel) {

0 commit comments

Comments
 (0)