perf(decode): cut per-frame fixed overhead on the in-memory path#456
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughAdds slice-based frame header parsing and slice-direct decoder reset paths, wires them into ChangesSlice-direct decoding optimizations
Benchmark cleanup
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@zstd/src/decoding/frame_decoder.rs`:
- Around line 3053-3071: The fast path in `frame_decoder.rs` for single-block
terminal frames is incorrectly guarding checksum-byte consumption behind
`#[cfg(feature = "hash")]`, unlike the general `decode_blocks` and direct-loop
paths. Update the `content_checksum_flag()` handling in the
`state.frame_header.descriptor` branch so the 4 trailing checksum bytes are
always read, `bytes_read_counter` is always advanced, and `state.check_sum` is
always set; keep only the scratch hash seeding and `twox_hash::XxHash64` logic
behind the `hash` feature. This should mirror the existing checksum handling
used by `verify_content_checksum` / `get_calculated_checksum` and preserve
`is_finished()` behavior in no-`hash` builds.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: d264ea14-7145-408e-a792-708dd7ed5f7f
📒 Files selected for processing (5)
zstd/benches/compare_ffi.rszstd/src/decoding/frame.rszstd/src/decoding/frame_decoder.rszstd/src/decoding/scratch.rszstd/src/fse/fse_decoder.rs
…atrix
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.
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.
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.
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.
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.
…out hash 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.
7a52dbb to
d4e0971
Compare
Summary
Cuts the per-frame fixed overhead on the in-memory decode path, where it
dominates small / high-entropy frames. On the
small-1k-randomlevel-4 frame(a single RAW block — the shape random / already-compressed payloads always
take) the pure-Rust decode goes from ~86 ns to ~36 ns (−58 %) and now
beats the C reference (42.8 ns → 1.18×), up from 0.49× before. The wins are
byte-identical and generalize to other small frames (
small-10k-randomdecode−17 %); larger frames where the per-block work dominates see proportionally
less.
All four commits keep the wire output and decoded bytes identical (full decode +
fuzz_interopsuites pass); the streamingReaddecode path is untouched.Profiling note
The decode path was profiled on the x86_64 bench host with callgrind +
perf(built-Csplit-debuginfo=offso valgrind resolves Rust source lines).The per-frame machinery — not the block copy — was ~50 % of a tiny-frame decode;
__memcpy(the raw copy) is already at parity with C.Changes
Skip the per-frame entropy-table reset when unbuilt
The per-frame scratch reset cleared all three sequence FSE tables and the
Huffman table (buffers + a
[0; 13]rank-count memset) even on RAW / RLE /raw-literal frames that never build an entropy table. Upstream zstd's
ZSTD_decompressBeginnever clears entropy per frame — it marks tables invalidby flag and rebuilds lazily. Gate each reset on whether the table holds built
state (FSE
accuracy_log != 0, Huffmanmax_num_bits != 0); resetting analready-empty table is a no-op on observable state, so this is byte-identical.
Direct copy for a single-RAW-block frame
A frame that is one RAW block spanning the whole declared content went through
the full direct-decode machinery (
DirectScratch/DecodeBuffer/UserSliceBackendwrappers + the general per-block loop) for what is one memcpy.Mirror upstream's
ZSTD_copyRawBlock: detect the single last RAW block whosesize equals the frame content size and copy it straight into the caller slice,
with the same checksum / counter bookkeeping. Falls through to the general path
for every other shape.
Parse the frame header straight from the input slice
The in-memory path holds the input as a
&[u8], but the header parse wentthrough
read_frame_header_with_format's genericReadinterface: 3–5 per-fieldread_exactcalls dispatched through theio::implsRead-for-&[u8]shim,plus per-byte little-endian assembly loops. This was the single biggest
tiny-frame cost (the non-inlined
read_exactcall overhead far exceeds itsinstruction count). Add
read_frame_header_from_slice— direct byte indexing +from_le_bytes, advancing the slice exactly as theReadversion does(identical skippable-frame / truncation contracts) — and route
decode_all/the skippable-visitor path through a
reset_from_slicethat uses it, sharing theparsed-header apply with the
Readpath. Mirrors upstream parsing the headerfrom a raw pointer. This single change is −43 % on the 1 KiB frame.
Drop the redundant no-dict arm from the compress-dict bench matrix
c_ffi_without_dictre-measured C compressing the same level/scenario without adictionary — already covered by the plain
compress/{level}/{scenario}/matrix/ c_ffigroup, and it built the CCtx per-iter while the two dictionary arms aresteady-state, so it was not even a clean baseline. Keep only
c_ffi_with_dictand
pure_rust_with_dict.Testing
cargo nextest run -p structured-zstd --features hash,std,dict_builder— 837 passcargo nextest run -p ffi-bench --features bench_internals,dict_builder— 59 pass(includes
fuzz_interopround-trip + skippable-frame coverage)cargo clippy(default +--no-default-featuresno-std +--tests) clean;cargo fmt --checkcleancompare_ffidecode with a flatc_fficontrol arm; the three perf commits measured individually and cumulatively
Summary by CodeRabbit