Repository navigation
Decide threads and the GIL per chunk for Python codecs and filters - #769
Open
FabianIsensee wants to merge 2 commits into
Open
FabianIsensee wants to merge 2 commits into
FabianIsensee wants to merge 2 commits into
Conversation
decompress2() and vldecompress() decompress with the nthreads they are given (blosc2.nthreads by default), decompress() with the global context and its number of threads. For a chunk that uses a codec or filter registered from Python, the callbacks then run on C-Blosc2's worker threads, which never hold the GIL, and the interpreter crashes (with one thread, the callbacks raise because there is no super-chunk). Super-chunks refuse such dparams in _check_dparams(), but these functions only get a chunk, which names its codec and filters in its header only. Read them from the header (_chunk_runs_python(), with the id arrays of register_codec() and register_filter()) and use one thread for those chunks: decompress2() and vldecompress() lower nthreads, decompress() uses a context of one thread of its own instead of the global one. Such calls now end as they do with one thread; other chunks keep their threads. Chunks that name a Python codec but that C-Blosc2 decodes without calling it (memcpyed, special values) still decompress. decompress2() and vldecompress() now create their context after checking src, so a too short src no longer leaks it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiPFztdpnKMJkzrjT36BuP
With set_releasegil(True), decompress2() kept the GIL whenever any codec or filter was registered from Python, and to_cframe() whenever _can_release_gil() said so, which is also the case whenever anything is registered: one registered Python codec made all such calls hold the GIL (4 threads decompress2() 256 KiB chunks at 15 us per chunk instead of 5). decompress2() now keeps the GIL only for a chunk whose header names a Python codec or filter (_chunk_runs_python()). to_cframe() (_can_serialize_without_gil()) releases it for an in-memory contiguous frame, which it only copies, and for an in-memory sparse super-chunk without prefilter and postfilter, whose chunks C-Blosc2 copies as they are; only its vlmetalayers get decompressed, so their headers are checked the same way. A prefilter or postfilter makes C-Blosc2 decompress and recompress every chunk, and a frame on disk can get its vlmetalayers reloaded during the call, so those keep the previous check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiPFztdpnKMJkzrjT36BuP
This was referenced Nov 6, 2026
Release the input buffer when SChunk(data=...) fails; test the prefilter writes without the GIL
#777
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi Francesc,
two commits on
mainabout chunks that use a codec or filter registered from Python (register_codec()/register_filter()). Both need to know whether a chunk uses one, so both read it from the chunk header with one new helper,_chunk_runs_python(): the codec (including the user-defined codec byte of the extended header) and the 6 filter ids, checked against the id arrays from #766. No Python lookups; with nothing registered from Python it returns at once.1. Crash with more than one thread.
decompress2(),vldecompress()anddecompress()crash when the chunk uses a Python codec or filter and the call uses more than one thread: the callbacks then run on C-Blosc2's worker threads, which never hold the GIL.decompress2()andvldecompress()useblosc2.nthreadsby default, so a plainblosc2.decompress2(chunk)segfaults on any multi-core machine once the chunk has more than one block;decompress()does afterblosc2.set_nthreads(n > 1). SChunks refuse such dparams in_check_dparams(), but these functions only get a chunk.Fix:
decompress2()andvldecompress()use one thread for such chunks (they now create their context after looking atsrc, which also stops them from leaking it whensrcis too short);decompress()decompresses them in a context of one thread of its own instead of the global context (_blosc1_decompress()).vldecompress_block()needs nothing, C-Blosc2 decodes a single VL block on the calling thread. Such calls now end as they do with one thread (the callback'sRuntimeError). Chunks whose header names a Python codec but that C-Blosc2 decodes without calling it (memcpyed or special-value chunks) still decompress; that is why this drops to one thread instead of raising up front. Other chunks keep their threads, also while a Python codec is registered; using one thread whenever anything is registered would have been simpler, but would make e.g. a 16 MiB LZ4 chunk take 651 instead of ~355 us on our test node (measured yesterday).2. GIL kept for calls that do not use Python callbacks. With
set_releasegil(True),decompress2()kept the GIL whenever any codec or filter was registered from Python, andSChunk.to_cframe()whenever_can_release_gil()said so, which is also the case whenever anything is registered. Nowdecompress2()keeps it only for a chunk that uses a Python codec or filter.to_cframe()releases it for super-chunks in memory: a contiguous one is only copied (blosc2_schunk_to_buffer_alloc()copiesframe->cframe), and a sparse one without prefilter and postfilter gets its chunks copied as they are, so only its vlmetalayers are decompressed, and their headers get the same check. Everything else keeps the previous check: with a prefilter or postfilter C-Blosc2 decompresses and recompresses every chunk, and a frame on disk can get its vlmetalayers reloaded from the file during the call. Theset_releasegil()docstring says thatdecompress2()keeps the GIL for such chunks. One difference to before: if another thread changes a writablesrcbetween the header check and the C call, a Python codec named only by the new header could run without the GIL; I think that data race is the caller's.Tests:
tests/test_python_codec_threads.pyruns, in a subprocess with 1 and with 4 threads,decompress2()anddecompress()of a codec chunk and a filter chunk,vldecompress()of VL-block chunks whose header names the Python codec or filter (vlcompress()cannot run them), anddecompress2()of a plain chunk. It checks that the callbacks raise, that the 4-thread outcomes equal the 1-thread ones, and that the plain chunk decompresses. Without the first commit the 4-thread subprocess segfaults (each of the 6 calls does on its own). Skipped on WASM.tests/test_releasegil_callbacks.py:test_decompress2_releases_the_gil_next_to_python_codecsandtest_to_cframe_releases_the_gil_next_to_python_codecs(contiguous and sparse) check with a ticker thread, as the write test does, that the call lets other threads run while an unused Python codec and filter are registered; without the second commit all three fail (the longest gap is 0.88-0.99 of the call).test_to_cframe_python_codec_filter(codec and filter, contiguous and sparse, GIL held and released) checks that serializing such a super-chunk calls none of the callbacks and that the frame reads back;test_to_cframe_contiguous_callbacksdoes the same with a Python prefilter or postfilter on a contiguous frame. The retry loop of the write test moved intoassert_releases_the_gil().pytest testsonmain5ff9823: 11013 passed,main10997 (+16 new cases); the 2 failures are the b2view http tests that need aiohttp, on both. Fork CI (da5cf56): green on Linux x86 (Python 3.12 default and legacy, 3.14), Linux ARM, macOS, Windows, NumPy 1.26 and WASM.Perf (EPYC 9554 node, 3 interleaved rounds against
mainand a second build ofmain, us per call):decompress2()of 4 KiB and 64 KiB chunks with 1 and 4 threads, with and without an unused Python codec registered, is within ±1.1% ofmain(the second build ofmain: ±1.2%); 16 MiB chunks with 4 threads and an unused Python codec registered keep their threads (335 vs 368 us onmain, which is noisy there: second build 349). Withset_releasegil(True)and an unused Python codec registered, 4 threads callingdecompress2()on 256 KiB zstd chunks take 7.1 us per chunk instead of 21.2 (7.3 without the codec).Not in this PR (I can follow up):
dparams.nthreadswithBLOSC_NTHREADSinblosc2_create_dctx(), so withBLOSC_NTHREADS=4these calls still run the callbacks on worker threads, and so do SChunks with a Python codec (_check_dparams()checks the requested nthreads, not the effective ones). A super-chunk made byschunk_from_cframe()or opened from a file gets C-Blosc2's global number of threads without_check_dparams(), with the same result afterblosc2.set_nthreads(n > 1). A flag onblosc2_codec/blosc2_filterregistration saying "needs the calling thread and the GIL" would let C-Blosc2 run such chunks serially itself and cover all of these for every binding, and would also let the NDArray slice reads, which still keep the GIL whenever anything is registered from Python, decide per chunk. I can propose that in c-blosc2 if you like the idea.I used Claude Code (Anthropic) to find the causes, write the fixes and the tests, and run the benchmarks; I reviewed the result.
Best,
Fabian
🤖 Generated with Claude Code
https://claude.ai/code/session_01DiPFztdpnKMJkzrjT36BuP