From 0f6c5a10a70e96a144f13e48df2e3f62c18b3a77 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sat, 24 Oct 2026 00:01:34 +0100 Subject: [PATCH 1/3] BUG: Release the Python GIL during pipeline updates The global SWIG nothreadallow feature overrides threading support. Limit GIL release to pipeline updates and reacquire it for callbacks. Replace setup-time overlap detection with a native execution check. Fixes #7000 --- .../migration_guides/itk_6_migration_guide.md | 19 ++- .../Core/Common/include/itkPyImageFilter.hxx | 8 + .../Generators/Python/Tests/CMakeLists.txt | 13 +- .../Python/Tests/PyImageFilterTest.py | 24 ++- .../Python/Tests/test_gil_release.py | 143 +++++------------- .../Python/Tests/test_gil_release_safety.py | 15 +- .../Generators/SwigInterface/igenerator.py | 6 +- 7 files changed, 92 insertions(+), 136 deletions(-) diff --git a/Documentation/docs/migration_guides/itk_6_migration_guide.md b/Documentation/docs/migration_guides/itk_6_migration_guide.md index d14721ccdb6..5fca0a79bf4 100644 --- a/Documentation/docs/migration_guides/itk_6_migration_guide.md +++ b/Documentation/docs/migration_guides/itk_6_migration_guide.md @@ -367,16 +367,15 @@ These names were deprecated in CMake 3.20 and removed in CMake 4.1.0. Additional Python Global Interpreter Lock (GIL) Release --------------------------------------------- -ITK now releases the Python Global Interpreter Lock (GIL) during C++ operations by default, -allowing for true multi-threaded execution of ITK operations from Python. This enables -parallel filter invocation when using ITK in parallel computing frameworks like Dask, Ray, or -Python's standard `threading` module. +ITK releases the Python Global Interpreter Lock (GIL) during `Update()` and +`UpdateLargestPossibleRegion()` by default. Other wrapped methods retain the GIL. +This allows independent pipelines to execute concurrently from Python threads. ### Key Changes **New CMake Option:** -- `ITK_PYTHON_RELEASE_GIL` (default: `ON`) - Controls whether the GIL is released during ITK operations -- When enabled, the `-threads` flag is passed to SWIG to generate thread-safe wrappers +- `ITK_PYTHON_RELEASE_GIL` (default: `ON`) - Controls GIL release during pipeline execution +- When enabled, SWIG's `-threads` flag supports GIL release in the update wrappers **Benefits:** - Multiple Python threads can execute ITK operations concurrently @@ -392,7 +391,7 @@ import threading image_paths = ["image1.mha", "image2.mha"] def process_image(image_path): - # GIL is released during ITK operations + # Pipeline updates release the GIL. image = itk.imread(image_path) smoothed = itk.median_image_filter(image, radius=5) return smoothed @@ -408,9 +407,9 @@ for thread in threads: thread.join() ``` -**Note:** ITK callbacks and event monitoring may be affected by GIL release. If you encounter -issues with callbacks, you can disable GIL release by setting `-DITK_PYTHON_RELEASE_GIL=OFF` -when building ITK. +**Note:** Python callbacks reacquire the GIL. Concurrent threads must use independent +pipelines; GIL release does not make shared filters or images thread-safe. +Build with `-DITK_PYTHON_RELEASE_GIL=OFF` to retain the GIL during updates. Python lazy loading is always on -------------------------------- diff --git a/Modules/Core/Common/include/itkPyImageFilter.hxx b/Modules/Core/Common/include/itkPyImageFilter.hxx index f10a75a85f1..d2fa7cc5207 100644 --- a/Modules/Core/Common/include/itkPyImageFilter.hxx +++ b/Modules/Core/Common/include/itkPyImageFilter.hxx @@ -148,6 +148,7 @@ PyImageFilter::GenerateOutputInformation() { Superclass::GenerateOutputInformation(); + SWIG_PYTHON_THREAD_BEGIN_BLOCK; // make sure that the CommandCallable is in fact callable if (PyCallable_Check(this->m_GenerateOutputInformationCallable)) { @@ -168,6 +169,7 @@ PyImageFilter::GenerateOutputInformation() itkExceptionStringMacro("There was an error executing the CommandCallable."); } } + SWIG_PYTHON_THREAD_END_BLOCK; } @@ -177,6 +179,7 @@ PyImageFilter::EnlargeOutputRequestedRegion(DataObjec { Superclass::EnlargeOutputRequestedRegion(data); + SWIG_PYTHON_THREAD_BEGIN_BLOCK; // make sure that the CommandCallable is in fact callable if (PyCallable_Check(this->m_EnlargeOutputRequestedRegionCallable)) { @@ -200,6 +203,7 @@ PyImageFilter::EnlargeOutputRequestedRegion(DataObjec itkExceptionStringMacro("There was an error executing the CommandCallable."); } } + SWIG_PYTHON_THREAD_END_BLOCK; } @@ -209,6 +213,7 @@ PyImageFilter::GenerateInputRequestedRegion() { Superclass::GenerateInputRequestedRegion(); + SWIG_PYTHON_THREAD_BEGIN_BLOCK; // make sure that the CommandCallable is in fact callable if (PyCallable_Check(this->m_GenerateInputRequestedRegionCallable)) { @@ -229,12 +234,14 @@ PyImageFilter::GenerateInputRequestedRegion() itkExceptionStringMacro("There was an error executing the CommandCallable."); } } + SWIG_PYTHON_THREAD_END_BLOCK; } template void PyImageFilter::GenerateData() { + SWIG_PYTHON_THREAD_BEGIN_BLOCK; // make sure that the CommandCallable is in fact callable if (!PyCallable_Check(this->m_GenerateDataCallable)) { @@ -262,6 +269,7 @@ PyImageFilter::GenerateData() itkExceptionStringMacro("There was an error executing the CommandCallable."); } } + SWIG_PYTHON_THREAD_END_BLOCK; } } // namespace itk diff --git a/Wrapping/Generators/Python/Tests/CMakeLists.txt b/Wrapping/Generators/Python/Tests/CMakeLists.txt index c17427e455d..49548ec9bec 100644 --- a/Wrapping/Generators/Python/Tests/CMakeLists.txt +++ b/Wrapping/Generators/Python/Tests/CMakeLists.txt @@ -281,12 +281,13 @@ itk_python_add_test( 5 ) -# Test GIL release during ITK operations -itk_python_add_test( - NAME PythonGILReleaseTest - COMMAND - ${CMAKE_CURRENT_SOURCE_DIR}/test_gil_release.py -) +if(ITK_PYTHON_RELEASE_GIL) + itk_python_add_test( + NAME PythonGILReleaseTest + COMMAND + ${CMAKE_CURRENT_SOURCE_DIR}/test_gil_release.py + ) +endif() # Comprehensive GIL release safety tests: callbacks, exceptions, # concurrency, deadlock, signals, stress diff --git a/Wrapping/Generators/Python/Tests/PyImageFilterTest.py b/Wrapping/Generators/Python/Tests/PyImageFilterTest.py index 884d7ff0df8..ddf860d787a 100644 --- a/Wrapping/Generators/Python/Tests/PyImageFilterTest.py +++ b/Wrapping/Generators/Python/Tests/PyImageFilterTest.py @@ -20,9 +20,9 @@ import numpy as np import functools -input_image = itk.image_from_array( - np.random.randint(0, 255, size=(6, 6), dtype=np.uint8) -) +input_image = itk.Image[itk.F, 2].New() +input_image.SetRegions([6, 6]) +input_image.AllocateInitialized() def constant_output(py_image_filter, constant=42): @@ -107,3 +107,21 @@ def enlarge_output_requested_region(py_image_filter, data): assert region.GetIndex()[1] == 0 assert region.GetSize()[0] == 6 assert region.GetSize()[1] == 6 + +py_filter.Modified() +median = itk.MedianImageFilter.New(Input=py_filter, Radius=1) +median.Update() +assert np.all(np.asarray(median.GetOutput()) == 42) + + +def failing_generate_data(py_image_filter): + raise ValueError("callback failure") + + +py_filter.SetPyGenerateData(failing_generate_data) +try: + median.Update() +except RuntimeError: + pass +else: + raise AssertionError("Expected the Python callback failure to propagate") diff --git a/Wrapping/Generators/Python/Tests/test_gil_release.py b/Wrapping/Generators/Python/Tests/test_gil_release.py index 3aab977e88f..7478e1c782a 100644 --- a/Wrapping/Generators/Python/Tests/test_gil_release.py +++ b/Wrapping/Generators/Python/Tests/test_gil_release.py @@ -1,119 +1,46 @@ -""" -Test that the Python Global Interpreter Lock (GIL) is released during ITK operations. - -This test verifies that when ITK_PYTHON_RELEASE_GIL is enabled, multiple Python threads -can execute ITK operations concurrently. -""" - import sys import threading -import time -# Threshold for determining if parallel execution is significantly faster than sequential -# With 4 outer threads and 1 ITK thread per filter, we expect at least 2x speedup -# A value of 0.5 means parallel execution should be at most 50% of sequential time -# This accounts for threading overhead and ensures GIL is being released -PARALLEL_SPEEDUP_THRESHOLD = 0.5 +import itk def test_gil_release(): - """Test that GIL is released during ITK operations.""" - try: - import itk - except ImportError: - print("ITK not available, skipping GIL release test") - sys.exit(0) - - # Create a simple test image image_type = itk.Image[itk.F, 2] - size = [100, 100] - - # Shared counter to track concurrent execution - execution_times = [] - lock = threading.Lock() - - def run_filter(): - """Run an ITK filter operation that should release the GIL.""" - # Create an image - image = itk.Image[itk.F, 2].New() - region = itk.ImageRegion[2]() - region.SetSize(size) - image.SetRegions(region) - image.Allocate() - image.FillBuffer(1.0) - - start_time = time.time() - - # Run a computationally intensive filter - # MedianImageFilter is a good test as it performs actual computation - median_filter = itk.MedianImageFilter[image_type, image_type].New() - median_filter.SetInput(image) - median_filter.SetRadius(5) - # Limit ITK internal threads to 1 to make the test more reliable - median_filter.SetNumberOfWorkUnits(1) - median_filter.Update() - - end_time = time.time() - - with lock: - execution_times.append((start_time, end_time)) - - # Run multiple threads - num_threads = 4 - threads = [] - - overall_start = time.time() - - for _ in range(num_threads): - thread = threading.Thread(target=run_filter) + image = image_type.New() + image.SetRegions([512, 512]) + image.Allocate() + image.FillBuffer(1.0) + median = itk.MedianImageFilter[image_type, image_type].New(Input=image, Radius=5) + median.SetNumberOfWorkUnits(1) + + for operation in (median.Update, median.UpdateLargestPossibleRegion): + median.Modified() + ready = threading.Event() + start = threading.Event() + progressed = threading.Event() + + def worker(): + ready.set() + start.wait() + progressed.set() + + thread = threading.Thread(target=worker) thread.start() - threads.append(thread) - - for thread in threads: - thread.join() - - overall_end = time.time() - - # If GIL is properly released, the threads should have overlapping execution times - # and the total time should be less than the sum of individual execution times - - total_sequential_time = sum(end - start for start, end in execution_times) - total_parallel_time = overall_end - overall_start - - print(f"Total sequential time if run serially: {total_sequential_time:.3f}s") - print(f"Total parallel time: {total_parallel_time:.3f}s") - - # Check for overlap in execution times - has_overlap = False - if len(execution_times) >= 2: - for i in range(len(execution_times)): - for j in range(i + 1, len(execution_times)): - start1, end1 = execution_times[i] - start2, end2 = execution_times[j] - # Check if there's any overlap - if (start1 <= start2 < end1) or (start2 <= start1 < end2): - has_overlap = True - break - if has_overlap: - break - - if has_overlap: - print("SUCCESS: Thread execution times overlap - GIL appears to be released") - return 0 - else: - # Even without overlap, if parallel time is significantly less than sequential, - # it suggests concurrent execution - if total_parallel_time < total_sequential_time * PARALLEL_SPEEDUP_THRESHOLD: - print("SUCCESS: Parallel execution is faster - GIL appears to be released") - return 0 - else: - print("FAILURE: No clear evidence of concurrent execution") - print("This indicates that GIL is not being released properly") - print( - f"Expected parallel time < {total_sequential_time * PARALLEL_SPEEDUP_THRESHOLD:.3f}s, got {total_parallel_time:.3f}s" - ) - return 1 + ready.wait() + switch_interval = sys.getswitchinterval() + try: + # Prevent Python bytecode scheduling from masquerading as native GIL release. + sys.setswitchinterval(100) + start.set() + operation() + released = progressed.is_set() + finally: + sys.setswitchinterval(switch_interval) + start.set() + thread.join() + + assert released, f"{operation.__name__} did not release the GIL" if __name__ == "__main__": - sys.exit(test_gil_release()) + test_gil_release() diff --git a/Wrapping/Generators/Python/Tests/test_gil_release_safety.py b/Wrapping/Generators/Python/Tests/test_gil_release_safety.py index be51d59273c..411e74db938 100644 --- a/Wrapping/Generators/Python/Tests/test_gil_release_safety.py +++ b/Wrapping/Generators/Python/Tests/test_gil_release_safety.py @@ -211,16 +211,17 @@ def run_filter_with_callback(): print("Test 3: Exception propagation across GIL boundary") print("=" * 60) -# itk.imread on a non-existent file throws RuntimeError through the -# SWIG wrapper. With -threads, the GIL is released before the C++ call -# and must be re-acquired before setting the Python exception. +image_type = itk.Image[itk.F, 2] +median_type = itk.MedianImageFilter[image_type, image_type] + +# A missing input forces Update() to throw across the native GIL-release boundary. try: - itk.imread("/nonexistent/path/that/does/not/exist.nii.gz") - check("exception from imread", False, "should have raised") + median_type.New().Update() + check("exception from Update", False, "should have raised") except RuntimeError: check("C++ RuntimeError propagated correctly", True) except Exception as e: - check("exception propagation", True, f"got {type(e).__name__}") + check("exception propagation", False, f"got {type(e).__name__}") # Exception from a threaded context exception_results = [] @@ -228,7 +229,7 @@ def run_filter_with_callback(): def threaded_exception(): try: - itk.imread("/nonexistent/threaded.nii.gz") + median_type.New().Update() exception_results.append("no_exception") except RuntimeError: exception_results.append("ok") diff --git a/Wrapping/Generators/SwigInterface/igenerator.py b/Wrapping/Generators/SwigInterface/igenerator.py index 61d8c075656..a859b7d0791 100755 --- a/Wrapping/Generators/SwigInterface/igenerator.py +++ b/Wrapping/Generators/SwigInterface/igenerator.py @@ -1561,11 +1561,13 @@ def generate_headerfile(self, idx_file, wrappers_namespace): """ ) - # Also, release the GIL header_file.write( - f'%module(package="itk",threads="1") {self.submodule_name}{lang.title()}\n' + f'%module(package="itk") {self.submodule_name}{lang.title()}\n' ) + # Python-facing helpers require the GIL; only pipeline execution releases it. header_file.write('%feature("nothreadallow");\n') + header_file.write("%threadallow Update;\n") + header_file.write("%threadallow UpdateLargestPossibleRegion;\n") header_file.write('%feature("autodoc","2");\n') else: header_file.write(f"%module {self.submodule_name}{lang.title()}\n") From 2d8272e742cb50df603a6638f01459bf06b1e4d0 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sat, 24 Oct 2026 10:08:55 +0100 Subject: [PATCH 2/3] BUG: Select an enabled pixel type for Python filter tests Custom wrapping builds may disable float while retaining other scalar types. Exercise an available two-dimensional specialization instead. --- Wrapping/Generators/Python/Tests/PyImageFilterTest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Wrapping/Generators/Python/Tests/PyImageFilterTest.py b/Wrapping/Generators/Python/Tests/PyImageFilterTest.py index ddf860d787a..5a8518ae448 100644 --- a/Wrapping/Generators/Python/Tests/PyImageFilterTest.py +++ b/Wrapping/Generators/Python/Tests/PyImageFilterTest.py @@ -20,7 +20,12 @@ import numpy as np import functools -input_image = itk.Image[itk.F, 2].New() +image_type = next( + input_type + for input_type, output_type in itk.PyImageFilter.keys() + if input_type == output_type and input_type.GetImageDimension() == 2 +) +input_image = image_type.New() input_image.SetRegions([6, 6]) input_image.AllocateInitialized() From d0a0902962067094492eaf2662e62809e98be7a3 Mon Sep 17 00:00:00 2001 From: Samuel Le Meur-Diebolt Date: Sun, 25 Oct 2026 01:19:11 +0100 Subject: [PATCH 3/3] BUG: Acquire the GIL before validating observer callables Observers can execute while pipeline updates have released the GIL. Protect callable validation as well as invocation and error handling. --- Modules/Core/Common/src/itkPyCommand.cxx | 5 +++-- .../Generators/Python/Tests/test_gil_release_safety.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Modules/Core/Common/src/itkPyCommand.cxx b/Modules/Core/Common/src/itkPyCommand.cxx index dd398eeb17d..ec61726c456 100644 --- a/Modules/Core/Common/src/itkPyCommand.cxx +++ b/Modules/Core/Common/src/itkPyCommand.cxx @@ -115,6 +115,8 @@ PyCommand::Execute(const Object *, const EventObject &) void PyCommand::PyExecute() { + const PyGILStateEnsure gil; + // make sure that the CommandCallable is in fact callable if (!PyCallable_Check(this->m_Object)) { @@ -125,8 +127,7 @@ PyCommand::PyExecute() } else { - const PyGILStateEnsure gil; - PyObject * result = PyObject_Call(this->m_Object, this->m_EmptyArgumentList, (PyObject *)nullptr); + PyObject * result = PyObject_Call(this->m_Object, this->m_EmptyArgumentList, (PyObject *)nullptr); if (result) { diff --git a/Wrapping/Generators/Python/Tests/test_gil_release_safety.py b/Wrapping/Generators/Python/Tests/test_gil_release_safety.py index 411e74db938..da46b166687 100644 --- a/Wrapping/Generators/Python/Tests/test_gil_release_safety.py +++ b/Wrapping/Generators/Python/Tests/test_gil_release_safety.py @@ -146,6 +146,16 @@ def progress_callback(): check("no callback errors", len(callback_errors) == 0, f"errors: {callback_errors}") del median, image +image = make_image() +median = itk.MedianImageFilter.New(Input=image, Radius=2) +median.AddObserver(itk.StartEvent(), itk.PyCommand.New()) +try: + median.Update() + check("unset observer callable rejected", False, "should have raised") +except RuntimeError as error: + check("unset observer callable rejected", "CommandCallable" in str(error)) +del median, image + # ================================================================== # Test 2: Concurrent callbacks from threaded filters # Ref: https://docs.python.org/3/c-api/init.html#non-python-created-threads