Skip to content

perf(decoding): bound the streaming ring at a window plus a block; the reused-context L19 output change is the btultra2 seed pass #508

Description

@polaz

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

  1. 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.
  2. 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.
  3. 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.
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions