Problem
Two reports against v0.0.53, both at level 19 without a dictionary.
1. Compression of 4–64 KiB inputs "twice as slow, with different bytes"
Measured on the i9 (x86_64, AVX2), v0.0.52 against v0.0.53, 4/16/64 KiB cuts of an access log and of decodecorpus/z000033:
| path |
cycles v0.0.53 / v0.0.52 |
bytes |
a fresh FrameCompressor per frame |
0.90–0.97 |
identical |
one reused FrameCompressor |
1.69–1.87 |
changed |
C ABI ZSTD_compressCCtx / ZSTD_compress2 / ZSTD_compressStream2, one reused context, 4–64 KiB frames |
0.3–1.0 (streaming is 2–3x faster) |
changed |
CLI -19, one file per input |
not timed |
identical |
The slowdown is real on a reused compressor, and it is the reuse fix from #502, not a regression. btultra2 seeds its price statistics with a first pass over a frame's first block (upstream ZSTD_compressBlock_btultra2, zstd_opt.c:1509, run whenever ms->opt.litLengthSum == 0 at the start of a frame, which ZSTD_invalidateMatchState resets on every context reset, zstd_compress.c:1957). Upstream documents the cost as "2x cpu time on first block". v0.0.52 gated that pass on absolute position zero, so a reused compressor skipped it from its second frame on: faster, but emitting different (mostly larger) frames than a fresh compressor and than libzstd. v0.0.53 runs it on every frame, so a reused compressor now emits exactly what a fresh one does, and through the C ABI 16–64 KiB frames are byte-identical to libzstd at the same speed (4 KiB ZSTD_compressCCtx: 315 ms against libzstd's 312 ms for 1 MiB).
What is wrong is the announcement: #502 states that levels 16+ move "by at most ±0.003%" on small inputs, which holds for a fresh encoder only. On a reused context frames change by up to 1.6% (z000033, 16 KiB), and the v0.0.53 release notes do not mention an output change at all.
2. Decompression "twice as slow"
Not reproduced between the releases, on either architecture:
| path |
v0.0.53 / v0.0.52 |
Rust decode_all / StreamingDecoder, one libzstd frame, L19 and L-1, i9 |
1.07–1.08 (instructions +0.5%: layout, same work) |
| same, M1 |
1.00 |
C ABI ZSTD_decompressDCtx / ZSTD_decompress / ZSTD_decompressStream |
0.99–1.008 |
CLI -d |
1.00 |
| our own L19 frames |
byte-identical between the releases |
What is 2x is against libzstd, and only on the streaming path: a ZSTD_DStream created per frame decodes an 8 MiB level-19 frame (8 MiB window) in 9.44 ms against libzstd's 4.79 ms, and a reused one in 6.6 ms against 4.6 ms. The ring buffer behind FrameDecoder::decode_from_to is the cause, twice over:
decode_from_to decodes every block its input holds before draining any of it into the caller's buffer. Given a whole frame, the ring grows to the frame's content size instead of one window.
- The ring grows by the amortized policy,
next_power_of_two(needed) + 1: once a power-of-two window is full, the reservation for the next block doubles the ring to twice the window, copying the whole window across and zero-filling the new allocation (glibc serves the second and later allocations from the heap, so alloc_zeroed pays a real memset).
A profile of the per-frame stream shows RingBuffer::reserve_amortized at 8.6% in memmove and 8.4% in memset of the whole decode, on top of the drain copy. Upstream ZSTD_decompressStream decodes one block into a buffer of ZSTD_decodingBufferSize_min (window + block + slack, capped by the content size) and flushes it before it decodes the next.
The same cost is far larger on a one-shot decode of a frame that declares no content size, the shape every streaming producer emits: ZSTD_decompress of a 4 KiB level-19 frame (8 MiB declared window) takes 186 µs against libzstd's 4.3 µs. decode_all sends such a frame through the drain path, which reserves (and zeroes) the whole declared window per call; upstream ZSTD_decompressDCtx decodes straight into dst and needs no window buffer at all.
Solution
decode_from_to drains into target before decoding each block and stops decoding while target is full, so the ring never holds more than one window plus the block being decoded, however much input the caller hands over.
- The ring's amortized growth is capped per frame at one window plus
min(window, MAX_BLOCK_SIZE): doubling stays for small frames, and the step that would overshoot to twice the window lands on the cap instead.
- Where a frame already reserves its window up front (
decode_blocks / decode_all fallback, content-size capped), reserve window plus block instead, so filling the window costs no copy; a streamed frame that declares its size gets that buffer in one allocation on its first decode_from_to call. Frames of unknown size keep growing lazily, so a small frame is never charged a window-sized zeroed allocation.
decode_all decodes a frame of unknown size straight into the caller's slice, the slice being its limit (TargetTooSmall past it), as upstream decodes into dst.
Acceptance criteria
- A frame longer than its window, streamed through
decode_from_to in 128 KiB steps with the whole frame as input, keeps the decoder's workspace under 1.5x the window (unit test; today it holds the whole content).
- C ABI
ZSTD_decompressStream, per-frame and reused ZSTD_DStream, 8 MiB L19 frame: time and page faults against v0.0.53 and libzstd, before/after on the i9; decode output byte-identical.
- No regression on small streamed frames (4 KiB) or on
decode_all.
- The v0.0.53 release notes gain a note on the reused-context output change at levels 19+.
Estimate: 1d 2h (4h ring growth and drain, 3h tests, 3h measurement, 2h review overhead).
Problem
Two reports against v0.0.53, both at level 19 without a dictionary.
1. Compression of 4–64 KiB inputs "twice as slow, with different bytes"
Measured on the i9 (x86_64, AVX2), v0.0.52 against v0.0.53, 4/16/64 KiB cuts of an access log and of
decodecorpus/z000033:FrameCompressorper frameFrameCompressorZSTD_compressCCtx/ZSTD_compress2/ZSTD_compressStream2, one reused context, 4–64 KiB frames-19, one file per inputThe slowdown is real on a reused compressor, and it is the reuse fix from #502, not a regression. btultra2 seeds its price statistics with a first pass over a frame's first block (upstream
ZSTD_compressBlock_btultra2,zstd_opt.c:1509, run wheneverms->opt.litLengthSum == 0at the start of a frame, whichZSTD_invalidateMatchStateresets on every context reset,zstd_compress.c:1957). Upstream documents the cost as "2x cpu time on first block". v0.0.52 gated that pass on absolute position zero, so a reused compressor skipped it from its second frame on: faster, but emitting different (mostly larger) frames than a fresh compressor and than libzstd. v0.0.53 runs it on every frame, so a reused compressor now emits exactly what a fresh one does, and through the C ABI 16–64 KiB frames are byte-identical to libzstd at the same speed (4 KiBZSTD_compressCCtx: 315 ms against libzstd's 312 ms for 1 MiB).What is wrong is the announcement: #502 states that levels 16+ move "by at most ±0.003%" on small inputs, which holds for a fresh encoder only. On a reused context frames change by up to 1.6% (z000033, 16 KiB), and the v0.0.53 release notes do not mention an output change at all.
2. Decompression "twice as slow"
Not reproduced between the releases, on either architecture:
decode_all/StreamingDecoder, one libzstd frame, L19 and L-1, i9ZSTD_decompressDCtx/ZSTD_decompress/ZSTD_decompressStream-dWhat is 2x is against libzstd, and only on the streaming path: a
ZSTD_DStreamcreated per frame decodes an 8 MiB level-19 frame (8 MiB window) in 9.44 ms against libzstd's 4.79 ms, and a reused one in 6.6 ms against 4.6 ms. The ring buffer behindFrameDecoder::decode_from_tois the cause, twice over:decode_from_todecodes every block its input holds before draining any of it into the caller's buffer. Given a whole frame, the ring grows to the frame's content size instead of one window.next_power_of_two(needed) + 1: once a power-of-two window is full, the reservation for the next block doubles the ring to twice the window, copying the whole window across and zero-filling the new allocation (glibc serves the second and later allocations from the heap, soalloc_zeroedpays a real memset).A profile of the per-frame stream shows
RingBuffer::reserve_amortizedat 8.6% inmemmoveand 8.4% inmemsetof the whole decode, on top of the drain copy. UpstreamZSTD_decompressStreamdecodes one block into a buffer ofZSTD_decodingBufferSize_min(window + block + slack, capped by the content size) and flushes it before it decodes the next.The same cost is far larger on a one-shot decode of a frame that declares no content size, the shape every streaming producer emits:
ZSTD_decompressof a 4 KiB level-19 frame (8 MiB declared window) takes 186 µs against libzstd's 4.3 µs.decode_allsends such a frame through the drain path, which reserves (and zeroes) the whole declared window per call; upstreamZSTD_decompressDCtxdecodes straight intodstand needs no window buffer at all.Solution
decode_from_todrains intotargetbefore decoding each block and stops decoding whiletargetis full, so the ring never holds more than one window plus the block being decoded, however much input the caller hands over.min(window, MAX_BLOCK_SIZE): doubling stays for small frames, and the step that would overshoot to twice the window lands on the cap instead.decode_blocks/decode_allfallback, content-size capped), reserve window plus block instead, so filling the window costs no copy; a streamed frame that declares its size gets that buffer in one allocation on its firstdecode_from_tocall. Frames of unknown size keep growing lazily, so a small frame is never charged a window-sized zeroed allocation.decode_alldecodes a frame of unknown size straight into the caller's slice, the slice being its limit (TargetTooSmallpast it), as upstream decodes intodst.Acceptance criteria
decode_from_toin 128 KiB steps with the whole frame as input, keeps the decoder's workspace under 1.5x the window (unit test; today it holds the whole content).ZSTD_decompressStream, per-frame and reusedZSTD_DStream, 8 MiB L19 frame: time and page faults against v0.0.53 and libzstd, before/after on the i9; decode output byte-identical.decode_all.Estimate: 1d 2h (4h ring growth and drain, 3h tests, 3h measurement, 2h review overhead).