Skip to content

Commit 2f38aba

Browse files
committed
Keep the input as unconsumed_tail when decompress() reads none of it
With a small max_length, isal_inflate often gives the output from data it already decoded into its own buffer, and reads no input. The decompress() method still copied all of its input to a new unconsumed_tail, so a loop that passes unconsumed_tail back in copied the whole rest of the compressed stream on most calls. When no input was read and the input is an exact bytes object that the buffer covers entirely, unconsumed_tail now keeps a reference to it. Other input, such as a bytearray, a memoryview or a bytes subclass, is still copied. Decompressing 4 MiB of Python source compressed at level 2 (1.2 MB) on Linux, passing unconsumed_tail back in, median of 5 rounds: with max_length=1024 it goes from 38 to 5.9 ms (6.4x) on Python 3.11 and from 39 to 5.9 ms (6.7x) on Python 3.14. 8192 is 1.5-1.7x faster and 16384 is 1.2x faster. 65536 and max_length=0 are unchanged. The only behavior change is identity: after a call that read no input, unconsumed_tail is the bytes object that was passed in.
1 parent 599958d commit 2f38aba

3 files changed

Lines changed: 83 additions & 4 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Changelog
99
1010
version 1.10.0-dev
1111
------------------
12+
+ Speed up ``decompress()`` of ``isal_zlib.decompressobj`` objects with a small
13+
``max_length``. When ISA-L gave the output from its own buffer without
14+
reading any input, the whole input was still copied to ``unconsumed_tail``.
15+
On Linux, decompressing 4 MiB of text 1 KiB at a time, passing
16+
``unconsumed_tail`` back in, is 5 times faster, and 8 KiB at a time is 1.5
17+
times faster.
1218
+ Fix the gzip header that ``igzip_threaded.open`` writes when ``threads`` is
1319
not 0. It had the XFL and OS bytes swapped, so the ``file`` command reported
1420
the output as coming from VM/CMS or a FAT file system.

‎src/isal/isal_zlibmodule.c‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -983,10 +983,25 @@ save_unconsumed_input(decompobject *self, Py_buffer *data, int err)
983983
1. Output limit was reached. Save leftover input in unconsumed_tail.
984984
2. All input data was consumed. Clear unconsumed_tail. */
985985
Py_ssize_t left_size = (uint8_t *)data->buf + data->len - self->zst.next_in;
986-
PyObject *new_data = PyBytes_FromStringAndSize(
987-
(char *)self->zst.next_in, left_size);
988-
if (new_data == NULL)
989-
return -1;
986+
PyObject *new_data;
987+
if (left_size == data->len && data->obj != NULL &&
988+
PyBytes_CheckExact(data->obj) &&
989+
data->buf == (void *)PyBytes_AS_STRING(data->obj) &&
990+
data->len == PyBytes_GET_SIZE(data->obj)) {
991+
/* No input was consumed. This is common with a small max_length,
992+
because ISA-L serves output from its own buffer first. Keep
993+
the input bytes object instead of copying it. The buffer must
994+
cover the whole object: an exporter may set obj to a bytes
995+
object and export only part of it. */
996+
Py_INCREF(data->obj);
997+
new_data = data->obj;
998+
}
999+
else {
1000+
new_data = PyBytes_FromStringAndSize(
1001+
(char *)self->zst.next_in, left_size);
1002+
if (new_data == NULL)
1003+
return -1;
1004+
}
9901005
Py_SETREF(self->unconsumed_tail, new_data);
9911006
}
9921007

‎tests/test_compat.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,64 @@ def test_decompressobj_unconsumed_tail():
258258
assert len(output) == 2048
259259

260260

261+
class BytesSubclass(bytes):
262+
pass
263+
264+
265+
def memoryview_slice(data):
266+
return memoryview(b"pad" + data)[3:]
267+
268+
269+
@pytest.mark.parametrize(["input_type", "max_length", "wbits"],
270+
list(itertools.product(
271+
[bytes, BytesSubclass, bytearray, memoryview,
272+
memoryview_slice],
273+
[100, 1000, 8192], [-15, 15, 31])))
274+
def test_decompressobj_decompress_unconsumed_tail(input_type, max_length,
275+
wbits):
276+
data = DATA[:256 * 1024]
277+
compressor = zlib.compressobj(wbits=wbits)
278+
compressed = compressor.compress(data) + compressor.flush()
279+
decompressor = isal_zlib.decompressobj(wbits=wbits)
280+
unconsumed = compressed + b"unused"
281+
result = []
282+
while not decompressor.eof:
283+
chunk = input_type(unconsumed)
284+
output = decompressor.decompress(chunk, max_length)
285+
assert len(output) <= max_length
286+
result.append(output)
287+
unconsumed = decompressor.unconsumed_tail
288+
assert type(unconsumed) is bytes
289+
if isinstance(chunk, bytearray):
290+
# The tail must be a copy, not the mutable input.
291+
chunk[:] = bytes(len(chunk))
292+
result.append(decompressor.flush())
293+
assert b"".join(result) == data
294+
assert decompressor.unconsumed_tail == b""
295+
assert decompressor.unused_data == b"unused"
296+
297+
298+
@pytest.mark.skipif(not hasattr(sys, "getrefcount"),
299+
reason="Needs reference counting")
300+
def test_decompressobj_keeps_unconsumed_tail():
301+
data = DATA[:1024 * 1024]
302+
compressed = zlib.compress(data)
303+
decompressor = isal_zlib.decompressobj()
304+
result = [decompressor.decompress(compressed, 1)]
305+
tail = decompressor.unconsumed_tail
306+
assert tail
307+
refcount = sys.getrefcount(tail)
308+
# ISA-L gives this output from its own buffer without reading input, so
309+
# the input itself is kept as the tail.
310+
for _ in range(100):
311+
result.append(decompressor.decompress(tail, 1))
312+
assert decompressor.unconsumed_tail is tail
313+
assert sys.getrefcount(tail) == refcount
314+
result.append(decompressor.flush())
315+
assert b"".join(result) == data
316+
assert sys.getrefcount(tail) == refcount - 1
317+
318+
261319
@pytest.mark.parametrize(["data_size", "level"],
262320
list(itertools.product(DATA_SIZES, range(4))))
263321
def test_igzip_compress(data_size, level):

0 commit comments

Comments
 (0)