Skip to content

Wait for every C call that runs without the GIL before blosc2_destroy() at exit - #772

Open
FabianIsensee wants to merge 1 commit into
Blosc:mainfrom
FabianIsensee:releasegil-nogil-call-count
Open

FabianIsensee wants to merge 1 commit into
Blosc:mainfrom
FabianIsensee:releasegil-nogil-call-count

Conversation

@FabianIsensee

Copy link
Copy Markdown
Contributor

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 calls blosc2_destroy(), which frees the global context and the shared thread pools. The calls that set_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 inside blosc2.compress() at exit keeps compressing with the freed global context:

import threading, time
import numpy as np, blosc2

blosc2.set_releasegil(True)
blosc2.set_blocksize(4 * 2**20)  # big temporaries get mmap()ed, so the use after free crashes
data = (np.arange(4 * 2**20) % 1000).astype(np.int32)

def compress():
    while True:
        blosc2.compress(data, typesize=4, codec=blosc2.Codec.ZSTD, clevel=3)

for _ in range(2):
    threading.Thread(target=compress, daemon=True).start()
time.sleep(0.2)

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 in blosc_c()/blosc2_compress(), freed by blosc2_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 by SChunk._prefilter_data() now), _decompress_ctx(), _store_buffer(), vlcompress(), schunk_to_cframe(), NDArray._read_slice() and NDArray.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) and MenudetResidentPrefilter.evaluate() (a blosc2_compress_ctx() loop on a multi-thread context). For those two, the C call moved into a small noexcept nogil helper (_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(), so destroy(), register_codec() and register_filter() now wait for all of these calls and keep new ones from releasing the GIL meanwhile.
  • Not counted, on purpose (each site says so): opening a frame (SChunk(mode="r"), open(), also with mmap) and SChunk.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, so register_codec() should not wait for them. One gap stays: with BLOSC_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.
  • Also not counted: the miniexpr-only calls that Introduce Menudet 0.1 draft for portable array and table computation #741 runs without the GIL (graph prepare/specialize/execute on NumPy buffers including its parallel tasks, artifact load/eval, evaluate_sources with Python storage readers, which count their own C-Blosc2 calls). They use no C-Blosc2 and nothing blosc2_destroy() frees. The with nogil blocks in groupby_ext.pyx and indexing_ext.pyx call 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.
  • Full suite: 10999 passed vs 10997 on main, with a check after every test that the counter is back to 0; the same 2 failures as on main (b2view http tests that need aiohttp, not installed here).

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 is if _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 and to_cframe(), so on a frame opened with locking=True a thread that holds the frame lock (e.g. inside holding_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

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 branch has not been deployed

No deployments
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.

1 participant