From 59a18dca5abef4bab3ccd8f45744fe0c1357c1d1 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 17:14:18 +0300 Subject: [PATCH 1/6] chore(bench): drop the redundant no-dict arm from the compress-dict matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c_ffi_without_dict re-measured C compressing the same level/scenario WITHOUT a dictionary — already covered by the plain compress/{level}/{scenario}/matrix/c_ffi group. It also built the CCtx per-iter while the two dictionary arms are steady-state, so it was not even a clean baseline, and the REPORT ratio reads the with-dict sizes, not this arm. Keep only c_ffi_with_dict and pure_rust_with_dict. --- zstd/benches/compare_ffi.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/zstd/benches/compare_ffi.rs b/zstd/benches/compare_ffi.rs index 867a8aa82..67c6301fc 100644 --- a/zstd/benches/compare_ffi.rs +++ b/zstd/benches/compare_ffi.rs @@ -753,21 +753,10 @@ fn bench_dictionary(c: &mut Criterion) { configure_group(&mut group, scenario, BenchOp::Compress); group.throughput(Throughput::Bytes(scenario.throughput_bytes())); - // Construct the FFI compressor INSIDE `b.iter` to match the - // per-iter setup shape used by both *_with_dict arms below. - // Reusing one compressor outside the loop here biased the - // baseline: `c_ffi_with_dict` paid CCtx-create + CDict attach - // every iter, but `c_ffi_without_dict` paid only the compress - // call, making the dict-vs-nodict delta look larger than the - // real attach cost. - group.bench_function("c_ffi_without_dict", |b| { - b.iter(|| { - let mut compressor = zstd::bulk::Compressor::new(level.ffi_level).unwrap(); - configure_ffi_bulk_compressor(&mut compressor, &level); - black_box(compressor.compress(&scenario.bytes).unwrap()) - }) - }); - + // No-dict baseline for this level/scenario is already measured by the + // plain `compress/{level}/{scenario}/matrix/c_ffi` group, so this + // dict group only times the two dictionary arms. + // // c_ffi_with_dict + pure_rust_with_dict: STEADY-STATE measurement. // Both build the compressor + attach the dictionary ONCE before // `b.iter`, then compress in the loop reusing that context — the From e2c5b804c5fcbfd65f1a378f0363f217c925f425 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 20:39:52 +0300 Subject: [PATCH 2/6] perf(decode): skip the per-frame entropy-table reset when unbuilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-frame scratch reset unconditionally cleared all three sequence FSE tables and the Huffman table (6 buffers + a [0; 13] rank-count memset) even on RAW / RLE / raw-literal frames that never build any entropy table. Upstream zstd ZSTD_decompressBegin never clears entropy per frame — it marks tables invalid by flag and rebuilds lazily. Gate each table reset on whether it holds a built table (FSE accuracy_log != 0, Huffman max_num_bits != 0): resetting an already-empty table is a no-op on observable state, so this is byte-identical and just drops the wasted clears on entropy-less frames. --- zstd/src/decoding/scratch.rs | 28 ++++++++++++++++++++++++---- zstd/src/fse/fse_decoder.rs | 10 ++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/zstd/src/decoding/scratch.rs b/zstd/src/decoding/scratch.rs index 1b5ac3950..4efbaa70b 100644 --- a/zstd/src/decoding/scratch.rs +++ b/zstd/src/decoding/scratch.rs @@ -216,9 +216,22 @@ impl DecoderScratch { self.buffer.reset(window_size); - self.fse.literal_lengths.reset(); - self.fse.match_lengths.reset(); - self.fse.offsets.reset(); + // Mirror upstream zstd's `ZSTD_decompressBegin`: it never clears the + // entropy tables per frame (marks them invalid by flag, rebuilds lazily). + // Resetting an already-empty table is a no-op on the observable state + // (`accuracy_log`/`max_num_bits` stay 0, the buffers stay empty), so a + // RAW / RLE / zero-sequence frame — which never builds a table — skips + // the clears (and the Huffman `[0; 13]` rank-count memset) entirely. + // Byte-identical: the table is cleared exactly when it was built. + if self.fse.literal_lengths.is_populated() { + self.fse.literal_lengths.reset(); + } + if self.fse.match_lengths.is_populated() { + self.fse.match_lengths.reset(); + } + if self.fse.offsets.is_populated() { + self.fse.offsets.reset(); + } // Reset the cached pipeline-gate signal alongside the FSE // table reset — otherwise scratch reuse across frames could // engage the long pipeline on a new frame's Repeat-mode @@ -239,7 +252,14 @@ impl DecoderScratch { // the documented "no behaviour change" property. self.fse.ddict_is_cold = false; - self.huf.table.reset(); + // Same lazy-entropy skip as the FSE tables above: a Huffman table that + // was never built this round (`max_num_bits == 0`) is already in its + // reset state, so re-running `reset` (clearing 6 buffers + the + // `[0; 13]` rank-count memset) is wasted no-op work on RAW / RLE / raw- + // literal frames. Byte-identical. + if self.huf.table.max_num_bits != 0 { + self.huf.table.reset(); + } // Mirror the FSE detach: a reused workspace must not read a // previous frame's dictionary Huffman table. self.huf.detach_dict(); diff --git a/zstd/src/fse/fse_decoder.rs b/zstd/src/fse/fse_decoder.rs index 0394bcc74..913122c43 100644 --- a/zstd/src/fse/fse_decoder.rs +++ b/zstd/src/fse/fse_decoder.rs @@ -391,6 +391,16 @@ impl FSETableImpl { self.accuracy_log = 0; } + /// Whether this table holds a built decode table (`accuracy_log > 0`). A + /// just-reset / never-built table reads as `false`; resetting it again is a + /// no-op, so the per-frame scratch reset can skip it (mirrors upstream zstd, + /// which never clears entropy tables per frame — it marks them invalid by + /// flag and rebuilds lazily). + #[inline] + pub(crate) fn is_populated(&self) -> bool { + self.accuracy_log != 0 + } + /// Build the equivalent encoder-side table from a parsed decoder table. pub(crate) fn to_encoder_table(&self) -> Option { if self.accuracy_log == 0 || self.symbol_probabilities.is_empty() { From 95c6700a666b4da9de4f107f703206ad8f7b673b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 22:17:23 +0300 Subject: [PATCH 3/6] perf(decode): direct copy for a single-RAW-block frame A frame that is one RAW block spanning the whole declared content (the shape incompressible / already-compressed payloads always take) went through the full direct-decode machinery: DirectScratch / DecodeBuffer / UserSliceBackend wrapper construction plus the general per-block loop, for what is ultimately a single memcpy. Mirror upstream zstd's ZSTD_copyRawBlock: detect the single last RAW block whose size equals the frame content size and copy it straight into the caller slice, with the same checksum / counter bookkeeping the general path does. Byte-identical; falls through to the general path for every other shape. --- zstd/src/decoding/frame_decoder.rs | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/zstd/src/decoding/frame_decoder.rs b/zstd/src/decoding/frame_decoder.rs index aa291170e..9dd21e75d 100644 --- a/zstd/src/decoding/frame_decoder.rs +++ b/zstd/src/decoding/frame_decoder.rs @@ -2946,6 +2946,62 @@ impl FrameDecoder { .as_mut() .expect("caller ensures init populated state"); + // Fast path: a frame that is a single RAW block spanning the whole + // declared content. Upstream zstd handles this as one `ZSTD_copyRawBlock` + // (a `memmove`) inside `ZSTD_decompressFrame`; do the same here — a direct + // `copy_from_slice` into the caller's output slice — skipping the + // `DirectScratch` / `DecodeBuffer` / `UserSliceBackend` wrapper + // construction and the general per-block loop. Incompressible payloads + // (random / already-compressed data) emit exactly this shape, so the win + // lands on small high-entropy frames where the per-frame machinery, not + // the 1-block copy, dominates. + { + let mut probe = *input; + let mut header_dec = block_decoder::new(); + if let Ok((bh, hsize)) = header_dec.read_block_header(&mut probe) { + let n = bh.decompressed_size as usize; + if bh.last_block + && matches!(bh.block_type, crate::blocks::block::BlockType::Raw) + && n as u64 == content_size + && probe.len() >= n + && output.len() >= n + { + output[..n].copy_from_slice(&probe[..n]); + *input = &probe[n..]; + state.bytes_read_counter += u64::from(hsize) + n as u64; + state.block_counter += 1; + #[cfg(feature = "hash")] + if state.frame_header.descriptor.content_checksum_flag() { + let mut chksum = [0u8; 4]; + Read::read_exact(input, &mut chksum).map_err(err::FailedToReadChecksum)?; + state.bytes_read_counter += 4; + state.check_sum = Some(u32::from_le_bytes(chksum)); + // Mirror the general path: seed the scratch hash so + // `verify_content_checksum` / `get_calculated_checksum` + // read the digest. Skipped under `ContentChecksum::None`. + if self.content_checksum != ContentChecksum::None { + use core::hash::Hasher; + let mut h = twox_hash::XxHash64::with_seed(0); + h.write(&output[..n]); + match &mut state.decoder_scratch { + DecoderScratchKind::Flat(s) => s.buffer.hash = h, + DecoderScratchKind::Ring(s) => s.buffer.hash = h, + } + } + } + #[cfg(all(feature = "lsm", feature = "hash"))] + if self.per_block_checksums_enabled { + use core::hash::Hasher; + let mut h = twox_hash::XxHash64::with_seed(0); + h.write(&output[..n]); + self.computed_block_checksums.push(h.finish() as u32); + } + state.frame_finished = true; + return Ok(n); + } + } + } + // Borrow persistent fields out of whichever scratch variant // `init` produced (Flat for single_segment, Ring for // multi-segment) — both expose the same HUF/FSE/Vec From fca8fb0f5772b40311e5412f1059887b2116b0f5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 22:40:59 +0300 Subject: [PATCH 4/6] perf(decode): parse the frame header straight from the input slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory decode path holds the input as a &[u8], but the header parse went through read_frame_header_with_format's generic Read interface: 3-5 per-field read_exact calls dispatched through the io::impls Read-for-&[u8] shim plus per-byte little-endian assembly loops. Add read_frame_header_from_slice — direct byte indexing + from_le_bytes, advancing the slice exactly as the Read version does (identical skippable-frame / truncation contracts) — and route decode_all / the skippable-visitor path through a reset_from_slice that uses it, sharing the parsed-header apply with the Read path. Mirrors upstream zstd parsing the header from a raw pointer. Byte-identical; the streaming Read path is unchanged. --- zstd/src/decoding/frame.rs | 88 ++++++++++++++++++++++++++++++ zstd/src/decoding/frame_decoder.rs | 86 ++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/zstd/src/decoding/frame.rs b/zstd/src/decoding/frame.rs index c4cec87f9..afce0f4be 100644 --- a/zstd/src/decoding/frame.rs +++ b/zstd/src/decoding/frame.rs @@ -106,6 +106,94 @@ pub fn read_frame_header_with_format( Ok((frame_header, bytes_read as u8)) } +/// Slice-direct equivalent of [`read_frame_header_with_format`]: parses the +/// header straight out of `input` with byte indexing + `from_le_bytes`, instead +/// of per-field `read_exact` calls through the `Read` trait, and advances +/// `*input` past the consumed bytes EXACTLY as the `Read` version does — so the +/// skippable-frame and truncation contracts are byte-identical. The decode path +/// already holds a `&[u8]`, so this avoids the `io::impls` `Read`-trait +/// dispatch the generic version pays per field, mirroring upstream zstd, which +/// parses the header from a raw pointer (`MEM_readLE32` / byte reads). +pub(crate) fn read_frame_header_from_slice( + input: &mut &[u8], + magicless: bool, +) -> Result<(FrameHeader, u8), ReadFrameHeaderError> { + use ReadFrameHeaderError as err; + fn eof() -> crate::io::Error { + crate::io::Error::from(crate::io::ErrorKind::UnexpectedEof) + } + fn take<'a>(input: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + if input.len() < n { + return None; + } + let (head, tail) = input.split_at(n); + *input = tail; + Some(head) + } + + let mut bytes_read: u8 = 0; + if !magicless { + let m = take(input, 4).ok_or_else(|| err::MagicNumberReadError(eof()))?; + let magic_num = u32::from_le_bytes([m[0], m[1], m[2], m[3]]); + bytes_read = 4; + if (0x184D2A50..=0x184D2A5F).contains(&magic_num) { + let s = take(input, 4).ok_or_else(|| err::FrameDescriptorReadError(eof()))?; + let skip_size = u32::from_le_bytes([s[0], s[1], s[2], s[3]]); + return Err(ReadFrameHeaderError::SkipFrame { + magic_number: magic_num, + length: skip_size, + }); + } + if magic_num != MAGIC_NUM { + return Err(ReadFrameHeaderError::BadMagicNumber(magic_num)); + } + } + + let d = take(input, 1).ok_or_else(|| err::FrameDescriptorReadError(eof()))?; + let desc = FrameDescriptor(d[0]); + bytes_read += 1; + + let mut frame_header = FrameHeader { + descriptor: FrameDescriptor(desc.0), + dict_id: None, + frame_content_size: 0, + window_descriptor: 0, + }; + + if !desc.single_segment_flag() { + let w = take(input, 1).ok_or_else(|| err::WindowDescriptorReadError(eof()))?; + frame_header.window_descriptor = w[0]; + bytes_read += 1; + } + + let dict_id_len = desc.dictionary_id_bytes()? as usize; + if dict_id_len != 0 { + let b = take(input, dict_id_len).ok_or_else(|| err::DictionaryIdReadError(eof()))?; + bytes_read += dict_id_len as u8; + let mut buf4 = [0u8; 4]; + buf4[..dict_id_len].copy_from_slice(b); + let dict_id = u32::from_le_bytes(buf4); + if dict_id != 0 { + frame_header.dict_id = Some(dict_id); + } + } + + let fcs_len = desc.frame_content_size_bytes()? as usize; + if fcs_len != 0 { + let b = take(input, fcs_len).ok_or_else(|| err::FrameContentSizeReadError(eof()))?; + bytes_read += fcs_len as u8; + let mut buf8 = [0u8; 8]; + buf8[..fcs_len].copy_from_slice(b); + let mut fcs = u64::from_le_bytes(buf8); + if fcs_len == 2 { + fcs += 256; + } + frame_header.frame_content_size = fcs; + } + + Ok((frame_header, bytes_read)) +} + /// A frame header has a variable size, with a minimum of 2 bytes, and a maximum of 14 bytes. pub struct FrameHeader { pub descriptor: FrameDescriptor, diff --git a/zstd/src/decoding/frame_decoder.rs b/zstd/src/decoding/frame_decoder.rs index 9dd21e75d..1d5a03515 100644 --- a/zstd/src/decoding/frame_decoder.rs +++ b/zstd/src/decoding/frame_decoder.rs @@ -860,6 +860,16 @@ impl FrameDecoderState { magicless: bool, ) -> Result { let (frame, header_size) = frame::read_frame_header_with_format(source, magicless)?; + Self::new_with_parsed_header(frame, header_size) + } + + /// Build a fresh state from an already-parsed frame header (the non-parsing + /// tail of [`new_with_format`]). Shared by the `Read` path and the + /// slice-direct path ([`FrameDecoder::reset_from_slice`]). + pub(crate) fn new_with_parsed_header( + frame: frame::FrameHeader, + header_size: u8, + ) -> Result { let window_size = frame.window_size()?; if window_size > MAXIMUM_ALLOWED_WINDOW_SIZE { @@ -906,6 +916,18 @@ impl FrameDecoderState { magicless: bool, ) -> Result<(), FrameDecoderError> { let (frame_header, header_size) = frame::read_frame_header_with_format(source, magicless)?; + self.reset_with_parsed_header(frame_header, header_size) + } + + /// Apply an already-parsed frame header to this state (the non-parsing tail + /// of [`reset_with_format`]). Shared by the `Read` path and the slice-direct + /// path ([`FrameDecoder::reset_from_slice`]). + #[inline] + pub(crate) fn reset_with_parsed_header( + &mut self, + frame_header: frame::FrameHeader, + header_size: u8, + ) -> Result<(), FrameDecoderError> { let window_size = frame_header.window_size()?; if window_size > MAXIMUM_ALLOWED_WINDOW_SIZE { @@ -1281,6 +1303,64 @@ impl FrameDecoder { Ok(()) } + /// Slice-direct equivalent of [`reset`](Self::reset) for the in-memory + /// decode path: parses the frame header straight out of `*input` via + /// [`frame::read_frame_header_from_slice`] (no `Read`-trait `read_exact` + /// per field) and advances `*input` past it, then applies it through the + /// shared parsed-header path. Behaviour — including skippable-frame and + /// truncation errors, dictionary-id resolution, and pinned-expectation + /// validation — is identical to `reset`; only the header read avoids the + /// `io::impls` dispatch. + pub(crate) fn reset_from_slice(&mut self, input: &mut &[u8]) -> Result<(), FrameDecoderError> { + use FrameDecoderError as err; + #[cfg(all(feature = "lsm", feature = "hash"))] + self.computed_block_checksums.clear(); + let magicless = self.magicless; + let (frame_header, header_size) = frame::read_frame_header_from_slice(input, magicless)?; + let dict_id = match &mut self.state { + Some(s) => { + s.reset_with_parsed_header(frame_header, header_size)?; + s.frame_header.dictionary_id() + } + None => { + self.state = Some(FrameDecoderState::new_with_parsed_header( + frame_header, + header_size, + )?); + self.state + .as_ref() + .and_then(|state| state.frame_header.dictionary_id()) + } + }; + #[cfg(feature = "lsm")] + if let Some(state) = self.state.as_ref() { + self.validate_expectations(&state.frame_header)?; + } + if let Some(dict_id) = dict_id { + let state = self.state.as_mut().expect("state initialized"); + let owned_dicts = &self.owned_dicts; + #[cfg(target_has_atomic = "ptr")] + let shared_dicts = &self.shared_dicts; + let dict = owned_dicts + .get(&dict_id) + .or_else(|| { + #[cfg(target_has_atomic = "ptr")] + { + shared_dicts.get(&dict_id) + } + #[cfg(not(target_has_atomic = "ptr"))] + { + None + } + }) + .ok_or(err::DictNotProvided { dict_id })?; + state.decoder_scratch.init_from_dict(dict); + state.set_active_dict(dict); + state.using_dict = Some(dict_id); + } + Ok(()) + } + /// Reset this decoder for a new frame using a pre-parsed dictionary handle. /// /// If the frame header has a dictionary ID, this validates it against @@ -2286,11 +2366,11 @@ impl FrameDecoder { ) -> Result { #[cfg(not(feature = "lsm"))] { - self.decode_all_impl(input, output, |this, src| this.init(src)) + self.decode_all_impl(input, output, |this, src| this.reset_from_slice(src)) } #[cfg(feature = "lsm")] { - self.decode_all_impl(input, output, |this, src| this.init(src), None) + self.decode_all_impl(input, output, |this, src| this.reset_from_slice(src), None) } } @@ -2341,7 +2421,7 @@ impl FrameDecoder { self.decode_all_impl( input, output, - |this, src| this.init(src), + |this, src| this.reset_from_slice(src), Some(&mut visitor), ) } From 90f41274a46b17713004749d6f2213dbc8acfd1b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 23:04:45 +0300 Subject: [PATCH 5/6] test(fse): add regression for is_populated missing RLE tables build_rle sets decode_len=1 but leaves accuracy_log=0, so the accuracy_log-based is_populated misreports an RLE sequence table as unpopulated. The per-frame scratch reset gates on is_populated, so a used RLE table is not cleared and a later Repeat-mode frame reads stale state. Failing test pins decode_len as the built signal. --- zstd/src/fse/fse_decoder/tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/zstd/src/fse/fse_decoder/tests.rs b/zstd/src/fse/fse_decoder/tests.rs index d861ebb17..b93671a58 100644 --- a/zstd/src/fse/fse_decoder/tests.rs +++ b/zstd/src/fse/fse_decoder/tests.rs @@ -123,3 +123,21 @@ fn to_encoder_table_rejects_accuracy_log_above_nine() { "accuracy_log > 9 must not be reusable as an encoder table" ); } + +/// Regression: `is_populated` must report a built table for RLE tables, not just +/// FSE-mode ones. `build_rle` sets `decode_len = 1` but leaves `accuracy_log` at +/// 0 (a valid RLE DTable's tableLog), so an `accuracy_log != 0` check would miss +/// it — and the per-frame scratch reset that gates on `is_populated` would then +/// SKIP clearing a used RLE sequence table, letting a later Repeat-mode frame +/// read stale RLE state. The "built" signal is `decode_len != 0`, matching the +/// uninitialized check `init_state` uses. +#[test] +fn is_populated_detects_built_rle_table() { + let mut t = SeqFSETable::new(9); + assert!(!t.is_populated(), "a fresh table must report unpopulated"); + t.build_rle(7); + assert!( + t.is_populated(), + "an RLE table (decode_len = 1, accuracy_log = 0) must report populated" + ); +} From d4e097126d20659cff576b365c6dcf8bef8e475c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 29 Jun 2026 23:07:06 +0300 Subject: [PATCH 6/6] fix(decode): detect RLE tables in is_populated; consume checksum without hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_populated now tests decode_len != 0, not accuracy_log != 0: a valid RLE sequence DTable has decode_len = 1 but accuracy_log = 0, so the accuracy_log check left a used RLE table uncleared by the per-frame scratch reset, letting a later Repeat-mode frame read stale RLE state. decode_len is the same built/ uninitialized signal init_state uses. In the single-RAW-block fast path, the trailing content-checksum read was wholly behind #[cfg(feature = hash)]. Without hash a checksummed frame left the 4 bytes in the input (misparsed as the next frame) and never set check_sum (is_finished stayed false). Move the byte consumption + counter + check_sum out of the cfg — matching the general direct loop and decode_blocks, which gate only the hashing. Verified no-std/no-hash builds clean; the bug is build-config-specific so it is not exercisable in the hash-enabled test suite. --- zstd/src/decoding/frame_decoder.rs | 9 ++++++++- zstd/src/fse/fse_decoder.rs | 16 ++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/zstd/src/decoding/frame_decoder.rs b/zstd/src/decoding/frame_decoder.rs index 1d5a03515..8b0808201 100644 --- a/zstd/src/decoding/frame_decoder.rs +++ b/zstd/src/decoding/frame_decoder.rs @@ -3050,7 +3050,13 @@ impl FrameDecoder { *input = &probe[n..]; state.bytes_read_counter += u64::from(hsize) + n as u64; state.block_counter += 1; - #[cfg(feature = "hash")] + // Consume the trailing 4-byte content checksum UNCONDITIONALLY + // when the frame declares one — exactly like the general + // direct loop and `decode_blocks`. Only the hash SEEDING is + // `hash`-gated; the byte consumption / counter / `check_sum` + // must not be, or a no-`hash` build leaves the 4 bytes in + // `*input` (misparsed as the next frame) and never sets + // `check_sum` (so `is_finished` stays false). if state.frame_header.descriptor.content_checksum_flag() { let mut chksum = [0u8; 4]; Read::read_exact(input, &mut chksum).map_err(err::FailedToReadChecksum)?; @@ -3059,6 +3065,7 @@ impl FrameDecoder { // Mirror the general path: seed the scratch hash so // `verify_content_checksum` / `get_calculated_checksum` // read the digest. Skipped under `ContentChecksum::None`. + #[cfg(feature = "hash")] if self.content_checksum != ContentChecksum::None { use core::hash::Hasher; let mut h = twox_hash::XxHash64::with_seed(0); diff --git a/zstd/src/fse/fse_decoder.rs b/zstd/src/fse/fse_decoder.rs index 913122c43..7ab6f06df 100644 --- a/zstd/src/fse/fse_decoder.rs +++ b/zstd/src/fse/fse_decoder.rs @@ -391,14 +391,18 @@ impl FSETableImpl { self.accuracy_log = 0; } - /// Whether this table holds a built decode table (`accuracy_log > 0`). A - /// just-reset / never-built table reads as `false`; resetting it again is a - /// no-op, so the per-frame scratch reset can skip it (mirrors upstream zstd, - /// which never clears entropy tables per frame — it marks them invalid by - /// flag and rebuilds lazily). + /// Whether this table holds a built decode table. A just-reset / never-built + /// table reads as `false`; resetting it again is a no-op, so the per-frame + /// scratch reset can skip it (mirrors upstream zstd, which never clears + /// entropy tables per frame — it marks them invalid by flag and rebuilds + /// lazily). The signal is `decode_len != 0`, NOT `accuracy_log != 0`: a valid + /// RLE DTable (`build_rle`) has `decode_len == 1` but `accuracy_log == 0`, so + /// an `accuracy_log` check would miss it and leave a used RLE table uncleared + /// for a later Repeat-mode frame. `init_state` uses the same `decode_len` + /// signal for "uninitialized". #[inline] pub(crate) fn is_populated(&self) -> bool { - self.accuracy_log != 0 + self.decode_len != 0 } /// Build the equivalent encoder-side table from a parsed decoder table.