Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 42 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ jobs:
run: cargo clippy -p structured-zstd --features hash,std,dict-builder -- -D warnings
- name: Clippy (testing facade)
run: cargo clippy -p structured-zstd --features hash,std,dict-builder,bench-internals -- -D warnings
- name: Clippy (caller-workspace, all targets)
# The feature swaps the entropy tables' build scratch for inline
# storage and adds the workspace decoder; lint both with their tests.
run: cargo clippy -p structured-zstd --all-targets --features hash,std,dict-builder,lsm,caller-workspace -- -D warnings
- name: Clippy (ffi-bench targets)
# The libzstd-linking benches/tests/examples live in `ffi-bench`.
# Lint them with the features their `required-features` gate on so the
Expand All @@ -130,8 +134,8 @@ jobs:
env:
RUSTDOCFLAGS: -D warnings
run: |
cargo doc -p structured-zstd --no-deps --features std,hash,ldm,dict-builder,lsm
cargo doc -p structured-zstd --no-deps --document-private-items --features std,hash,ldm,dict-builder,lsm
cargo doc -p structured-zstd --no-deps --features std,hash,ldm,dict-builder,lsm,caller-workspace
cargo doc -p structured-zstd --no-deps --document-private-items --features std,hash,ldm,dict-builder,lsm,caller-workspace
cargo doc -p structured-zstd-c -p structured-zstd-wasm --no-deps --document-private-items
- name: Gate — compare_ffi/memory must not pull bench-internals
# Rationale: both `compare_ffi` (timing) and `compare_ffi_memory`
Expand Down Expand Up @@ -385,9 +389,16 @@ jobs:
if: needs.changes.outputs.code == 'true'
working-directory: zstd
run: cargo nextest run --profile ci -p structured-zstd --features hash,std,dict-builder
- name: Test (caller-workspace)
# The whole suite again with the feature on: the shared decode core
# then runs on inline table storage, and the workspace decoder's own
# tests and its zero-allocation test join in.
if: needs.changes.outputs.code == 'true'
working-directory: zstd
run: cargo nextest run --profile ci -p structured-zstd --features hash,std,dict-builder,caller-workspace
- name: Doc tests
if: needs.changes.outputs.code == 'true'
run: cargo test --doc -p structured-zstd --features hash,std,dict-builder
run: cargo test --doc -p structured-zstd --features hash,std,dict-builder,caller-workspace
- name: FFI parity + cross-validation tests
if: needs.changes.outputs.code == 'true'
# The libzstd-linking parity / cross-validation / corpus tests live in
Expand Down Expand Up @@ -478,6 +489,9 @@ jobs:
- name: Clippy (embedded_minimal — kernel-scalar + std)
working-directory: zstd
run: cargo clippy -p structured-zstd --no-default-features --features kernel-scalar,std,hash -- -D warnings
- name: Clippy (no_std + caller-workspace)
working-directory: zstd
run: cargo clippy -p structured-zstd --no-default-features --features caller-workspace,hash -- -D warnings
# The clippy steps above compile for the host, which has SSE2 and a
# float unit. A kernel target has neither (x86_64-unknown-none is
# -sse,-sse2,...,+soft-float), so a vector type that
Expand All @@ -493,6 +507,7 @@ jobs:
for profile in dev release; do
cargo build -p structured-zstd --lib --profile "$profile" --target x86_64-unknown-none --no-default-features --features hash
cargo build -p structured-zstd --lib --profile "$profile" --target x86_64-unknown-none --no-default-features --features kernel-avx2,hash
cargo build -p structured-zstd --lib --profile "$profile" --target x86_64-unknown-none --no-default-features --features caller-workspace,hash
done

wasm:
Expand Down Expand Up @@ -527,8 +542,24 @@ jobs:
# where a missing cargo-feature gate would go unnoticed, so keep it built.
- name: Clippy (wasm32 simd128 feature without +simd128)
run: cargo clippy -p structured-zstd -p structured-zstd-wasm --target wasm32-unknown-unknown --no-default-features --features kernel-simd128 -- -D warnings
- name: Clippy (wasm32 caller-workspace)
run: cargo clippy -p structured-zstd --target wasm32-unknown-unknown --no-default-features --features caller-workspace,hash,kernel-simd128 --config 'target.wasm32-unknown-unknown.rustflags=["-C","target-feature=+simd128"]' -- -D warnings
# The caller-workspace decoder is built for browser bundles, so its tests
# and its zero-allocation test run as wasm too, under wasmtime (the WASI
# target supplies the test harness's std).
- name: Install wasmtime
uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: wasmtime,cargo-nextest
- name: Workspace decoder tests (wasm32-wasip1)
env:
CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime
run: |
rustup target add wasm32-wasip1
cargo nextest run --profile ci -p structured-zstd --target wasm32-wasip1 --no-default-features --features caller-workspace,hash,std --lib -E 'test(/decoding::workspace::tests/)'
cargo nextest run --profile ci -p structured-zstd --target wasm32-wasip1 --no-default-features --features caller-workspace,hash,std --test workspace_no_alloc
- name: Install wasm-pack
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: wasm-pack
- uses: actions/setup-node@v7
Expand Down Expand Up @@ -594,7 +625,7 @@ jobs:
with:
prefix-key: wasm-bench
- name: Install wasm-pack
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: wasm-pack
- uses: actions/setup-node@v7
Expand Down Expand Up @@ -763,7 +794,7 @@ jobs:
prefix-key: fuzz
workspaces: zstd/fuzz
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-fuzz
- name: Replay regression corpus
Expand Down Expand Up @@ -793,6 +824,11 @@ jobs:
cargo fuzz run --target "$FUZZ_TARGET_TRIPLE" "$target" -- -max_total_time=90 -timeout=30
echo "::endgroup::"
done
- name: Fuzz decode_workspace (90s, caller-workspace build)
# Its own step: the target needs the library's caller-workspace
# feature, which the targets above must not get.
working-directory: zstd/fuzz
run: cargo fuzz run --target "$FUZZ_TARGET_TRIPLE" --features workspace decode_workspace -- -max_total_time=90 -timeout=30
- name: Upload new crash artifacts on failure
if: failure()
uses: actions/upload-artifact@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
with:
targets: wasm32-unknown-unknown
- name: Install wasm-pack
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: wasm-pack
- uses: actions/setup-node@v7
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,19 @@ reads the first frame header and reports any problem with the input, and
the source untouched, so a container format can decide not to decompress an
entry and copy its raw bytes instead.

### Decoding in caller-owned memory

Behind the `caller-workspace` feature, `decoding::workspace::WorkspaceDecoder`
decodes a stream chunk by chunk inside one byte slice the caller supplies,
and never allocates. `workspace_bytes(max_window)` says how large the slice
must be, so a consumer that accounts its own memory (a browser bundle
verifying a download, say) admits it before decoding starts. Each step is
bounded by the window a frame may declare, the total output, and a work
budget charged before the work it pays for; a refusal is reported apart from
malformed or truncated input. It works in `no_std` builds and in WebAssembly,
and decodes through the same code as the other decoders. Frames that need a
dictionary are refused.

### Dictionaries

```rust,no_run
Expand Down Expand Up @@ -294,6 +307,7 @@ in pure Rust:
| `kernel-scalar` | ✅ | Marker for the always-compiled scalar fallback |
| `dict-builder` | ❌ | Pure-Rust COVER / FastCOVER / legacy dictionary training |
| `lsm` | ❌ | [Storage-format extensions](#storage-format-extensions) |
| `caller-workspace` | ❌ | [Decoding in caller-owned memory](#decoding-in-caller-owned-memory) |

Each flag gates its tier wherever that tier exists. `kernel-sse`,
`kernel-bmi2`, `kernel-avx2`, `kernel-neon` and `kernel-simd128` cover both
Expand Down
24 changes: 23 additions & 1 deletion zstd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ required-features = ["std", "dict-builder", "ldm"]
# `#[cfg_attr(docsrs, doc(cfg(...)))]` annotations that render feature
# badges on each item.
[package.metadata.docs.rs]
features = ["std", "hash", "ldm", "dict-builder", "lsm"]
features = ["std", "hash", "ldm", "dict-builder", "lsm", "caller-workspace"]
rustdoc-args = ["--cfg", "docsrs"]

[dependencies]
Expand Down Expand Up @@ -182,6 +182,11 @@ ldm = ["hash"]
# targets with atomic pointer support this feature is a no-op.
critical-section = ["dep:critical-section"]
std = []
# Allocation-free streaming decoder over caller-owned workspace memory
# (`decoding::workspace`). Also stores the entropy tables' build scratch
# inline instead of in `Vec`s, which the workspace decoder needs; with the
# feature off the other decoders build exactly as before.
caller-workspace = []

# ---------------------------------------------------------------------------
# Internal features. NOT part of the public API: they widen the crate's
Expand Down Expand Up @@ -229,6 +234,23 @@ lsm = []
# stable interface of this crate.
rustc-dep-of-std = ["dep:compiler_builtins", "dep:core", "dep:alloc"]

[[test]]
name = "workspace_no_alloc"
path = "tests/workspace_no_alloc.rs"
required-features = ["caller-workspace", "std"]

# Pure-Rust examples of the caller-workspace decoder; unlike the examples the
# `ffi-bench` crate builds, they link no C code.
[[example]]
name = "workspace_stream_verify"
path = "examples/workspace_stream_verify.rs"
required-features = ["caller-workspace", "std"]

[[example]]
name = "workspace_decode_loop"
path = "examples/workspace_decode_loop.rs"
required-features = ["caller-workspace", "std"]

# All benches, integration tests, and FFI diagnostic examples are targets of
# the non-published `ffi-bench` crate (they link the C `zstd` bindings); their
# source files remain under `benches/`, `tests/`, and `examples/` here and are
Expand Down
135 changes: 135 additions & 0 deletions zstd/examples/workspace_decode_loop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Decode loop comparing the caller-workspace decoder with the existing ones
//! on the same build, for `perf stat` A/B runs.
//!
//! ```text
//! workspace_decode_loop <level> <iters> <mode> [file|synthetic] [window_log]
//! ```
//!
//! `mode` is `workspace` (the step decoder, 64 KiB input and output chunks),
//! `stream` (`StreamingDecoder` read through a 64 KiB buffer, the existing
//! streaming path) or `all` (`FrameDecoder::decode_all` into one buffer). The
//! input is compressed once with this crate's encoder at `level`; the default
//! file is the in-tree `decodecorpus_files/z000033`, and `synthetic` is random
//! bytes, which compress to Raw blocks. `window_log` caps the encoder's window
//! (default: the level's own) so small and large windows can be compared.

use std::hint::black_box;
use std::io::Read;

use structured_zstd::decoding::workspace::{
DecodeLimits, StepState, WorkBudget, WorkspaceDecoder, workspace_bytes,
};
use structured_zstd::decoding::{FrameDecoder, StreamingDecoder, read_frame_header_info};
use structured_zstd::encoding::{
CompressionLevel, CompressionParameters, compress_with_parameters,
};

const CHUNK: usize = 64 * 1024;

fn source(arg: Option<&str>) -> Vec<u8> {
match arg {
Some(s) if s.eq_ignore_ascii_case("synthetic") => {
let mut x = 0x517c_c1b7_2722_0a95u64;
(0..(1 << 20))
.map(|_| {
x = x
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(x >> 56) as u8
})
.collect()
}
Some(path) => std::fs::read(path).expect("read input file"),
None => {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/decodecorpus_files/z000033");
std::fs::read(path).expect("read decodecorpus_files/z000033")
}
}
}

fn compress(data: &[u8], level: i32, window_log: Option<u32>) -> Vec<u8> {
let level = CompressionLevel::Level(level);
let mut parameters = CompressionParameters::builder(level);
if let Some(log) = window_log {
parameters = parameters.window_log(log);
}
compress_with_parameters(data, &parameters.build().expect("parameters"))
}

fn main() {
let args: Vec<String> = std::env::args().collect();
let level: i32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(3);
let iters: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(1_000);
let mode = args.get(3).map_or("workspace", String::as_str);
let data = source(args.get(4).map(String::as_str));
let window_log: Option<u32> = args.get(5).and_then(|s| s.parse().ok());

let compressed = compress(&data, level, window_log);
let window = read_frame_header_info(&compressed, false)
.expect("frame header")
.window_size
.max(1024);
eprintln!(
"{} bytes -> {} at level {level}, window {window}, mode {mode}",
data.len(),
compressed.len()
);

let mut total = 0u64;
let start = std::time::Instant::now();
match mode {
"workspace" => {
let limits = DecodeLimits::new(window);
let mut workspace = vec![0u8; workspace_bytes(window).expect("window")];
let mut decoder = WorkspaceDecoder::new(&mut workspace, limits).expect("workspace");
let mut out = vec![0u8; CHUNK];
for _ in 0..iters {
decoder.reset();
let mut work = WorkBudget::new(u64::MAX);
let mut input = black_box(compressed.as_slice());
loop {
let chunk = &input[..input.len().min(CHUNK)];
let last = chunk.len() == input.len();
let progress = decoder
.step(chunk, last, &mut out, &mut work)
.expect("workspace decode");
total += black_box(&out[..progress.produced]).len() as u64;
input = &input[progress.consumed..];
if progress.state == StepState::Done {
break;
}
}
}
}
"stream" => {
let mut out = vec![0u8; CHUNK];
for _ in 0..iters {
let mut decoder = StreamingDecoder::new(black_box(compressed.as_slice()));
loop {
let n = decoder.read(&mut out).expect("stream decode");
if n == 0 {
break;
}
total += black_box(&out[..n]).len() as u64;
}
}
}
"all" => {
let mut target = vec![0u8; data.len() + 64];
let mut decoder = FrameDecoder::new();
for _ in 0..iters {
total += decoder
.decode_all(black_box(&compressed), &mut target)
.expect("decode_all") as u64;
}
}
other => panic!("unknown mode {other}; use workspace, stream or all"),
}
let elapsed = start.elapsed();
assert_eq!(total, data.len() as u64 * u64::from(iters));
eprintln!(
"{iters} iters, {:.0} ns/iter, {:.1} MB/s",
elapsed.as_nanos() as f64 / f64::from(iters),
total as f64 / elapsed.as_secs_f64() / 1e6
);
}
Loading
Loading