Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
10ce499
perf(decode): share dictionary by handle instead of per-frame copy
polaz Jun 5, 2026
cadab08
perf(decode): copy only decode-essential entropy on dict reinit
polaz Jun 5, 2026
1c830ae
test(bench): measure compress-dict steady-state with reused context
polaz Jun 5, 2026
007864f
perf(encode): cache primed dict matcher state (CDict-equivalent)
polaz Jun 5, 2026
32a4ebe
Revert "perf(encode): cache primed dict matcher state (CDict-equivale…
polaz Jun 5, 2026
7f04de0
Reapply "perf(encode): cache primed dict matcher state (CDict-equival…
polaz Jun 5, 2026
3a2a5eb
perf(encode): gate dict prime-snapshot copy to large sources
polaz Jun 5, 2026
677b163
perf(encode): size block-read buffer to source hint, not always 128 KiB
polaz Jun 5, 2026
9bcb13b
refactor(encode): bound block-read sizing without saturating arithmetic
polaz Jun 5, 2026
a9b0f38
perf(encode): share dictionary entropy tables by handle, not per-fram…
polaz Jun 5, 2026
4a05b14
perf(encode): cache Fast dict table across attach-path frames
polaz Jun 5, 2026
3e8be02
perf(decode): move decompression-bomb ceiling off the per-match hot path
polaz Jun 5, 2026
d415f54
docs(encode): note retire_dictionary_budget saturating_sub is a real …
polaz Jun 5, 2026
7f3caab
fix(encode): key dict prime snapshot on resolved matcher shape
polaz Jun 6, 2026
8c1e350
perf(decode): single-compare sequence bounds check + per-block bomb c…
polaz Jun 6, 2026
3255616
fix(encode): scope fast_attach to Simple backend in prime snapshot key
polaz Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 48 additions & 49 deletions zstd/benches/compare_ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use structured_zstd::decoding::FrameDecoder;
use structured_zstd::dictionary::{
FastCoverOptions, FinalizeOptions, finalize_raw_dict, train_fastcover_raw_from_slice,
};
use structured_zstd::encoding::FrameCompressor;
use structured_zstd::encoding::{EncoderDictionary, FrameCompressor};
use support::{
LevelConfig, Scenario, ScenarioClass, benchmark_scenarios, kernel_report_line,
supported_levels_filtered,
Expand Down Expand Up @@ -651,60 +651,59 @@ fn bench_dictionary(c: &mut Criterion) {
})
});

// c_ffi_with_dict + pure_rust_with_dict: both construct
// their compressor + attach dict INSIDE `b.iter` so the
// measurement covers the same shape on both sides.
// Asymmetric setup-vs-iter placement was flagged in
// PR #277 review (Copilot threads #2/#3/#4): the Rust
// FrameCompressor API stores `set_drain`'s `&mut Vec<u8>`
// inside the compressor for its full lifetime, so a
// "compressor outside b.iter, fresh Vec inside" pattern
// doesn't type-check. Rather than gymnast around that,
// match the per-iter shape on both arms — the FFI per-
// iter construction cost is small (`Compressor::with_
// dictionary` is a single CDict reference attach into a
// freshly-allocated CCtx) and stays comparable to the
// Rust per-iter `FrameCompressor::new +
// set_dictionary_from_bytes`.
// 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
// real prepared-dictionary lifecycle (C `CDict` created once +
// `ZSTD_compress_usingCDict` per frame; Rust prepared
// `EncoderDictionary` + `compress_independent_frame` per frame).
// The earlier per-iter shape (fresh compressor + dict parse every
// iteration) measured one-time setup cost, not steady-state
// throughput, and unfairly penalised the side with heavier
// per-attach setup. `compress_independent_frame_into` reads the
// input in place and takes the output buffer per call, so it needs
// no `set_drain`/`set_source` — sidestepping the lifetime issue
// (PR #277) that forced the old per-iter shape.
group.bench_function("c_ffi_with_dict", |b| {
b.iter(|| {
let mut compressor =
zstd::bulk::Compressor::with_dictionary(level.ffi_level, &ffi_dictionary)
.unwrap();
black_box(compressor.compress(&scenario.bytes).unwrap())
})
let mut compressor =
zstd::bulk::Compressor::with_dictionary(level.ffi_level, &ffi_dictionary)
.unwrap();
b.iter(|| black_box(compressor.compress(&scenario.bytes).unwrap()))
});

// Gate pure_rust_with_dict registration on the same
// `rust_dict_handle.is_some()` signal that
// decompress-dict uses below — if DictionaryHandle::
// decode_dict failed earlier (the per-scenario parse
// above), FrameCompressor::set_dictionary_from_bytes
// routes through the same Dictionary::decode_dict and
// would fail identically. An `.expect()` panic inside
// b.iter would abort the whole bench suite.
if let Some(preallocated_capacity) = rust_with_dict_len {
// `rust_with_dict_len` above already did the one-time
// pre-compress that learns the Rust encoder's actual output
// size at this (scenario, level, dict) — reuse it as the
// timing-loop preallocation hint. Using `with_dict_bytes.len()`
// (the FFI-side size) would under-allocate when Rust emits a
// slightly larger frame, forcing `Vec` reallocations inside the
// timing loop and skewing the measurement vs FFI (which always
// allocates exact output size internally).
// `rust_dict_handle.is_some()` signal that decompress-dict uses
// below — if the per-scenario dictionary parse failed earlier,
// `EncoderDictionary::from_bytes` routes through the same parse and
// would fail identically; an `.expect()` panic before `b.iter`
// would abort the whole bench suite.
if let Some(preallocated_capacity) = rust_with_dict_len
&& EncoderDictionary::from_bytes(&ffi_dictionary).is_ok()
{
group.bench_function("pure_rust_with_dict", |b| {
// `compress_independent_frame_into` reads input in
// place + takes the output buffer per call, so neither
// the source `R` nor drain `W` generic is ever bound by
// a `set_source`/`set_drain` call — pin them to the
// defaults so inference has a concrete type.
let mut compressor: FrameCompressor = FrameCompressor::new(level.rust_level);
compressor
.set_encoder_dictionary(
EncoderDictionary::from_bytes(&ffi_dictionary)
.expect("dictionary parse checked above"),
)
.expect("prepared dictionary should attach");
// Reuse one output buffer across iterations (the
// CCtx-equivalent caller-owned `dst`). `rust_with_dict_len`
// pre-sizes it so no realloc happens inside the loop.
let mut compressed = Vec::with_capacity(preallocated_capacity);
b.iter(|| {
let mut compressor = FrameCompressor::new(level.rust_level);
compressor
.set_dictionary_from_bytes(&ffi_dictionary)
.expect("dictionary should attach");
compressor.set_source_size_hint(scenario.bytes.len() as u64);
compressor.set_source(scenario.bytes.as_slice());
let mut compressed = Vec::with_capacity(preallocated_capacity);
compressor.set_drain(&mut compressed);
compressor.compress();
black_box(compressed)
})
compressor.compress_independent_frame_into(
scenario.bytes.as_slice(),
&mut compressed,
);
black_box(&compressed);
});
});
}

Expand Down
47 changes: 47 additions & 0 deletions zstd/src/decoding/buffer_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ use crate::io::{Error, Read};
/// slack contract cannot drift between backends.
pub(crate) const WILDCOPY_OVERLENGTH: usize = 32;

/// Single-compare output-capacity guard for the inline sequence-exec hot
/// path, shared by every [`BufferBackend::exec_sequence_inline`] /
/// `exec_sequence_inline_avx2` override so the per-sequence bounds check has
/// one implementation instead of a duplicated `checked_add` chain.
///
/// Returns `lit_length + match_length` when the literal+match write plus
/// `overshoot` bytes of SIMD wildcopy slack fits within `cap - tail`;
/// otherwise [`ExecuteSequencesError::OutputBufferOverflow`](super::errors::ExecuteSequencesError::OutputBufferOverflow).
///
/// # Preconditions
/// - `tail <= cap`, so `cap - tail` cannot underflow. Holds for every
/// backend: `Vec::len() <= Vec::capacity()` on the flat / growable buffers,
/// and the user-slice tail only ever advances past this same check.
/// - `lit_length` and `match_length` are each bounded by the maximum FSE
/// LL/ML code expansion (~131 KB), so `total` and `total + overshoot`
/// cannot overflow `usize` even on 32-bit.
///
/// Each caller documents why the first precondition holds at its site; the
/// arithmetic safety of the second is the same FSE bound everywhere.
#[inline(always)]
pub(crate) fn sequence_output_fits(
lit_length: usize,
match_length: usize,
tail: usize,
cap: usize,
overshoot: usize,
) -> Result<usize, super::errors::ExecuteSequencesError> {
let total = lit_length + match_length;
if total + overshoot > cap - tail {
return Err(super::errors::ExecuteSequencesError::OutputBufferOverflow {
tail,
requested: total,
capacity: cap,
});
}
Ok(total)
}

/// Storage operations the decoder needs from its output buffer.
///
/// The trait surface mirrors the historical `RingBuffer` API the
Expand Down Expand Up @@ -226,6 +264,15 @@ pub(crate) trait BufferBackend: Sized {
Ok(())
}

/// Lower the per-block growth ceiling on backends whose `try_reserve`
/// may grow without bound. The block sequence decoder sets it to
/// `len + MAX_BLOCK_SIZE` per block so an over-producing match is rejected
/// on the cold growth path, bounding decompression-bomb OOMs. Overridden by
/// the streaming `RingBuffer` and the growable `FlatBuf` (whose fallback
/// `push`/`repeat` path grows through `try_reserve`). Default no-op for
/// fixed-capacity backends (`UserSliceBackend`), which are already bounded.
fn set_max_capacity(&mut self, _max_capacity: usize) {}

/// Live byte count: bytes between the logical head and tail.
fn len(&self) -> usize;

Expand Down
Loading
Loading