Repository navigation
Wait for every C call that runs without the GIL before blosc2_destroy() at exit - #772
Open
FabianIsensee wants to merge 1 commit into
Open
FabianIsensee wants to merge 1 commit into
FabianIsensee wants to merge 1 commit into
Conversation
destroy() waited only for the NDArray slice reads that run without the GIL before blosc2_destroy() frees the global context and the thread pools. The calls that set_releasegil(True) runs without the GIL (compress(), compress2(), decompress2(), vlcompress(), the SChunk writes and to_cframe()) were not counted, so a daemon thread inside blosc2.compress() at exit kept compressing with the freed global context and crashed (SIGSEGV in 4 of 5 runs with 4 MiB blocks). Count all of them with one pair of helpers, _nogil_enter() and _nogil_exit(), so that destroy(), register_codec() and register_filter() wait for every one and keep new ones from releasing the GIL meanwhile. The counter still only changes with the GIL held. 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,
a follow-up to #766 (merged): it extends the counter that main already had for slice reads to every C call that releases the GIL, including the SChunk writes that #765 moved to
_store_buffer()and the C-Blosc2 calls that #741 added in the native graph and Menudet paths.At exit, the atexit
destroy()waits (up to 1 s) for NDArray slice reads that run without the GIL before it callsblosc2_destroy(), which frees the global context and the shared thread pools. The calls thatset_releasegil(True)runs without the GIL (compress(),compress2(),decompress2(),vlcompress(), the SChunk writes in_store_buffer(),to_cframe()) are not counted. So a daemon thread insideblosc2.compress()at exit keeps compressing with the freed global context:On main (5ff9823, C-Blosc2 fa29efc0) this exits with SIGSEGV in 39 of 40 runs here (Linux, Python 3.12). With automatic blocks and 64 MiB of data it aborts with heap corruption (
malloc(): invalid size (unsorted)) in about a fifth of the runs (measured on 126fa8f); 4.14.1 crashes too. An ASan build reports a heap-use-after-free inblosc_c()/blosc2_compress(), freed byblosc2_destroy(). Calls with their own context (compress2, decompress2, SChunk writes, to_cframe, also with nthreads 4) did not show anything in 300+ ASan runs, but they use the same pools and are covered by the same change.What this PR does (
blosc2_ext.pyx):_nogil_enter(release)/_nogil_exit()bracket every C-Blosc2 call that releases the GIL._nogil_enter()says whether the call may release it (the caller's own condition, and nobody waiting for such calls to stop) and counts it until_nogil_exit(). Both run with the GIL held, as the old slice-read counter did, so this is one compare and one increment per call, inlined.compress(),_compress_ctx()(also used bySChunk._prefilter_data()now),_decompress_ctx(),_store_buffer(),vlcompress(),schunk_to_cframe(),NDArray._read_slice()andNDArray.get_1d_span_numpy()use them, and so do two Introduce Menudet 0.1 draft for portable array and table computation #741 paths: graph executions with native span readers (NativeGraphSchedule._execute_sources_bound(), whose reader callbacks decompress with C-Blosc2) andMenudetResidentPrefilter.evaluate()(ablosc2_compress_ctx()loop on a multi-thread context). For those two, the C call moved into a smallnoexcept nogilhelper (_graph_execute_sources(),MenudetResidentPrefilter._schedule()) so that it can run with or without the GIL; the code itself is unchanged._nogil_reads/_wait_for_nogil_reads()became_nogil_calls/_wait_for_nogil_calls(), sodestroy(),register_codec()andregister_filter()now wait for all of these calls and keep new ones from releasing the GIL meanwhile.SChunk(mode="r"),open(), also with mmap) andSChunk.lock(). They run no Python codec or filter and use neither the global context nor a thread pool, and most of them can wait for another thread's frame lock, soregister_codec()should not wait for them. One gap stays: withBLOSC_NTHREADS> 1, an open decompresses its offsets index with a pool, so an open in a daemon thread at the exact moment of exit could still meet a pool being torn down.evaluate_sourceswith Python storage readers, which count their own C-Blosc2 calls). They use no C-Blosc2 and nothingblosc2_destroy()frees. Thewith nogilblocks ingroupby_ext.pyxandindexing_ext.pyxcall no C-Blosc2 function either.Tests (
tests/test_nogil_calls.py, both in a child interpreter so that Python codecs registered by other tests do not keep the GIL, skipped on WASM):test_calls_without_the_gil_are_counted: compress, compress2, decompress2, vlcompress, append_data (_store_buffer()), to_cframe, an NDArray slice,get_1d_span_numpy(), and, where the build and NumPy support them, a graph execution with native span readers and a resident Menudet prefilter, each looped in a thread, must show up in the counter, and the counter must be back to 0 afterwards. On main only the slice is counted.test_exit_waits_for_daemon_threads_in_calls_without_the_gil: the script above, 3 runs, each must exit with 0. It fails on main.Perf: no slowdown. Small calls through the changed paths (4 KiB compress/compress2/decompress2/append_data and 1024-item
get_1d_span_numpy()spans, releasegil off and on, from 1 and from 4 Python threads), wheels built with GCC 14 like the PyPI ones, best of 7 per process, median over 5 interleaved rounds on an EPYC node, main built twice as a noise floor: every case within 0.96-1.005 of main, the second main build within 0.974-1.011.Merge order: this conflicts in one line with #769 (
pycodec-chunk-header, which changes_decompress_ctx()'s condition). If that one goes in first, the resolution isif _nogil_enter(RELEASEGIL and not _chunk_runs_python(<const uint8_t*>src, srcsize)):(checked on 126fa8f: the merge builds and passes the tests of both; on 5ff9823 the conflict is the same line).Not changed:
register_codec()/register_filter()still wait without a timeout, as they already did for slice reads. With this PR they also wait for releasegil writes andto_cframe(), so on a frame opened withlocking=Truea thread that holds the frame lock (e.g. insideholding_lock()) and registers a codec would wait forever for another thread whose counted call waits for that lock. That needs two handles of the same file in one process; a timeout there would be a separate change. Also not changed:get_1d_span_numpy()and the native span readers from #741 release the GIL even when a chunk uses a codec or filter registered from Python (get_1d_span_numpy()then segfaults; a slice read of the same chunk keeps the GIL and works). Checking_can_release_gil()there, as slice reads do, would fix that; I can send it separately.I used Claude Code (Anthropic) to reproduce the crash, write the change and the tests, and run the benchmarks; I reviewed the result.
Best,
Fabian
🤖 Generated with Claude Code
https://claude.ai/code/session_01DiPFztdpnKMJkzrjT36BuP