Skip to content

fix: keep FusedCodecPipeline compute off the event-loop thread - #247

Open
d-v-b wants to merge 4 commits into
mainfrom
claude/fusedcodecpipeline-zstd-perf-0bb07d
Open

fix: keep FusedCodecPipeline compute off the event-loop thread#247
d-v-b wants to merge 4 commits into
mainfrom
claude/fusedcodecpipeline-zstd-perf-0bb07d

Conversation

@d-v-b

@d-v-b d-v-b commented Jul 28, 2026

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

fix: keep FusedCodecPipeline compute off the event-loop thread

Problem

Users reported that the opt-in FusedCodecPipeline is slower than
BatchedCodecPipeline for zstd-compressed data. Investigation showed the
fused pipeline is actually ~2x faster in every single-threaded scenario; the
regression only appears under concurrent access from multiple user threads
(the dask/xarray pattern: many threads, each reading roughly one chunk per
call).

Root cause: FusedCodecPipeline.read/write ran their synchronous fast path
(read_sync/write_sync) inline on the coroutine servicing the request —
i.e. on the global zarr_io event-loop thread. Single-chunk batches
decoded inline on the loop; multi-chunk batches blocked the loop in
pool.map until the whole batch finished. Since every sync-API call from
every user thread is serviced by that one loop, concurrent operations
serialized behind each other's codec compute. The blocked-loop window is the
codec compute time, so zstd-compressed data (real CPU work per chunk) showed
the regression prominently while uncompressed data barely moved.

Evidence (8192x8192 f32, 64 x 4 MB chunks, zstd level 3, LocalStore; each
user thread reads one chunk per call):

user threads batched fused (before)
1 669 ms 336 ms
2 361 ms 334 ms
4 204 ms 407 ms
8 121 ms 439 ms

Batched scales with threads; fused was flat-to-degrading — the signature of
everything funneling through one thread. Tracing confirmed decode executed on
the thread named zarr_io for single-chunk reads.

Fix

Run the synchronous batch on a worker thread via asyncio.to_threadone
hop per batch, not per chunk
, so the fused design's win over per-chunk
async scheduling (the reason this pipeline exists) is preserved, while the
event loop stays free to service concurrent callers.

Benchmarks (after)

Same workload as above, interleaved in-process A/B (medians of 7 rounds;
"inline" = old behavior, "to_thread" = this PR):

workload inline (before) to_thread (after)
full-array read, 1 call 102 ms 102 ms
64 single-chunk reads, 8 threads 469-497 ms 105-107 ms

Thread-scaling after the fix (same benchmark as the table above):

user threads batched fused (after)
1 649 ms 625 ms
2 366 ms 329 ms
4 206 ms 183 ms
8 118 ms 109 ms

Fused now scales with reader threads and is at least as fast as batched at
every point. Single-threaded workloads are unchanged: the isolated cost of the
offload hop is ~75 µs per batch (measured with a noop through
sync(asyncio.to_thread(...))), which is noise next to per-chunk decode.
(An earlier A/B that appeared to show a ~2x single-thread regression for
sequential single-chunk reads turned out to be a thermal/ordering artifact:
reversing the variant order moved the inflated median to the other variant;
per-call interleaved p50s are 4.78 ms vs 4.89 ms.)

Single-threaded sweep (unchanged, fused/batched time ratios after this PR,
LocalStore): zstd write 0.62, zstd read 0.61, raw write 0.69, raw read 0.85.

Changes

  • FusedCodecPipeline.read/write: offload read_sync/write_sync to a
    worker thread via asyncio.to_thread, with a comment explaining why inline
    execution is forbidden.
  • New regression test test_sync_api_compute_off_event_loop: deterministic
    (no timing) — asserts codec compute never runs on a thread with a running
    event loop, for single- and multi-chunk reads and writes through the sync
    API.
  • New benchmark test_read_array_concurrent in tests/benchmarks/test_e2e.py
    covering the dask-style many-threads/one-chunk-per-call access pattern for
    both pipelines, zstd and uncompressed.
  • Changelog entry changes/247.bugfix.md.

🤖 Generated with Claude Code

dependabot Bot and others added 3 commits July 27, 2026 18:11
)

Bumps the actions group with 3 updates: [actions/labeler](https://github.com/actions/labeler), [actions/attest](https://github.com/actions/attest) and [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).


Updates `actions/labeler` from 6.2.0 to 7.0.0
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](actions/labeler@b8dd2d9...bf12e9b)

Updates `actions/attest` from 4.1.1 to 4.2.0
- [Release notes](https://github.com/actions/attest/releases)
- [Changelog](https://github.com/actions/attest/blob/main/RELEASE.md)
- [Commits](actions/attest@a1948c3...f7c74d2)

Updates `zizmorcore/zizmor-action` from 0.5.7 to 0.6.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](zizmorcore/zizmor-action@192e21d...6599ee8)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/attest
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…#4187)

Allows constructing a ZipStore from any seekable binary reader, enabling
zip archives on remote storage:

- io objects (BytesIO, fsspec file objects) are used directly
- minimal readers that are not io.IOBase instances and whose read() may
  return buffer-protocol objects rather than bytes (e.g.
  obstore.ReadableFile) are adapted via a small io.RawIOBase wrapper
  when opened for reading

clear()/move() raise NotImplementedError for file-object-backed stores.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…op thread

FusedCodecPipeline.read/write ran their synchronous fast path inline on
the coroutine servicing the request — i.e. on the global zarr_io event
loop thread. Single-chunk batches decoded inline on the loop and
multi-chunk batches blocked the loop in pool.map, so every sync-API call
from every user thread serialized behind each other's codec compute.
The blocked window scales with codec cost, which is why users reported
the fused pipeline as "slower for zstd-compressed data" under
multi-threaded (dask-style, one chunk per call) access: at 8 reader
threads on 4 MiB zstd chunks it was 3.4x slower than
BatchedCodecPipeline, and throughput did not scale with threads at all
(336 -> 439 ms from 1 to 8 threads, versus 669 -> 121 ms for batched).

Offload the synchronous batch to a worker thread with asyncio.to_thread:
one hop per batch, not per chunk, preserving the fused pipeline's win
over per-chunk async scheduling while keeping the loop free. After the
fix the same workload scales 625 -> 109 ms from 1 to 8 threads, beating
batched at every thread count; single-threaded performance is unchanged
(the hop costs ~75 us per batch).

The regression test asserts deterministically (no timing) that codec
compute never runs on a thread with a running event loop, covering
single- and multi-chunk reads and writes through the sync API. A new
benchmark covers the many-threads/one-chunk-per-call access pattern.

Assisted-by: ClaudeCode:claude-fable-5
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.

2 participants