Skip to content

Replace Blosc2 with zstd-1 and decode chunks in parallel on read - #68

Merged
aarmey merged 4 commits into
mainfrom
zstd-parallel-load
Oct 14, 2026
Merged

aarmey merged 4 commits into
mainfrom
zstd-parallel-load

Conversation

@aarmey

@aarmey aarmey commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

  • zstd level 1 replaces Blosc2+LZ4 as the default compression for every numeric array written by write_h5ad (via hdf5plugin.Zstd) and write_zarr (zarr's/numcodecs' zstd codec). There is no option to switch back; dataset_kwargs still overrides it as before.

  • .h5ad reads now decode in parallel. HDF5 runs a dataset's filter pipeline one chunk at a time on a single thread. The new _compression.read_array():

    • finds every chunk's file offset in one chunk_iter pass;
    • reads the compressed chunks directly with os.pread;
    • decodes them with numcodecs.Zstd, which releases the GIL, on a thread pool sized by numba.get_num_threads(), giving each thread one contiguous run of chunks.

    Every VCS/IVCS array read through the anndata registry (read_h5ad, load_packed, anndata.io.read_elem) uses it.

  • Large arrays are written in 1 MiB chunks. numeric_only_compression now gives compressed 1-D HDF5 arrays larger than one chunk ~1 MiB chunks, unless the caller picked chunks. h5py's automatic 64–330 KiB chunks made the reader's per-chunk Python overhead dominate. The zstd ratio is unchanged from 64 KiB to 4 MiB chunks.

  • Blosc2 files still read. Anything the parallel path doesn't handle falls back to an ordinary h5py read: Blosc2 files from earlier versions, uncompressed or contiguous datasets, zstd combined with another filter, chunks that were never written, files open for writing, and non-sec2 drivers.

  • Fixes read_h5ad in a fresh process. It never imported hdf5plugin, so reading compressed obs/var columns (which happens before X) failed unless something else had already loaded the plugin.

  • numcodecs is now a direct dependency. It was already installed via zarr.

Results

Recompressed with this branch, all 15 files in /opt/IVCSC went from 18.96 GB to 14.99 GB (1.26×). On packed_indices, Blosc2/LZ4 got ~1.00× on every file, while zstd-1 gets 1.1–1.5× through entropy coding of the mostly single-byte varints. Higher zstd levels gain nothing.

Warm-cache reads, Blosc2 file → zstd-1 file:

File full packed_indices read load_packed
lupus (1.26M cells) 1.31 s → 0.11 s 4.00 s → 2.89 s
K562-genome-wide (2.0M cells) 7.99 s → 0.71 s 14.06 s → 6.48 s
full_IBD_cohort (1.19M cells) 1.22 s → 0.54 s 20.46 s → 15.37 s

What's left of load_packed is mostly anndata parsing obs/obsm plus index decoding, which this PR doesn't touch. Subsetting cells during decode is planned in #67, which also closes the design question in #24.

Merge fix

Merging main (#66) into this branch kept this branch's _rapid_load.py, re-adding load_and_normalize and its helpers without the kernels and imports they call: 19 undefined names under ruff, and a NameError if called. cca3f93 restores main's file. The only change this PR needs there is the plugin-import comment, since load_packed already reads X through the registry.

Test plan

  • tests/test_parallel_read.py:
    • parallel path across dtypes and lengths (empty, exact multiples of the chunk size, a partial final chunk);
    • files with a userblock;
    • a chunk HDF5 stored without the filter (filter-mask bit set);
    • fallback for unwritten chunks, contiguous, chunked-uncompressed, Blosc2, zstd+shuffle, writable and in-memory (core) files, and zarr arrays;
    • write_h5ad → read_h5ad taking the parallel path;
    • a Blosc2-written file still reading;
    • read_h5ad in a fresh subprocess (fails without the hdf5plugin import fix);
    • numcodecs decoding raw HDF5 zstd chunks.
  • tests/test_compression.py: 1 MiB chunking for large 1-D arrays; small arrays, explicit chunks and 2-D arrays are left alone; zstd filter/codec defaults.
  • Full suite: 1578 passed, 71 skipped. ruff check, ruff format --check, codespell and ty check are clean.
  • vulture, jscpd, radon and xenon report nothing new in the changed files.
  • Each /opt/IVCSC file was verified against its Blosc2 original, before and after being overwritten in place. The check covers every group, attribute, dataset, shape, dtype and value, and the new files are read through read_array.

https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM

Blosc2+LZ4 compresses the IVCSR packed_indices stream by ~1.00x on real
files (the delta+varint bytes have no repeats for an LZ codec to find),
while zstd's entropy coding gets ~1.15-1.5x. zstd at level 1 is now the
default for every numeric array written to .h5ad and zarr.

HDF5 runs a dataset's filter pipeline one chunk at a time on one thread,
so reads are decode-bound. read_array() looks up every chunk's file
offset in one chunk_iter pass, preads the compressed chunks, and decodes
them with numcodecs.Zstd on a thread pool sized by numba's thread count.
VCS/IVCS arrays read through the anndata registry and the rapid loader
both use it. Anything else -- Blosc2 files from earlier versions,
uncompressed or contiguous datasets, unwritten chunks, files open for
writing or on non-sec2 drivers -- falls back to an ordinary h5py read.

read_h5ad now imports hdf5plugin itself: in a fresh process it
previously failed on compressed obs/var columns, which are read before X.

Claude-Session: https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM
aarmey and others added 3 commits October 14, 2026 08:07
Merging main into this branch kept this branch's _rapid_load.py, which
re-added load_and_normalize, _read_ivcsr_group and _validate_min_cells
after #66 removed them -- without the numba kernels and imports they
call, so ruff flagged 19 undefined names and calling it would raise
NameError. Take main's version. load_packed already reads X through the
anndata registry, which uses the parallel reader, so the only change
this branch needs here is the hdf5plugin comment.

Claude-Session: https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM
h5py's automatic chunking gave the VCS arrays ~64-330 KiB chunks, and
the parallel reader pays a fixed, GIL-holding Python cost per chunk, so
on lupus (1.26M cells) reading packed_indices took 0.33 s instead of the
~0.08 s the zstd decode itself needs. numeric_only_compression now gives
compressed 1-D HDF5 arrays larger than one chunk ~1 MiB chunks (unless
the caller picked chunks), and read_array hands each thread one
contiguous run of chunks and decodes full chunks straight into the
output. On lupus: packed_indices 1.21 s (Blosc2) -> 0.095 s, value_ptr
0.10 s -> 0.031 s, load_packed 3.74 s -> 2.76 s, at the same zstd ratio.

Claude-Session: https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM
@aarmey
aarmey merged commit 6ae7edb into main Oct 14, 2026
7 of 8 checks passed
@aarmey
aarmey deleted the zstd-parallel-load branch October 14, 2026 15:27
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