Repository navigation
Large Blosc chunks decode about 3x slower than in zarr 2 because numcodecs.blosc.use_threads is set to False on import #4531
Description
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
- zarr 3 turns Blosc threading off at import.
blosc.py#L104setsnumcodecs.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).Nonedoes not help: zarr decodes on worker threads and numcodecs only enables threads on the main thread. - 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% inNDBuffer.__setitem__, reached fromscatter_chunk: the decoded chunk is copied into the output array, while zarr 2 decodes into it. Withuse_threads=Truedecode 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. FusedCodecPipelinemakes 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 (readcallsread_syncthroughasyncio.to_thread).- 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). - 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 withuse_threads=True). With 4-8 workers it is 0.83x-0.94x anduse_threadschanges nothing, because the workers are forked (finding 6). The remaining 6-17% is notuse_threads: one 219 MB chunk read in a forked child took about 168 ms with zarr 3 against about 119 ms with zarr 2. forkvsspawn/forkserver. In a process whose pid differs from the one that imported numcodecs (a forked child), numcodecs returnsFalsebefore it readsuse_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 withTrue):
| 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)
- numcodecs side. With
True,Blosc.decodetakes a module-level lock and callsblosc_decompress; withFalseit callsblosc_decompress_ctx(..., 1)without the lock (blosc.pyx, search_MUTEXand_importer_pid; I readmain, 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,Truewas never slower thanFalse(reads/s 6.3 / 12.5 / 14.8 withFalse, 9.1 / 14.7 / 16.7 withTrue); withBLOSC_NTHREADS=1it 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 withNoneorTrue, 72 ms withFalse; forked child ~73 ms with every setting; spawned child 15 ms withTrue, 73 ms withNone. zarr 2 reaches 9.1 / 30.5 / 44.5 reads/s with 1 / 4 / 8 threads, while zarr 3 stops at about 15 whateveruse_threadsis. 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 ); thereFusedCodecPipelinereaches 39-43 reads/s with 8 threads. - Safety of
use_threads = True(not a proof). I compared an MD5 of every decoded sample with a reference read withuse_threads = False: 0 mismatches, 0 hangs, 0 errors with one thread; 8 threads; 8 threads while another thread flips the flag;asyncio.gatherin one event loop and in 4 threads with their own loops; forked children and a 4-workerDataLoader(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. - 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_arrayuses when none is passed) there is no Blosc, souse_threadsdoes 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. - 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 withTrue. Sharding adds about 13 ms. One variable, noDataLoader, and zstd decodes slower than the lz4 used elsewhere. FusedCodecPipelineandfork. FusedCodecPipeline: forked worker hangs on a multi-chunk read;reset_resources_after_forkmissescodec_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 withFusedCodecPipelinewhen the parent had read beforeforkand could not reproduce it; it may have been that bug. 3.4.1 predates the fix: which release will carry it?
Questions
- Is
use_threads = Falsemeant to stay? Would it be acceptable to leave Blosc threading on when a read decodes a single large chunk, or to expose the setting throughzarr.configso 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? - 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
encodeanddecodeboth take an optional reference to a buffer #3060, Add API toStoreinterface to read directly into an output buffer #3429) which look like the right place. Is this still planned, and would a benchmark case help? - 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-benchmarkcase (32 MiB Blosc-lz4 chunks, one vs four chunks per read, threads off/on) fortests/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
rabernat commentedon Oct 20, 2026
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:
threading.max_workers)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.
sheikhayaan commentedon Oct 20, 2026
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?
d-v-b commentedon Oct 20, 2026
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.
sheikhayaan commentedon Oct 20, 2026
"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?"
d-v-b commentedon Oct 20, 2026
presumably the
use_threadsoption 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 plainuse_threadsconfiguration is worth the effort.rabernat commentedon Oct 20, 2026
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?
d-v-b commentedon Oct 20, 2026
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.
d-v-b commentedon Oct 20, 2026
the alternative to some degree of coordination within
zarris 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 withinzarr.rabernat commentedon Oct 20, 2026
@sheikhayaan - does #4534
d-v-b commentedon Oct 20, 2026
we are already guilty of uncoordinated concurrency settings in this library, because we have
threading.max_workersas a separate knob fromcodec_pipeline.max_workerson the fused codec pipeline. lets not add any more debt. here's a concrete plan for blosc specifically:max(1, budget // concurrent_tasks).sheikhayaan commentedon Oct 21, 2026
@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?