deflate: observe post-init compression level changes - #69
Conversation
deflateParams() was not intercepted, so DeflateSettings::level was written only at init and the Z_NO_COMPRESSION gate in deflate() never saw a level set afterwards. A stream initialized at level 1-9 and then lowered to level 0 was silently compressed on every backend -- 428 (QAT), 1046 (IAA) and 85 (IGZIP) bytes for 64 KiB of input where zlib's contract requires 65552 stored bytes -- and identically so with use_zlib_compress=0, making it a silent contract violation rather than an error. Intercept deflateParams() and record the level and strategy, gated on the call returning Z_OK: zlib documents the parameters as unchanged on Z_BUF_ERROR, which is the same gating deflateInit*() and both *SetDictionary() functions already use. The existing per-call gate then pins such a stream to zlib, so it reaches orig_deflate even when use_zlib_compress is 0 -- the request was never an offload candidate. A stream ISA-L already owns has to be exempted from that pin. The gate runs before the IGZIP stickiness check, so pinning a stream ISA-L has emitted a header and compressed data for hands it to a zlib deflate state that was never fed, which writes a second header mid-stream: measured Z_DATA_ERROR with only the pre-switch bytes recoverable. Such a stream stays on IGZIP with the new level unhonored -- the same deliberate residual as deflate()'s Z_BLOCK -> Z_SYNC_FLUSH aliasing -- and its output remains valid deflate that round-trips. deflateReset() deliberately keeps the recorded level, matching zlib, which documents reset as leaving the level and strategy unchanged. gzsetparams() is a separate gap and is unchanged here: GzipFile carries no compression level at all. Signed-off-by: Olasoji <olasoji.denloye@intel.com>
There was a problem hiding this comment.
Pull request overview
Intercepts deflateParams() so compression-level changes affect backend selection.
Changes:
- Tracks accepted level and strategy updates.
- Preserves active IGZIP streams to avoid corruption.
- Adds regression tests and documents limitations.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
zlib_accel.cpp |
Adds interception and routing logic. |
tests/zlib_accel_test.cpp |
Tests level changes across backends. |
README.md |
Documents behavior and exceptions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Only the path is cleared. zlib's deflateReset keeps the compression level | ||
| // and strategy, including any set later by deflateParams(), so the recorded | ||
| // level must survive a reset too or path selection would disagree with the | ||
| // level zlib is actually using. |
Recording the level in deflateParams() is not sufficient on its own. isal_deflate_reset() deliberately preserves level and level_buf, and deflate() only calls InitCompressIGZIP() when isal_strm is null, so deflateReset() left the next stream running at the level the previous stream was built for. Measured on 64 KiB: a stream initialized at level 9, lowered to level 1 by deflateParams() and then reset produced 48335 bytes, byte-identical to a fresh level-9 stream, where a fresh level-1 stream produces 51021. The output round-trips, so this is compression ratio fidelity rather than corruption, but it is only reachable because intercepting deflateParams() made the recorded level mutable. Extract the zlib to ISA-L level mapping out of InitCompressIGZIP() into MapCompressionLevelIGZIP() so CompressLevelChangedIGZIP() cannot disagree with what an init would have chosen, and discard the ISA-L stream in deflateReset() when the level no longer matches, letting deflate() rebuild it from the current setting. A reset that does not change the level still takes ResetCompressIGZIP() and keeps its level_buf. That allocation is 276-340 kB depending on the level, so always discarding would charge a fresh allocation to every stream of the reset-per-object callers this path exists to serve. The new test compares the post-reset stream byte for byte against a fresh stream at the new level, and asserts the two level controls differ so it cannot pass vacuously on input that compresses identically at both levels. Signed-off-by: Olasoji <olasoji.denloye@intel.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
README.md:51
- This says there are two exceptions but presents three bullets, and the first IGZIP bullet already subsumes the second. Merge those two bullets so the exception count and documented behavior are unambiguous.
- `Z_NO_COMPRESSION` (level 0) streams are always handled by zlib. Level 0 requests stored, uncompressed deflate blocks, which none of the backends can produce — ISA-L's own level 0 is still LZ77+Huffman compression, and QAT and IAA take no compression level at all — so such streams are routed to zlib rather than being silently compressed. A level change made *after* initialization via `deflateParams` is observed as well, so a stream initialized at level 1-9 and later set to level 0 is routed to zlib from that point on. There are two exceptions:
- A stream that has already started on IGZIP when the level changes stays on IGZIP for the rest of that stream, and the new level is not honored. `deflateReset` ends the stream, and the next stream on the same `z_stream` does use the new level.
- A stream that has already started on IGZIP when the level drops to 0 stays on IGZIP, and the new level is not honored. ISA-L has already emitted a header and compressed data and still holds unflushed stream state at that point, so the stream cannot be moved to zlib without corrupting the output — the same constraint that makes `Z_BLOCK` alias to `Z_SYNC_FLUSH` mid-stream. The output remains valid deflate that round-trips correctly; it is compressed where the application asked for stored blocks.
- `gzsetparams` is not intercepted, so a level set through the `gz*` API is not observed at all.
zlib_accel.cpp:430
- The new
Z_OKgate is specifically intended to prevent a rejecteddeflateParams()change from affecting later path selection, but every added test makes the call succeed. Add a regression that forcesZ_BUF_ERROR, resets the stream, and verifies the rejected level was not recorded (for example, that the next stream remains eligible for IGZIP).
if (ret == Z_OK) {
auto deflate_settings = deflate_stream_settings.Get(strm);
if (deflate_settings != nullptr) {
deflate_settings->level = level;
deflate_settings->strategy = strategy;
Copilot's review round suppressed two comments that were both correct. The `ret == Z_OK` gate in deflateParams() had no test: every case made the call succeed. Add a per-backend regression that rejects the change with a legal level and an out-of-range strategy, so an ungated write would record level 0 and pin an offloadable stream to zlib. Z_STREAM_ERROR rather than Z_BUF_ERROR because zlib only returns the latter when it must flush its own deflate state, which an offloaded stream never advances. The level-0 README bullet claimed two exceptions and listed three, with the first IGZIP bullet subsuming the second. Merge them. Signed-off-by: Olasoji <olasoji.denloye@intel.com>
matt-welch
left a comment
There was a problem hiding this comment.
Verdict: Approve with comment
One low-severity edge case identified (compression ratio fidelity only, no corruption). Core fix is correct and well-tested.
Summary
Intercepts deflateParams() to track post-init level changes, fixing a silent contract violation: a stream lowered to level 0 after init was compressed (428-1046 bytes) instead of stored (65552 bytes) on every backend. The fix gates recording on ret == Z_OK, exempts active IGZIP streams from the level-0 pin (can't hand mid-stream state to zlib), and discards the ISA-L stream in deflateReset() when the level no longer matches.
What's correct
- deflateParams interceptor: forwards to zlib first, records only on Z_OK — same pattern as deflateInit/SetDictionary
- IGZIP mid-stream exemption:
igzip_owns_stream = (path == IGZIP && isal_strm != nullptr)— only true when ISA-L has actually been fed data; prevents second-header corruption - deflateReset level-change detection: uses
CompressLevelChangedIGZIP(sharedMapCompressionLevelIGZIPensures no disagreement with init); common no-change case keeps the 276-340 KB level_buf allocation - Level mapping refactor: exact preservation of the existing mapping with better readability (
Z_DEFAULT_COMPRESSIONvs raw-1) - Tests: strong oracles (stored output > input; byte-equality against fresh stream; non-vacuous assertion that levels differ)
Finding: stale ISA-L stream after deflateReset → deflateParams
Sequence: init(9) → deflate → reset → deflateParams(1) → deflate
After deflateReset, the level hasn't changed yet (still 9), so the ISA-L stream (level 3) is kept. Then deflateParams(1) updates the recorded level to 1 but doesn't discard the ISA-L stream. The next deflate() sees isal_strm != nullptr, skips InitCompressIGZIP, and uses the stale stream at ISA-L level 3 instead of rebuilding at level 1.
Impact: Compression ratio only — output is valid, round-trips. Self-corrects on next deflateReset().
Severity: Low. Requires the uncommon reset-then-params pattern (vs. the handled params-then-reset).
Suggested fix (for a follow-up): in deflateParams(), after recording the level, discard a non-active ISA-L stream whose level no longer matches:
#ifdef USE_IGZIP
if (deflate_settings->path != IGZIP &&
deflate_settings->isal_strm != nullptr &&
CompressLevelChangedIGZIP(deflate_settings->isal_strm, level)) {
EndCompressIGZIP(deflate_settings->isal_strm);
deflate_settings->isal_strm = nullptr;
}
#endifThe path != IGZIP guard preserves the mid-stream exemption.
Files changed
zlib_accel.cpp (+58/-2 lines)
deflateParamsinterceptor (forward + record on Z_OK)- IGZIP mid-stream exemption in level-0 gate
deflateResetconditional discard of ISA-L stream
igzip.cpp (+39/-13 lines)
MapCompressionLevelIGZIP()extracted fromInitCompressIGZIP()CompressLevelChangedIGZIP()uses the shared mapping
igzip.h (+2 lines)
VISIBLE_FOR_TESTINGdeclaration ofCompressLevelChangedIGZIP
tests/zlib_accel_test.cpp (+372 lines)
- 9 test cases: level-0 regression (3 backends), rejection gate (3 backends), legal-change stays offloadable, mid-stream exemption, reset-rebuilds-stream
README.md (+6/-3 lines)
- Documents the two exceptions (IGZIP mid-stream, gzsetparams)
- Adds
deflateParamsto intercepted functions list
deflateReset() gives up an ISA-L stream built for a compression level that deflateParams() has since changed, but only in that ordering. Reset first and the recorded level still matches at that point, so the stream is kept; the following deflateParams() then moves the level under a stream deflate() reuses as-is, since it builds one only when isal_strm is null. A level-1 request after a level-9 stream produced 48335 bytes, byte-identical to fresh level 9, where fresh level 1 produces 51021. The output is valid and round-trips -- this is compression ratio only -- and it self-corrects on the next reset. Discard such a stream in deflateParams() so the next deflate() rebuilds it at the level just requested, exempting a stream ISA-L already owns: that one holds a header plus unflushed data and cannot be rebuilt mid-stream, the same exemption the level-0 pin makes. The ownership predicate now has three call sites, so it becomes IgzipOwnsDeflateStream(). Signed-off-by: Olasoji <olasoji.denloye@intel.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
zlib_accel.cpp:335
- This predicate also treats a zero-progress path selection as ownership. On a fresh stream, an empty
Z_SYNC_FLUSHallocatesisal_strm, hitsCompressIGZIP()'sZSTATE_NEW_HDRfast path, and returnsZ_BUF_ERRORwithout consuming or emitting anything, butdeflate()still recordspath == IGZIP. A followingdeflateParams(..., Z_NO_COMPRESSION, ...)is therefore exempted from rebuilding/pinning and later input is compressed at the old level even though migration was still safe. Require actual ISA-L input/output progress before considering the stream owned.
static bool IgzipOwnsDeflateStream(
const std::shared_ptr<DeflateSettings>& settings) {
return settings != nullptr && settings->path == IGZIP &&
settings->isal_strm != nullptr;
deflateParams() was not intercepted, so DeflateSettings::level was written only at init and the Z_NO_COMPRESSION gate in deflate() never saw a level set afterwards. A stream initialized at level 1-9 and then lowered to level 0 was silently compressed on every backend -- 428 (QAT), 1046 (IAA) and 85 (IGZIP) bytes for 64 KiB of input where zlib's contract requires 65552 stored bytes -- and identically so with use_zlib_compress=0, making it a silent contract violation rather than an error.
Intercept deflateParams() and record the level and strategy, gated on the call returning Z_OK: zlib documents the parameters as unchanged on Z_BUF_ERROR, which is the same gating deflateInit*() and both *SetDictionary() functions already use. The existing per-call gate then pins such a stream to zlib, so it reaches orig_deflate even when use_zlib_compress is 0 -- the request was never an offload candidate.
A stream ISA-L already owns has to be exempted from that pin. The gate runs before the IGZIP stickiness check, so pinning a stream ISA-L has emitted a header and compressed data for hands it to a zlib deflate state that was never fed, which writes a second header mid-stream: measured Z_DATA_ERROR with only the pre-switch bytes recoverable. Such a stream stays on IGZIP with the new level unhonored -- the same deliberate residual as deflate()'s Z_BLOCK -> Z_SYNC_FLUSH aliasing -- and its output remains valid deflate that round-trips.
deflateReset() deliberately keeps the recorded level, matching zlib, which documents reset as leaving the level and strategy unchanged.
gzsetparams() is a separate gap and is unchanged here: GzipFile carries no compression level at all.