Skip to content

Large Blosc chunks decode about 3x slower than in zarr 2 because numcodecs.blosc.use_threads is set to False on import #4531

Open

Description

@Zappandy

Zarr version

v3.4.1

Numcodecs version

v0.17.0

Python Version

3.12

Operating System

Linuxx86_64

Installation

using uv to create a venv

Description

Hi, I'm looking at ECMWF's anemoi-datasets (a library for large zarr datasets to train weather models) to smooth out its support for zarr 3, and while checking some performance claims in ecmwf/anemoi-datasets#486 and ecmwf/anemoi-datasets#786 I found a slowdown that seems to belong here. #3524 looks related, so I'm linking it. The reproducer below needs only zarr and numpy.

Summary

With zarr-python 3.4.1 / numcodecs 0.17.0, reading one large Blosc chunk (~200 MB decoded) takes about 3x longer than with zarr-python 2.18.7 / numcodecs 0.15.1, with the data already in memory (warm page cache). In a single process, setting numcodecs.blosc.use_threads = True after import zarr closes more than half of the gap. #3524 was closed because FusedCodecPipeline (3.3.0) was expected to fix this; for one thread reading a single large chunk it does not (finding 3), though with several threads it does help (see the separate issue in point 7). Cold reads are not affected, and with four chunks per read zarr 3 is faster. The numbers are the same on main (bd0dc84).

In a PyTorch DataLoader it depends on how the workers start. With fork (the Linux default) numcodecs ignores use_threads and decodes on one thread, so True changes nothing. With spawn or forkserver it takes effect: 1.25-1.69x the samples/s of zarr 2 on real data (finding 6).

The script is in steps to reproduce. On my 8-core laptop (warm cache, so this is CPU work, not storage):

ms per chunk (median of 12)
zarr 2.18.7 69.6
zarr 3.4.1, as installed 183.9
zarr 3.4.1, numcodecs.blosc.use_threads = True 108.4
zarr main bd0dc84, as installed / with threads 183.3 / 107.4

Main findings

  1. zarr 3 turns Blosc threading off at import. blosc.py#L104 sets numcodecs.blosc.use_threads = False (docstring: "to avoid threading issues in asyncio contexts"), so one large chunk decodes on one thread. Decoding one 219 MB raw Blosc buffer takes 28 ms in a zarr 2 process and 103 ms in a zarr 3 process (raw_decode_486.py, end of steps to reproduce). None does not help: zarr decodes on worker threads and numcodecs only enables threads on the main thread.
  2. Most of the gap is decode; a copy explains part of the rest. On a 48-core machine, one 219 MB chunk takes 52.5 ms with zarr 2, 165.2 ms with zarr 3 and 102.8 ms with zarr 3 and use_threads=True. A py-spy profile of 60 reads of that array with zarr 3 (all threads, 2,488 samples) puts 49% of the samples in Blosc decode (_decode_sync, codecs/_v2.py:34) and 30% in NDBuffer.__setitem__, reached from scatter_chunk: the decoded chunk is copied into the output array, while zarr 2 decodes into it. With use_threads=True decode drops to 5% and the copy is 64% of the samples. That the remaining ~45 ms is this copy is my reading of the profile. The commands are in steps to reproduce.
  3. FusedCodecPipeline makes no difference for one thread reading one chunk: 170.6 vs 170.7 ms per read. It still decodes and scatters in one worker thread (read calls read_sync through asyncio.to_thread).
  4. Chunk layout matters. With 4 chunks along the grid zarr 3 is faster than zarr 2 (87 vs 104 ms, 64 ms with FusedCodecPipeline): small chunks parallelize across threads, one large chunk does not. Cold reads are not affected (zarr 3 / zarr 2 = 0.95-1.06).
  5. Real data. anemoi-datasets format 2 ERA5 O96 (104 variables x 40320 points, ~17 MB decoded per chunk, one chunk per date) read through a torch DataLoader, warm, one process: zarr 3 reaches 0.43x of zarr 2's samples/s (0.74x with use_threads=True). With 4-8 workers it is 0.83x-0.94x and use_threads changes nothing, because the workers are forked (finding 6). The remaining 6-17% is not use_threads: one 219 MB chunk read in a forked child took about 168 ms with zarr 3 against about 119 ms with zarr 2.
  6. fork vs spawn/forkserver. In a process whose pid differs from the one that imported numcodecs (a forked child), numcodecs returns False before it reads use_threads, for zarr 2 and zarr 3 alike. I logged the setting inside the workers. Samples/s as a ratio to zarr 2 on the O96 data (zarr 3 default / zarr 3 with True):
workers fork spawn forkserver
4 0.76 / 0.76 0.79 / 1.59 0.84 / 1.69
8 0.77 / 0.76 0.73 / 1.25 0.80 / 1.27

zarr 2 decodes on one thread in spawned workers, so the gain is threaded against single-threaded decoding. With no workers: 0.25 / 0.56. One machine, no real training run.

More measurements (numcodecs lock, safety tests with threads on, format 2 vs 3, a sharded layout)
  1. numcodecs side. With True, Blosc.decode takes a module-level lock and calls blosc_decompress; with False it calls blosc_decompress_ctx(..., 1) without the lock (blosc.pyx, search _MUTEX and _importer_pid; I read main, not 0.17.0). I could not find the discussion that added the line to zarr (it arrives with "Extensible codecs for V3", Extensible codecs for V3 #1588), so a lock under concurrent decodes as the reason is my guess. With 1, 4 and 8 Python threads, True was never slower than False (reads/s 6.3 / 12.5 / 14.8 with False, 9.1 / 14.7 / 16.7 with True); with BLOSC_NTHREADS=1 it was 20-28% slower, which fits a lock, but I did not test that directly. Performance regression in V3 #2710 notes that Blosc only uses its threads on the main thread. Decoding a 219 MB lz4 buffer (numcodecs 0.15.1, 0.16.5, 0.17.0 alike): main thread ~14 ms with None or True, 72 ms with False; forked child ~73 ms with every setting; spawned child 15 ms with True, 73 ms with None. zarr 2 reaches 9.1 / 30.5 / 44.5 reads/s with 1 / 4 / 8 threads, while zarr 3 stops at about 15 whatever use_threads is. I am opening a separate issue about that (Several Python threads reading large single-chunk Blosc arrays stop scaling at about 15 reads/s with zarr 3's default codec pipeline (zarr 2: 44, FusedCodecPipeline: 39) #4532 ); there FusedCodecPipeline reaches 39-43 reads/s with 8 threads.
  2. Safety of use_threads = True (not a proof). I compared an MD5 of every decoded sample with a reference read with use_threads = False: 0 mismatches, 0 hangs, 0 errors with one thread; 8 threads; 8 threads while another thread flips the flag; asyncio.gather in one event loop and in 4 threads with their own loops; forked children and a 4-worker DataLoader (threads were only on in the parent, see finding 6); and 15-minute runs with persistent spawn and forkserver workers (4 and 8) and 4 MPI ranks x 4 spawn workers, about 1.05 million samples, longest wait for a batch 1.3 s. One machine and one zarr version: I could not provoke a failure, which does not show there is none.
  3. Format 2 and format 3 files read at the same speed (170.7 vs 170.3 ms): the effect comes from the codec and its threading, not the metadata format. That is with Blosc. With zarr 3's default compressor for format 3 (zstd, no shuffle, what create_array uses when none is passed) there is no Blosc, so use_threads does not apply, and one thread reads the 219 MB test array in about 270 ms against about 157 ms with Blosc lz4 on a 48-core machine (3.7 vs 6.4 reads/s). In the thread test of the separate issue, format 3 with Blosc behaves like format 2.
  4. Sharded layout, one process. One variable of a sharded zarr 3 ERA5 0.25° store (54 MB decoded per time step, Blosc zstd level 5 with shuffle), warm, 30 time steps in random order: zarr 2 on a format 2 copy 21.0 ms per step; zarr 3 on that copy 75.7 ms, 33.5 ms with use_threads=True; zarr 3 on the sharded store 88.6 ms, 46.1 ms with True. Sharding adds about 13 ms. One variable, no DataLoader, and zstd decodes slower than the lz4 used elsewhere.
  5. FusedCodecPipeline and fork. FusedCodecPipeline: forked worker hangs on a multi-chunk read; reset_resources_after_fork misses codec_pipeline._pool #4478 / fix: reset the codec-pipeline thread pool after fork #4483 (closed 2026-10-09) describe a read that hangs in a forked child. I saw one hang with FusedCodecPipeline when the parent had read before fork and could not reproduce it; it may have been that bug. 3.4.1 predates the fix: which release will carry it?

Questions

  1. Is use_threads = False meant to stay? Would it be acceptable to leave Blosc threading on when a read decodes a single large chunk, or to expose the setting through zarr.config so applications can choose without reaching into numcodecs after import? If the concern is the numcodecs lock under concurrent decodes, is there a variant you would consider safe?
  2. Could the decoded chunk be written directly into the output array for whole-chunk selections? I found Codec pipeline performance #2904 and its sub-issues (should encode and decode both take an optional reference to a buffer #3060, Add API to Store interface to read directly into an output buffer #3429) which look like the right place. Is this still planned, and would a benchmark case help?
  3. Would a benchmark with a large compressed chunk be welcome? The existing ones use in-memory or uncompressed arrays, which is probably why Array indexing with Zarr 3 is noticeably slower than with Zarr 2 #3524 looked resolved. I have a small pytest-benchmark case (32 MiB Blosc-lz4 chunks, one vs four chunks per read, threads off/on) for tests/benchmarks/ that I can open as a PR if useful.

Steps to reproduce

My example to reproduce things with synthetic data, one operation (arr[i]):

# /// script
# requires-python = "==3.12.*"
# dependencies = [
#     "zarr==3.4.1",
#     "numcodecs==0.17.0",
#     "numpy==2.5.4",
# ]
# ///
"""Single-chunk Blosc read: zarr-python 3 vs 2 (anemoi-datasets #486). Synthetic data, one operation: ``arr[i]``.

zarr 3:  uv run repro_486.py
zarr 2:  uv run --python 3.12 --with 'zarr==2.18.7' --with 'numcodecs==0.15.1' --with 'numpy==2.5.4' --no-project repro_486.py   (override the pin above)
The data are written once, the page cache is warm (the files are read once untimed), so this measures CPU work, not storage.
"""
import statistics as st
import tempfile
import time

import numcodecs
import numpy as np
import zarr
from numcodecs import blosc

SHAPE = (4, 101, 1, 542080)  # ERA5 N320 date: 101 variables x 542080 points, float32, ~219 MB decoded per chunk
CHUNKS = (1, 101, 1, 542080)  # one chunk per date, as in anemoi-datasets #486
ZV = int(zarr.__version__.split(".")[0])


def create(path):
    rng = np.random.default_rng(0)
    base = rng.standard_normal(SHAPE[1:]).cumsum(-1).astype("f4")
    if ZV >= 3:
        arr = zarr.create_array(path, shape=SHAPE, chunks=CHUNKS, dtype="f4", compressors=numcodecs.Blosc("lz4"), zarr_format=2, overwrite=True)
    else:
        arr = zarr.open_array(path, mode="w", shape=SHAPE, chunks=CHUNKS, dtype="f4", compressor=numcodecs.Blosc("lz4"))
    for i in range(SHAPE[0]):
        arr[i] = base + np.float32(i)


def timed(arr, label):
    arr[0]  # warm the page cache and any lazy initialization
    t = [(lambda t0: (arr[i % SHAPE[0]], time.perf_counter() - t0)[1])(time.perf_counter()) for i in range(12)]
    print(f"  {label:34s} {st.median(t) * 1e3:7.1f} ms per chunk  (median of 12)")


with tempfile.TemporaryDirectory() as d:
    create(d + "/a.zarr")
    arr = zarr.open_array(d + "/a.zarr", mode="r")
    print(f"zarr {zarr.__version__}, numcodecs {numcodecs.__version__}, numpy {np.__version__}; numcodecs.blosc.use_threads = {blosc.use_threads}")
    timed(arr, "as installed")
    if ZV >= 3:
        blosc.use_threads = True
        timed(arr, "numcodecs.blosc.use_threads = True")

To run it, save it as repro_486.py. With zarr 3: uv run repro_486.py. With zarr 2: uv run --python 3.12 --with 'zarr==2.18.7' --with 'numcodecs==0.15.1' --with 'numpy==2.5.4' --no-project repro_486.py. Versions: Python 3.12, numpy 2.5.4, zarr 3.4.1 + numcodecs 0.17.0 against zarr 2.18.7 + numcodecs 0.15.1; py-spy 0.4.2. For a format 3 store, replace the zarr.create_array(...) call in create() for zarr 3 with arr = zarr.create_array(path, shape=SHAPE, chunks=CHUNKS, dtype="f4", zarr_format=3, overwrite=True) (zarr 3's default zstd compressor) or add compressors=zarr.codecs.BloscCodec(cname="lz4", clevel=5, shuffle="shuffle") for Blosc.

On a 48-core machine: zarr 2.18.7 52.6-53.6 ms, zarr 3.4.1 164.4-165.9 ms, zarr 3.4.1 with use_threads=True 101.0-101.8 ms.

To profile, I used a second script that creates the same array once and then only loops over reads, so that creating the array is not in the profile (repro_486_loop.py, below), and py-spy 0.4.2 installed in the same virtual environment as zarr:

py-spy --version
python repro_486_loop.py create a.zarr          # once, zarr 2 or zarr 3
py-spy record --format raw --threads --rate 250 -o prof.raw -- python repro_486_loop.py read a.zarr default 60
py-spy record --format raw --threads --rate 250 -o prof_threads.raw -- python repro_486_loop.py read a.zarr threads 60

prof.raw is one line per stack with a sample count; I summed the counts by the innermost frame of each stack. py-spy needs permission to attach to a process (on Linux, kernel.yama.ptrace_scope 0, or run it as root). These 60-read timings were 55.4 ms (zarr 2), 162.9 ms (zarr 3) and 107.0 ms (zarr 3 with use_threads).

repro_486_loop.py (read loop used for the profile)
"""Read loop for py-spy: create <path> once (zarr 2 env), then loop arr[i] on the same array as repro_486.py.
usage: repro_486_loop.py create <path> | read <path> <default|threads> <n_reads>"""
import sys, time, statistics as st
import numcodecs, numpy as np, zarr
from numcodecs import blosc
SHAPE = (4, 101, 1, 542080); CHUNKS = (1, 101, 1, 542080)
cmd, path = sys.argv[1:3]
ZV = int(zarr.__version__.split(".")[0])
if cmd == "create":
    rng = np.random.default_rng(0); base = rng.standard_normal(SHAPE[1:]).cumsum(-1).astype("f4")
    if ZV >= 3:
        arr = zarr.create_array(path, shape=SHAPE, chunks=CHUNKS, dtype="f4", compressors=numcodecs.Blosc("lz4"), zarr_format=2, overwrite=True)
    else:
        arr = zarr.open_array(path, mode="w", shape=SHAPE, chunks=CHUNKS, dtype="f4", compressor=numcodecs.Blosc("lz4"))
    for i in range(SHAPE[0]): arr[i] = base + np.float32(i)
else:
    mode, n = sys.argv[3], int(sys.argv[4])
    arr = zarr.open_array(path, mode="r")
    if mode == "threads": blosc.use_threads = True
    arr[0]
    t = []
    for k in range(n):
        t0 = time.perf_counter(); arr[k % SHAPE[0]]; t.append(time.perf_counter() - t0)
    print(f"zarr {zarr.__version__} numcodecs {numcodecs.__version__} mode={mode} use_threads={blosc.use_threads} median {st.median(t)*1e3:.1f} ms over {n} reads")
raw_decode_486.py (raw Blosc decode, no zarr read path; used for the decode numbers in finding 1)
# /// script
# requires-python = "==3.12.*"
# dependencies = ["zarr==3.4.1", "numcodecs==0.17.0", "numpy==2.5.4"]
# ///
"""Raw Blosc decode of one 219 MB chunk, no zarr read path involved. Importing zarr 3 turns numcodecs' Blosc threading off.

zarr 3:  uv run raw_decode_486.py
zarr 2:  uv run --python 3.12 --with 'zarr==2.18.7' --with 'numcodecs==0.15.1' --with 'numpy==2.5.4' --no-project raw_decode_486.py
"""
import time, statistics as st, numpy as np, numcodecs, zarr
from numcodecs import blosc
rng = np.random.default_rng(0)
x = rng.standard_normal((101, 542080)).cumsum(-1).astype("f4")
c = numcodecs.Blosc("lz4", clevel=5, shuffle=1)
enc = c.encode(x)
def t():
    r=[]
    for _ in range(7):
        t0=time.perf_counter(); c.decode(enc); r.append(time.perf_counter()-t0)
    return st.median(r)*1e3
print(f"zarr {zarr.__version__} numcodecs {numcodecs.__version__} use_threads={blosc.use_threads}: raw decode of {x.nbytes/1e6:.0f} MB = {t():.1f} ms")

Additional output

No response

Activity

  1. added
    bugPotential issues with the zarr-python library
    on Oct 20, 2026
  2. rabernat commented on Oct 20, 2026

    @rabernat
    Contributor

    I wanted to comment with an observation about why it is hard to simply set blosc.use_threads = True.

    A typical ML training pipeline may have up to five different layers of concurrency. Starting at the outer layer:

    1. The pytorch dataloader, running across multiple processes
    2. [optional] Dask, within each process
    3. The zarr threadpool that the codecs run in (set by threading.max_workers)
    4. The zarr asyncio tasks
    5. [optional] Blosc threads

    This makes it confoundingly difficult to know what the "right" settings are. The defaults that speed up one case (like using blosc threads, as suggested here) could really hurt another case (e.g. if you're using parallelism at other levels, like Dask).

    Zarr 3 contains its own threadpool, so it can do parallelized decoding across many chunks. This was missing from Zarr 2, forcing users to use Dask (which is overkill, and significant overhead, for simple data loading situations). This is better for many situations, but this issue shows how it is worse for the edge case of very large chunks.

    FWIW, especially now that we have sharding, I would not recommend using chunks of more than ~10 MB. I understand that existing Anemoi datasets pack a lot of data into a single chunk, but that choice may have been made to work around inefficiencies and gaps in Zarr 2 that have now been fixed in Zarr 3.

    In any case, I would support allowing the user to configure the number of blosc threads if that is feasible.

  3. sheikhayaan commented on Oct 20, 2026

    @sheikhayaan
    Contributor

    rabernat suggested exposing this through zarr.config, so I can put up a PR for that. I'd add a config option for numcodecs.blosc.use_threads, defaulting to whatever the current behavior is. Does that sound like the right approach?

  4. d-v-b commented on Oct 20, 2026

    @d-v-b
    Contributor

    we should absolutely expose the runtime configuration of the blosc codec via the config but we should consider a comprehensive design that composes with other routines that use threads. for example, we could model the available threads as a consumable quantity to avoid oversubscription.

  5. sheikhayaan commented on Oct 20, 2026

    @sheikhayaan
    Contributor

    "That makes sense. A consumable thread budget across the async pool and Blosc would be much more robust. For this PR, I'm keeping the scope limited to just exposing the boolean blosc.use_threads so users have an escape hatch. Would you prefer I add a note in the docs that a more comprehensive thread pool design is coming in the future, or should we hold off on merging this until that design is ready?"

  6. d-v-b commented on Oct 20, 2026

    @d-v-b
    Contributor

    presumably the use_threads option either doesn't work or conflicts with the thread budget once we implement that. so to avoid churn, we should scope out what the thread budget would cost in terms of complexity / code change, and decide from there whether a plain use_threads configuration is worth the effort.

  7. rabernat commented on Oct 20, 2026

    @rabernat
    Contributor

    I like the budget idea in principle, but in practice, it's very hard. Which part of the stack owns the budget?

    Example: the user is building a fastapi backend service that uses dask and zarr to read data from the cloud and return results over HTTP. The server is running on a 16 core machine.

    Where would this budget live? Who decides how to allocate the available cores across fastapi, dask, zarr, and blosc?

  8. d-v-b commented on Oct 20, 2026

    @d-v-b
    Contributor

    we can't control what dask or fastapi do, only what we do in this library. if we have 2 separate routines that claim a shared resource (threads), then we should have some way to coordinate that. a budget is one idea, predicted on the assumption that there's an order in which threads are claimed (e.g., the codec pipeline requests threads first, and the blosc codec is entitled to the remainder, given the budget). the consumable threads idea is just one concept, we could also use others. but the basic point has nothing to do with dask. it's just about providing an API to ensure that resource consumption within this library is coordinated.

  9. d-v-b commented on Oct 20, 2026

    @d-v-b
    Contributor

    the alternative to some degree of coordination within zarr is none, where users will need to ensure that the number of threads they allocate to e.g. the codec pipeline composes with the number of threads blosc uses. that's worse for interactions with other thread-hungry programs like dask and fastapi than a coordinated API within zarr.

  10. rabernat commented on Oct 20, 2026

    @rabernat
    Contributor

    @sheikhayaan - does #4534

    1. work?
    2. fix your performance regression vs Zarr 2?
  11. d-v-b commented on Oct 20, 2026

    @d-v-b
    Contributor

    we are already guilty of uncoordinated concurrency settings in this library, because we have threading.max_workers as a separate knob from codec_pipeline.max_workers on the fused codec pipeline. lets not add any more debt. here's a concrete plan for blosc specifically:

    1. In numcodecs: add an nthreads parameter to blosc.compress and blosc.decompress that is passed to the _ctx functions.
    2. in zarr: add one config key that defines the budget. the codec pipeline hands each decode max(1, budget // concurrent_tasks).
    3. in zarr: fold codec_pipeline.max_workers into threading.max_workers. there is a single number.
  12. sheikhayaan commented on Oct 21, 2026

    @sheikhayaan
    Contributor

    @rabernat Yes, setting blosc.use_threads = True via this config does fix the performance regression. It drops the single-chunk read time from ~183ms back down to ~108ms, which closes more than half the gap to Zarr 2.

    @d-v-b I completely understand your point about the thread budget and avoiding technical debt. Since this PR only exposes the boolean, and your plan involves a new nthreads parameter in numcodecs plus a coordinated Zarr thread budget, how would you like to proceed?

    Should we close this PR for now and wait for the comprehensive thread budget design? Or would you prefer to merge this as a temporary escape hatch for users hitting this specific large-chunk regression, with a note in the docs that a budget system is coming?

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugPotential issues with the zarr-python library

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions