You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We use to_thread in the BatchedCodecPipeline for running CPU-heavy work on a separate thread. to_thread is slow, according to python/cpython#136084 and also our observations in the development of the synchronous codec pipeline. Something faster than to_thread (e.g., a plain thread pool!) but also "coroutiney" for CPU-heavy work would be worth investigating.
I'm interested in this, however I'm not terribly familiar with zarr. I do see quite a bit of to_thread uses in the zarr implementation.
Is there any chance you could put together a runnable example that demonstrates the slowness? That way I'll have a concrete example to share with the asyncio experts.
I reproduced the MVCE on Windows 11 (Python 3.12.14, zarr 3.4.1) and then measured the dispatch on its own, because the guess in the issue, "something faster than to_thread, e.g. a plain thread pool", turns out not to hold on this machine.
This box was not quiet while I measured (other builds running on it), so absolute numbers move around between runs. I interleaved the variants round by round and report percentiles, so this is not a comparison of two different machine states.
Dispatch cost on its own, no payload
3000 interleaved rounds per variant after 300 warm-up rounds, no-op body:
how the call is dispatched
min
p10
median
mean
p90
direct call, no dispatch
0.7
1.0
1.1
1.2
1.3
asyncio.to_thread
93.6
117.7
139.5
140.9
165.0
loop.run_in_executor on a dedicated ThreadPoolExecutor(max_workers=8)
88.8
111.7
135.3
137.3
163.5
loop.run_in_executor(None, ...)
95.2
116.2
138.4
140.4
165.9
Microseconds per call. The three threaded variants are within 4 us of each other at every percentile. A hand-rolled pool does not buy anything here: the cost is the round trip to another thread and back, not the way asyncio.to_thread is spelled.
The same thing on the real workload
The MVCE array (1000 chunks of (1000,) i4), compressed and uncompressed, with np.array_equal checked on every variant so that a faster run cannot silently write different data. I ran the whole thing three times:
case
to_thread vs direct sync call, write
to_thread vs direct sync call, read
v2, compressors=None
1.78x / 2.08x / 2.07x
2.21x / 2.57x / 2.81x
v2, default compressor
1.99x / 1.89x / 2.40x
2.13x / 2.41x / 2.45x
The three numbers are three separate runs, each of them a min over 7 repeats.
Measured inside the write path, one uncompressed 1000-element chunk takes about 6 us of actual _encode_sync work (median over 1000 chunks), while any threaded variant pays the dispatch on top of that. That ratio is the whole problem: the payload is an astype that in practice does not copy plus a bytes() view, two orders of magnitude smaller than a thread hop.
It is not only V2Codec
Per-chunk asyncio.to_thread is the pattern across the codec layer on main (a2f2415): src/zarr/codecs/blosc.py:332 and :350, gzip.py:68 and :82, zstd.py:85 and :99, numcodecs/_codecs.py:147, :150, :170, :173, :193, :196, and _v2.py:130, :137. v3 BytesCodec (bytes.py:133, :159) does its work inline, which is why v3 without a compressor did not regress.
Caveat
One Windows machine, and I could not run a Linux comparison (no WSL here). Neither the MVCE above nor this comment establishes what a thread hop costs on Linux; if it is an order of magnitude cheaper there, this is a Windows-shaped problem and that changes how much restructuring is worth it. Re-running the dispatch benchmark on Linux before deciding seems worth doing.
Assuming the number holds up, there are two directions, and I would rather ask which one is acceptable than guess:
Skip the hop when there is nothing to move off the loop: when filters is None and compressor is None, call _encode_sync inline. There is no blocking work in that branch, just the astype and the byte view.
Keep one hop but amortise it: submit a batch of chunks per executor call so that 1000 chunks pay one hop instead of 1000.
The second is the more general fix, but it changes how the pipeline schedules work, so it is presumably not a small PR.
Activity
ngoldbaum commentedon Aug 26, 2026
I'm interested in this, however I'm not terribly familiar with zarr. I do see quite a bit of
to_threaduses in the zarr implementation.Is there any chance you could put together a runnable example that demonstrates the slowness? That way I'll have a concrete example to share with the asyncio experts.
maxrjones commentedon Oct 17, 2026
here's a concrete example that demonstrates the slowness:
Duo-ee commentedon Oct 28, 2026
I reproduced the MVCE on Windows 11 (Python 3.12.14, zarr 3.4.1) and then measured the dispatch on its own, because the guess in the issue, "something faster than
to_thread, e.g. a plain thread pool", turns out not to hold on this machine.This box was not quiet while I measured (other builds running on it), so absolute numbers move around between runs. I interleaved the variants round by round and report percentiles, so this is not a comparison of two different machine states.
Dispatch cost on its own, no payload
3000 interleaved rounds per variant after 300 warm-up rounds, no-op body:
asyncio.to_threadloop.run_in_executoron a dedicatedThreadPoolExecutor(max_workers=8)loop.run_in_executor(None, ...)Microseconds per call. The three threaded variants are within 4 us of each other at every percentile. A hand-rolled pool does not buy anything here: the cost is the round trip to another thread and back, not the way
asyncio.to_threadis spelled.The same thing on the real workload
The MVCE array (1000 chunks of
(1000,)i4), compressed and uncompressed, withnp.array_equalchecked on every variant so that a faster run cannot silently write different data. I ran the whole thing three times:to_threadvs direct sync call, writeto_threadvs direct sync call, readcompressors=NoneThe three numbers are three separate runs, each of them a min over 7 repeats.
Measured inside the write path, one uncompressed 1000-element chunk takes about 6 us of actual
_encode_syncwork (median over 1000 chunks), while any threaded variant pays the dispatch on top of that. That ratio is the whole problem: the payload is anastypethat in practice does not copy plus abytes()view, two orders of magnitude smaller than a thread hop.It is not only
V2CodecPer-chunk
asyncio.to_threadis the pattern across the codec layer on main (a2f2415):src/zarr/codecs/blosc.py:332and:350,gzip.py:68and:82,zstd.py:85and:99,numcodecs/_codecs.py:147,:150,:170,:173,:193,:196, and_v2.py:130,:137. v3BytesCodec(bytes.py:133,:159) does its work inline, which is why v3 without a compressor did not regress.Caveat
One Windows machine, and I could not run a Linux comparison (no WSL here). Neither the MVCE above nor this comment establishes what a thread hop costs on Linux; if it is an order of magnitude cheaper there, this is a Windows-shaped problem and that changes how much restructuring is worth it. Re-running the dispatch benchmark on Linux before deciding seems worth doing.
Assuming the number holds up, there are two directions, and I would rather ask which one is acceptable than guess:
filters is None and compressor is None, call_encode_syncinline. There is no blocking work in that branch, just theastypeand the byte view.The second is the more general fix, but it changes how the pipeline schedules work, so it is presumably not a small PR.