Skip to content

Streaming decompressor: suspend at symbol boundaries, not only block boundaries - #6

Open
alexey-milovidov wants to merge 11 commits into
masterfrom
streaming-symbol-suspend
Open

Streaming decompressor: suspend at symbol boundaries, not only block boundaries#6
alexey-milovidov wants to merge 11 commits into
masterfrom
streaming-symbol-suspend

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

The streaming decompressor kept its resume checkpoint only at DEFLATE block boundaries: on input exhaustion or output-buffer overflow it rolled back to the start of the current block and reported everything after it as unconsumed. For streams whose single block spans the whole input — the shape zlib-ng's deflate_quick path (compression level 1, the default of the official .NET SDK) emits — every refill re-decoded the block from its start, making streaming decompression quadratic in the compressed block size and forcing the caller to buffer the whole block. This is the root cause of ClickHouse/ClickHouse#114045 (a 22 MB gzip HTTP body that took 0.3 s to ingest on ClickHouse 26.6 took 15 s on 26.7).

Now the checkpoint is re-taken at every symbol boundary in the generic decode loop, and two new decompressor fields (in_block, block_is_final) let a resumed call jump straight back into symbol decoding: the litlen/offset decode tables already persist in the decompressor between calls. The fastloop needs no checkpointing (and stays untouched): suspensions can only trigger from the generic loop, which always runs between the fastloop and input exhaustion. A suspension now loses at most one partially decoded symbol, so decompression is linear-time with O(1) leftover regardless of the block structure.

Also, the checkpoints now guard the case where implicit appended zero bytes were consumed (truncated data declared complete via end_of_input): that fails with LIBDEFLATE_BAD_DATA at the checkpoint instead of producing an inconsistent input position.

Verified with a standalone harness: 5000+ randomized round-trips (zlib streams at all levels with random flush points, plus crafted static- and dynamic-Huffman single-block streams with matches reaching the full 32 KiB window, input chunking down to 1 byte, random output regions), a progress-contract check on every suspension, zlib inflate as decode oracle, and the one-shot decoder as cross-check. Streaming throughput on normal multi-block streams is unchanged (685 vs 697 MB/s before/after on a 256 MB zlib-level-6 stream, x86-64).

Related: ClickHouse/ClickHouse#114045

🤖 Generated with Claude Code

alexey-milovidov and others added 11 commits June 21, 2026 17:54
libdeflate's compression API is one-shot: every call emits a complete,
terminated DEFLATE stream. This makes it impossible to back a streaming
compressor (HTTP responses, .gz files of unbounded size) without either
buffering the whole input or emitting a multi-member stream, which some
decoders read only partially.

Add libdeflate_deflate_compress_stream_chunk(), which compresses a chunk
as non-final, byte-aligned DEFLATE blocks (ending with an empty stored
"sync flush" block, like zlib's Z_SYNC_FLUSH). The outputs of consecutive
calls can be concatenated and terminated with a final block, producing a
single valid DEFLATE/gzip/zlib member while keeping memory bounded to one
chunk.

Implementation:
- A `stream_chunk` flag on the compressor makes deflate_flush_block()
  force every block non-final. Only the BFINAL bit value changes, not its
  cost, so the output-size accounting is unaffected.
- Small inputs (<= max_passthrough_size, and level 0) are emitted as
  non-final stored blocks.
- A sync-flush helper byte-aligns the output after compression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add libdeflate_deflate_compress_stream_chunk() for streaming compression
libdeflate's decompressor is one-shot: it decodes until BFINAL and cannot
resume on a partial stream, so it can't back a streaming decompressor for
arbitrarily large zlib/gzip/deflate input without buffering the whole thing.

Add a resumable variant that suspends at DEFLATE block boundaries. The only
cross-block state is {bitbuf, bitsleft} plus the 32 KiB history window, so
suspension is cheap: at each block boundary we checkpoint the byte-aligned
bit state, and if the input runs out (REFILL would overread and
end_of_input is false) or the output fills mid-block, we roll back to that
checkpoint and return STREAM_NEED_INPUT / STREAM_NEED_OUTPUT. The caller
supplies more input or drains output and calls again. Back-references are
resolved by having the caller place up to 32 KiB of previously produced
output immediately before 'out' and pass 'window_nbytes'; the match-offset
validation base is shifted accordingly.

The streaming code is a second instantiation of decompress_template.h guarded
by DEFLATE_STREAMING, so the existing one-shot decoder is byte-for-byte
unchanged (the only shared-macro change is a no-op OVERREAD_HANDLER() hook in
REFILL_BITS). A single block larger than the output buffer is handled by the
caller growing the buffer and retrying; mid-block suspension is intentionally
not supported.

Verified with 50000 randomized round-trip cases against zlib (random data
styles, sizes up to 3 MB, all levels, random input-chunk and output-region
sizes exercising both suspension paths and the window carry-over).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add libdeflate_deflate_decompress_stream() for streaming decompression
The one-shot decompressor selects a BMI2-optimized instantiation at runtime
(x86/decompress_impl.h), but libdeflate_deflate_decompress_stream() called a
single non-dispatched generic instantiation, so on BMI2-capable x86 the
streaming path was stuck on the baseline code.

Move the streaming instantiation into its own section after the one-shot
dispatch (so DEFLATE_STREAMING doesn't leak into the one-shot BMI2 variant),
add x86/decompress_stream_impl.h mirroring x86/decompress_impl.h, and route
the public streaming entry through a dispatched function pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give the streaming decompressor the x86 BMI2 runtime dispatch
The crc32/adler32/decompress/stream-decompress dispatchers cache the
runtime-resolved best implementation in a function pointer on the first
call. The pointer was 'volatile', so the benign first-call race (every
thread resolves the same pointer, a pure function of the CPU) was still
undefined behavior and was flagged by ThreadSanitizer.

Access the pointer with relaxed atomics (__atomic_load_n/__atomic_store_n)
instead. The generated code is unchanged on real hardware, but the access
is now well-defined and TSan-clean. Relaxed ordering is sufficient because
no other memory is published through the pointer.
Make the lazy runtime CPU-feature dispatch thread-safe (relaxed atomics
instead of volatile) so ThreadSanitizer no longer flags the benign
first-call race in crc32/adler32/decompress/stream-decompress.
The previous fix (PR #4) made the per-codec dispatch function pointers
(crc32_impl, adler32_impl, ...) atomic, but each dispatcher still calls
get_x86_cpu_features() / get_arm_cpu_features(), which lazily initialize the
global libdeflate_x86_cpu_features / libdeflate_arm_cpu_features bitmask on
the first call. That global was only 'volatile', so a plain load racing with
the store in libdeflate_init_*_cpu_features() is undefined behavior and was
still flagged by ThreadSanitizer:

  WARNING: ThreadSanitizer: data race
    Write ... libdeflate_init_x86_cpu_features cpu_features.c
    Read  ... get_x86_cpu_features cpu_features.h
    Location is global 'libdeflate_x86_cpu_features'

This reproduced as a "Server died" failure when two HTTP connections first
compressed a gzip response concurrently under TSan.

Access the global with relaxed __atomic_load_n / __atomic_store_n, matching
the dispatch-pointer fix. The first-call initialization is a benign race
(every thread computes the same bitmask, a pure function of the CPU), and
relaxed ordering suffices because no other memory is published through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…boundaries

The streaming decompressor used to keep its resume checkpoint only at
DEFLATE block boundaries: on input exhaustion or output-buffer overflow it
rolled back to the start of the current block and reported everything after
it as unconsumed. For streams whose single block spans the whole input -
the shape zlib-ng's deflate_quick path (compression level 1, the default of
the official .NET SDK) emits - every refill re-decoded the block from its
start, making streaming decompression quadratic in the compressed block
size and forcing the caller to buffer the whole block.

Now the checkpoint is re-taken at every symbol boundary in the generic
decode loop, and two new decompressor fields (in_block, block_is_final) let
a resumed call jump straight back into symbol decoding: the litlen/offset
decode tables already persist in the decompressor between calls. The
fastloop needs no checkpointing (and thus stays untouched): suspensions can
only trigger from the generic loop, which always runs between the fastloop
and input exhaustion. A suspension now loses at most one partially decoded
symbol, so decompression is linear-time and constant-leftover regardless of
the block structure.

Also checkpoint-guard the case where implicit appended zero bytes were
consumed (truncated data declared complete): that now fails with BAD_DATA
at the checkpoint instead of producing an inconsistent input position.

Verified with a standalone harness: 5000 randomized round-trips (zlib
streams at all levels with random flush points, plus crafted static- and
dynamic-Huffman single-block streams with matches reaching the full 32 KiB
window, random input chunking down to 1 byte, random output regions), a
progress contract check on every suspension, and zlib as decode oracle.
Streaming throughput on normal multi-block streams is unchanged (685 vs
697 MB/s before/after on a 256 MB zlib-level-6 stream).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant