Skip to content

Block-parallel zlib/gzip with pthreads and a block index - #12

Merged
fangq merged 8 commits into
NeuroJSON:masterfrom
fangq:master
Sep 4, 2026
Merged

fangq merged 8 commits into
NeuroJSON:masterfrom
fangq:master

Conversation

@fangq

@fangq fangq commented Sep 4, 2026

Copy link
Copy Markdown
Member

What this adds

Block-parallel zlib and gzip, plus a block index so the result can be inflated
in parallel too.

DEFLATE has no framing: a decoder cannot locate block N without inflating
everything before it, and back-references reach arbitrarily far into the 32 KB
window. Two things make a parallel codec possible anyway. Z_FULL_FLUSH ends a
block and resets the window, so each block becomes independently inflatable
while the concatenation stays an ordinary zlib/gzip stream any decompressor can
read start to finish. And the block offsets, free to record at compression
time, are handed back so a later decode can start at block N.

  67 MB of poorly-compressible uint16, system zlib 1.2.11
                      deflate              inflate
    serial          32.2 MB/s            249.7 MB/s
    nthread=4      115.8 MB/s   3.6x    1159.3 MB/s   4.6x
    nthread=16     357.2 MB/s  11.1x    3915.3 MB/s  15.7x

Why pthreads and not OpenMP

-pthread was already linked for blosc2, whereas libgomp would be a new hard
runtime dependency for a project shipping prebuilt binaries across eight
platform/interpreter combinations — and -static-libgcc, already set in
src/Makefile, does not cover it. Worse, MATLAB ships libiomp5 and its MKL
BLAS links it, so a libgomp-linked MEX would put a second OpenMP runtime in the
process. The workload needs none of what OpenMP offers: N independent
deflate() calls over disjoint slices, then join.

MSVC has no pthreads at all and is what builds the Windows wheels, so the six
primitives used here sit behind a small abstraction with a Win32 backend
(CreateThread, CRITICAL_SECTION) selected on _MSC_VER.

An earlier attempt, and why it concluded otherwise

The pzlib branch ends with "[deadend] multi-threaded zlib/gzip offers no
speedup for memory-based buffer"
. That was an artifact of two bugs rather than
a property of the problem: the block count was capped at the thread count while
the block size stayed fixed at 128 KB, so the final block received 88–98% of the
input and the maximum achievable speedup was 1.02–1.13x whatever the thread
count; and nothing emitted Z_FULL_FLUSH or recorded offsets, so the decoder
was attempting to inflate a slice of an ordinary deflate stream, which is not
possible, and fell back to pigz-style I/O pipelining that does nothing for a
buffer already in memory.

Behaviour changes

  • Default zlib/gzip output changes above the 4 MB block size. Resetting the
    window at each boundary is what makes a block independently inflatable, and it
    costs ratio: +0.000% on incompressible data, about +21% on data whose
    redundancy spans a block (the absolute cost is ~8 KB per boundary, so it only
    looks large when the compressed output is tiny). nthread negative reproduces
    the previous bytes exactly — verified against the currently shipped
    private/zipmat.mexa64.
  • nthread now defaults to 8 rather than 4, so lzip/lzma/xz/zstd/blosc2 get
    8 and zlib/gzip get 8 instead of 1.
  • Python module bumped to 1.2.0.

Within the blocked construction the output is a pure function of (data, level,
blocksize): nthread of 1, 8 or 32 give identical bytes, so a payload can be
content-addressed without pinning the thread count of whoever compressed it. It
is also byte-identical to jdata.zlibmt's pure-Python implementation — same
57,613,937 bytes and sha256 on a 67 MB payload — so the same content lands under
the same name whether MATLAB, Octave or Python wrote it.

Also in here

  • Threading reaches every binding: MATLAB/Octave MEX, Python (offsets /
    return_offsets), Fortran, and the C zmat_encode/zmat_decode helpers.
    zmat_run itself is unchanged and stays serial.
  • -pthread on compile and link in src/Makefile (appended after the codec
    conditionals, since the HAVE_ZLIB!=yes branch clears LIBZLIB), CMake, and
    src/compilezmat.m.
  • include/zmat.h regenerated so the single-header build carries the new code.
  • test/test_zlibmt.c: sizes from 1 byte to 17 MB spanning the block boundary,
    covering unaware-reader compatibility, indexed parallel inflate,
    corrupt-index fallback and thread-count independence.
  • Five stale golden expectations fixed in run_zmat_test.m. Four were the zlib
    header's FLG byte in the isminiz arm — FLG carries FLEVEL, so it tracks the
    compression level, and the recorded values had level=9 and level=2.6
    expecting identical bytes. The fifth was a blosc2 container flags bit that
    drifted with the vendored blosc2. Also seeded sprand, the suite's only
    random input, which made the reported size differ on every run.

Verification

MATLAB 253 lines and Octave 308 lines with zero failures; Python 64 tests OK on
8 versions (3.7–3.14) on Windows CI under MSVC; the C harness passing on both
the zlib and miniz backends; the header-only library round-tripping standalone;
make lib/dll/mex/oct, example/c and example/f90 all building and running.

One gap worth naming: the Win32 threading backend is exercised on Linux by
backing its primitives with pthreads (7.15x scaling, all correctness checks),
and MSVC compiles it in CI with no new warnings — but it has not been run on a
real Windows machine.

fangq and others added 8 commits September 4, 2026 00:09
An earlier attempt at this lives on the pzlib branch and ends with the commit
"[deadend] multi-threaded zlib/gzip offers no speedup for memory-based buffer".
That conclusion was an artifact of two bugs, not a property of the problem:

  - the block count was capped at the thread count while the block size stayed
    fixed at 128 KB, so the final block received the remainder -- 88% to 98% of
    the input. Maximum achievable speedup was 1.02x to 1.13x by construction,
    whatever the thread count.
  - nothing emitted Z_FULL_FLUSH and no offsets were recorded, so the blocks
    were not independently inflatable and the decompressor was attempting the
    genuinely impossible thing: inflating a slice of an ordinary deflate stream.
    It then fell back to pigz-style I/O pipelining, which overlaps file reads
    and so does nothing for a buffer already in memory.

This implementation fixes both. Blocks are a fixed ZMAT_BLOCKSIZE (4 MB) with
only the remainder short, workers pull indices from a shared counter so a
slow-compressing block cannot starve the pool, and each block is closed with
Z_FULL_FLUSH -- which ends the block *and* resets the LZ77 window, making it
independently inflatable while the concatenation remains an ordinary zlib or
gzip stream. Z_FINISH would have set BFINAL and stopped every reader at the
first block. The block offsets, free to record at compression time, come back
through a new zmat_run_indexed() so a later decode can start at block N;
zmat_run keeps its signature and its bytes.

pthreads, not OpenMP. -pthread is already linked for blosc2, whereas libgomp
would be a new hard runtime dependency on a toolbox shipping prebuilt binaries
for eight platform/interpreter combinations -- and -static-libgcc, already set
in src/Makefile, does not cover it. Worse, MATLAB ships libiomp5 and its MKL
BLAS links it, so a libgomp-linked MEX would put a second OpenMP runtime in the
process. The workload needs none of what OpenMP provides: N independent
deflate() calls over disjoint slices, then join. The built MEX links libc,
libmex, libmx and libz, with zero GOMP symbols.

  67 MB of poorly-compressible uint16, C harness, system zlib 1.2.11
                      deflate              inflate
    serial          32.2 MB/s            249.7 MB/s
    nthread=4      115.8 MB/s   3.6x    1159.3 MB/s   4.6x
    nthread=16     357.2 MB/s  11.1x    3915.3 MB/s  15.7x
  size penalty +0.0015% (zlib), and the stream stays readable by plain
  zlib.decompress and gzip -dc at every thread count.

Two properties the tests pin down. The blocked construction is opt-in via an
explicit nthread, because it changes the compressed bytes: gating on nthread>1
would make nthread=1 and nthread=2 disagree byte for byte, which breaks content
addressing of the payload, while gating on something always true would change
the output of every existing caller. zmat.m therefore defaults nthread to 0,
meaning "never asked", and a default zmat(x,1,'zlib') call is byte-identical to
unmodified master built with the same flags (verified: same sha256 on 16 MB).
And a block index that does not describe the stream is rejected rather than
trusted, falling back to a serial inflate, so a stale index costs time but
never correctness.

The output is also byte-identical to jdata.zlibmt's pure-Python
implementation -- same 57,613,937 bytes and sha256 on a 67 MB payload, with
each able to read the other's index -- so the same content lands under the same
content-addressed name whether MATLAB, Octave or Python wrote it.

Verified: 44 C correctness checks across sizes 1 B to 17 MB spanning the block
boundary, covering unaware-reader compatibility, indexed parallel inflate,
corrupt-index fallback and thread-count independence; clean under -Wall -Wextra
in the zlib, miniz (NO_ZLIB) and NO_PTHREAD configurations; and zmat's own test
suite output is unchanged from master apart from stack-trace line numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-pthread now reaches both the compile and the link line unconditionally, rather
than arriving only as a side effect of blosc2 being enabled. It has to be
appended to LIBZLIB after the codec conditionals, because the HAVE_ZLIB!=yes
branch clears that variable and would silently drop an earlier append -- which
is the default miniz build, i.e. the one that matters. CMake gains
Threads::Threads with THREADS_PREFER_PTHREAD_FLAG; python/setup.py already
linked pthread.

ZMAT_DEFAULT_NTHREAD, 8, is applied when the caller does not name a thread
count, and is overridable at build time with -DZMAT_DEFAULT_NTHREAD=N. Since
the blocked construction changes the compressed bytes, this changes the default
output of zlib and gzip for inputs above the 4 MB block size. A negative
nthread is the way back to the historical single-stream bytes, and it is exact:
zmat(x, 1, 'zlib', 'nthread', -1) reproduces the sha256 that the currently
shipped private/zipmat.mexa64 produces on a 16 MB array.

  16 MB uint16, MATLAB R2019b, miniz build
    default (no nthread)   110.6 MB/s   4 blocks   sha f33f69a3...
    nthread=8              110.3 MB/s   4 blocks   sha f33f69a3...
    nthread=1               31.6 MB/s   4 blocks   sha f33f69a3...
    nthread=-1              32.4 MB/s   serial     sha 2ac90aee...  (shipped bytes)

nthread=1 producing the same bytes as nthread=8 while running serially is the
property worth keeping: the output is a function of the data, level and block
size only, so a payload can be content-addressed without pinning the thread
count of whoever compressed it.

For the record, since it came up: blosc2's own default is a single thread
(g_nthreads = 1 in src/blosc2/blosc/blosc2.c), and zmat has always passed 1
unless asked otherwise. The 4 in circulation is jsonlab's jsave.m, not a blosc2
or zmat default. blosc2's threading is left as it was.

zmat's test suite is unchanged against the shipped binary: same 245 lines, the
same 5 pre-existing failures, no new ones. Those failures are small-input zlib
golden-byte cases that sit below the block size and so never reach the threaded
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auditing the bindings and CI for the pthread dependency turned up one build
that my previous commit would have broken outright and two that would have
linked only by luck.

The Windows wheels are the broken one. build_windows_wheel.yml runs
"python -m build --wheel" on windows-2022, which uses MSVC -- confirmed by
setup.py's "/O2" branch -- and MSVC has no pthreads at all. setup.py links no
pthread there and does not define NO_PTHREAD, so zmatlib.c's pthread_create
would simply have failed to resolve. Rather than switch threading off on that
toolchain, or make it conditional on blosc2's vendored win32 shim being
compiled in, the six primitives this codec actually needs are now behind
zmat_thread_t / zmat_mutex_t, with a Win32 backend (CreateThread,
CRITICAL_SECTION) selected on _MSC_VER. The worker is stashed in the pool so
the Win32 entry point can dispatch without a per-thread allocation. Nothing
else needs to change: Win32 threads live in kernel32, which is always linked,
so setup.py and python/Makefile stay as they are.

The two lucky ones both concern -pthread going missing from a flag set that
gets rebuilt rather than appended to:

  - src/Makefile replaces CPPOPT wholesale in its Windows branch, so appending
    $(PTHREADOPT) at the definition site silently dropped it there. It is now
    appended after the platform conditionals, matching the fix already applied
    to LIBZLIB for the HAVE_ZLIB!=yes branch.
  - src/compilezmat.m, the MATLAB-side builder, passed no -pthread on either
    the compile or the link line, in either its MATLAB or its Octave branch. It
    now does, skipped under ispc where the Win32 path applies instead.

Everything else was already covered and is left alone: example/c/Makefile and
example/f90/Makefile link -lpthread; python/setup.py appends pthread on every
non-Windows platform; python/Makefile does the same for Linux, and macOS needs
nothing because pthreads is in libSystem; run_test.yml installs
mingw-w64-x86_64-winpthreads-git for the MinGW builds and passes -lwinpthread
or -lpthread explicitly on Windows and macOS; and the lib target builds a
static archive where thread linkage is the consumer's job.

Verified: all of make lib, dll, mex, oct and example/c build; the C correctness
suite still passes 44/44; zmat's MATLAB suite is byte-identical to the shipped
binary with no new failures; the Octave binding round-trips at nthread 8, 1 and
-1; and the MSVC branch is syntax-clean under a stubbed windows.h, since no
MSVC is available here to compile it for real. That last one is the weak spot
in this commit -- the Win32 path has been compiled but never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs claimed the output "is a pure function of (data, level, blocksize)
and is byte-identical however many threads produced it". True of the blocked
construction -- nthread of 1, 8 and 32 give the identical bytes -- but it read
as if it covered the serial path too, which it cannot: a negative nthread
selects a different construction with a different length.

Both are now stated side by side, along with the ratio cost of resetting the
window at every block boundary, which ranges from +0.000% on 32 MB of random
uint16 to +21% on a long monotonic counter whose redundancy spans the block
size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pyzmat.c called zmat_run everywhere, so the Python module could never reach the
threaded path no matter what nthread it was handed -- the nthread argument in
zmat/__init__.py was accepted and then quietly ignored for zlib and gzip. Only
the MATLAB/Octave MEX was wired. My earlier claim that the bindings were covered
was about pthread linkage, not about which entry point they call.

zmat() now routes through zmat_run_indexed and gains the two arguments needed to
use it: offsets, a block index from a previous compression that lets zlib and
gzip inflate concurrently, and return_offsets, which switches the return from
bytes to (bytes, index). compress() and decompress() are routed through it too,
so they pick up the build-time default thread count; encode() and decode()
default to base64, which the threading gate excludes, so they still call
zmat_run.

nthread now defaults to 0 rather than 1, matching zmat.m. Under the new
semantics 1 means "explicitly one thread", which selects the blocked
construction and so changes the output bytes; 0 means "unspecified" and lets the
library decide. Leaving the old default of 1 in place would have silently
switched every existing caller to blocked output while giving them none of the
speed.

  16.8 MB uint16, python3.10
    default / nthread=8   122.6 MB/s   sha d9334b6d...
    nthread=1              33.5 MB/s   sha d9334b6d...   same bytes, one thread
    nthread=-1             33.7 MB/s   sha c0f65053...   historical stream
    inflate, serial         258.8 MB/s
    inflate, 8 + index     1038.9 MB/s   4.0x

Also fixes a build bug found on the way. ensure_csrc() returned early whenever
csrc/src/zmatlib.c already existed, so a populated csrc was never refreshed from
../src: editing the C sources and rebuilding the wheel silently produced a
module from the previous sources, which is exactly how this change first
appeared to fail with an undefined zmat_run_indexed. A clean checkout is
unaffected -- csrc is untracked and gets populated on first build -- but any
working tree that has built before was affected. When the parent tree is present
it is now treated as the authority. Note that copy2 preserves mtimes, so a stale
build/ directory can still skip the recompile; rm -rf build after changing ../src.

Verified: the module builds clean, links pthread with zero GOMP symbols, the 64
existing tests pass, plain zlib.decompress reads the threaded output, a corrupt
index falls back to correct bytes, and base64 encode/decode are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fortran90/zmatlib.f90 is a pure bind(C) interface block, so wiring it meant two
things.

First, the simplified C helpers it declares. zmat_encode and zmat_decode called
zmat_run with a bare literal for iscompress, which leaves the packed thread byte
at 0; routing them through zmat_run_indexed with a null index means that 0 is
read as "unspecified" and zlib/gzip pick up ZMAT_DEFAULT_NTHREAD. Plain C callers
of those two helpers get the same benefit. Everything else about them is
unchanged.

Second, a declaration for zmat_run_indexed itself, so Fortran callers can hand
back a block index and inflate concurrently. offsets is a size_t** on the C side,
which maps to an intent(inout) type(c_ptr): set it to C_NULL_PTR to decline the
index, or pass the one from a previous compression. It is malloc'ed by the
library and freed with zmat_free.

Exercised from a real Fortran program on 16 MiB of the worst-ratio payload, not
just compiled:

    serial   (nthread=-1)  113760 bytes, no index
    threaded (nthread=8)   137868 bytes, 4 blocks, 10-entry index
    indexed inflate        16777216 bytes, roundtrip ok
    index[0..3]            2, 0, 34467, 4194304

Those match the C measurements exactly, and the first block starting at byte 2
is the zlib header, as expected. The pre-existing example/f90 still builds and
prints the same output.

One wrinkle worth recording for anyone packing the flags by hand: the thread
count is byte 1 of the level argument, so nthread=-1 is z'0000FF01' and not
z'FFFF0001' -- getting that wrong silently selects the default instead of the
serial path, which is how the first run of the test above appeared to produce a
blocked stream for its serial case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All five failures the suite has been carrying predate this branch -- master
built with the same flags produces byte-identical output -- and all five turn
out to be expectations that drifted from the code, not defects.

Four are the zlib header's FLG byte, and only in the isminiz arm of each pair;
the system-zlib arm already had the right values. FLG carries FLEVEL, which
encodes the compression level, so it cannot be the same for every level -- and
the giveaway is that level=9 and level=2.6 were given identical byte arrays,
which no conforming deflate can produce. The recorded values all had FLG=1
(FLEVEL=0), from a time when the header was written level-independently.
Measured now, both backends and both interpreters agree:

    scalar, array   120 156   FLEVEL=2 (default)
    level=9         120 218   FLEVEL=3
    level=2.6       120  94   FLEVEL=1

The fifth is blosc2zstd (typesize=2), where the vendored blosc2 changed one bit
of its own container flags byte, 0x97 to 0x87. zmat does not write that header.
Both typesizes still round-trip, so this is drift in the golden rather than a
functional break.

Separately, sprand was the suite's only random input and was never seeded, so
the compressed size it reports differed on every run -- master alone gave 14896
then 14913 on two consecutive runs. That made any run-to-run comparison noisy
enough to hide a real change, which is how it first looked like this branch had
altered something. Seeded, two Octave runs are now byte-identical.

MATLAB and Octave both report zero failures.

Also promotes the block-parallel correctness harness out of scratch and into
test/test_zlibmt.c, with build lines for both backends in its header. It covers
sizes from 1 byte to 17 MB spanning the block boundary: unaware-reader
compatibility, indexed parallel inflate, corrupt-index fallback, and
thread-count independence. Both backends pass. It skips the gzip unaware-reader
check under miniz, whose inflateInit2 rejects window bits 15|32 -- that check
cannot run there for any gzip stream, zmat's or otherwise, so reporting it as a
failure said nothing about the data.

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

Rebasing onto NeuroJSON/zmat brought in nine commits that overlap this work --
multi-threading for xz, lzip, zstd and lzma, per-codec shuffle, an info= form
for the Python wrapper, and a generated single-header build. Three things
needed deciding rather than merging.

The thread-count default. Upstream set zmat.m to 4 for its MT codecs; this
branch had used 0 to mean "caller said nothing" so zlib/gzip could tell opt-in
from default. Both now resolve to 8, so lzip/lzma/xz/zstd/blosc2 get 8 rather
than 4 and zlib/gzip get 8 rather than 1. A negative nthread remains the way
back to zlib/gzip's historical single-stream bytes; that escape hatch is the
only thing the 0 sentinel was protecting, and an explicit default states the
intent more plainly.

The version. Upstream already shipped 1.1.0 and its commit says "start
1.2.preview", so the bump here is to 1.2.0 -- git dropped this branch's own
1.1.0 bump during the rebase as already-applied upstream.

The Python signature. Upstream's info= and this branch's offsets/return_offsets
both survive, with offsets threaded through the info-dict decompress path as
well as the plain one. return_offsets cannot be combined with info, since the
info forms already return a tuple, so that combination now raises rather than
silently ignoring the request.

Also regenerates include/zmat.h. Upstream added it as a generated
single-header library, but it was generated before this work, so it carried
neither zmat_run_indexed nor the parallel implementation. Rebuilt with
"make -C src header"; it now compiles standalone and threads correctly.

Verified against the merged tree: MATLAB 253 lines and Octave 308 lines with
zero failures, the C block-parallel harness passing on both the zlib and miniz
backends, the header-only library round-tripping 16 MiB across 4 blocks, and
the Python module reporting 1.2.0 with lzma, zstd, lz4 and blosc2zstd
round-tripping alongside the new zlib/gzip path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fangq
fangq merged commit 385884a into NeuroJSON:master Sep 4, 2026
33 checks passed
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