Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions Documentation/docs/migration_guides/itk_6_migration_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
--------------------------------
Expand Down
8 changes: 8 additions & 0 deletions Modules/Core/Common/include/itkPyImageFilter.hxx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ PyImageFilter<TInputImage, TOutputImage>::GenerateOutputInformation()
{
Superclass::GenerateOutputInformation();

SWIG_PYTHON_THREAD_BEGIN_BLOCK;
// make sure that the CommandCallable is in fact callable
if (PyCallable_Check(this->m_GenerateOutputInformationCallable))
{
Expand All @@ -168,6 +169,7 @@ PyImageFilter<TInputImage, TOutputImage>::GenerateOutputInformation()
itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
}
SWIG_PYTHON_THREAD_END_BLOCK;
}


Expand All @@ -177,6 +179,7 @@ PyImageFilter<TInputImage, TOutputImage>::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))
{
Expand All @@ -200,6 +203,7 @@ PyImageFilter<TInputImage, TOutputImage>::EnlargeOutputRequestedRegion(DataObjec
itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
}
SWIG_PYTHON_THREAD_END_BLOCK;
}


Expand All @@ -209,6 +213,7 @@ PyImageFilter<TInputImage, TOutputImage>::GenerateInputRequestedRegion()
{
Superclass::GenerateInputRequestedRegion();

SWIG_PYTHON_THREAD_BEGIN_BLOCK;
// make sure that the CommandCallable is in fact callable
if (PyCallable_Check(this->m_GenerateInputRequestedRegionCallable))
{
Expand All @@ -229,12 +234,14 @@ PyImageFilter<TInputImage, TOutputImage>::GenerateInputRequestedRegion()
itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
}
SWIG_PYTHON_THREAD_END_BLOCK;
}

template <class TInputImage, class TOutputImage>
void
PyImageFilter<TInputImage, TOutputImage>::GenerateData()
{
SWIG_PYTHON_THREAD_BEGIN_BLOCK;
// make sure that the CommandCallable is in fact callable
if (!PyCallable_Check(this->m_GenerateDataCallable))
{
Expand Down Expand Up @@ -262,6 +269,7 @@ PyImageFilter<TInputImage, TOutputImage>::GenerateData()
itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
}
SWIG_PYTHON_THREAD_END_BLOCK;
}

} // namespace itk
Expand Down
5 changes: 3 additions & 2 deletions Modules/Core/Common/src/itkPyCommand.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand All @@ -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)
{
Expand Down
13 changes: 7 additions & 6 deletions Wrapping/Generators/Python/Tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions Wrapping/Generators/Python/Tests/PyImageFilterTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@
import numpy as np
import functools

input_image = itk.image_from_array(
np.random.randint(0, 255, size=(6, 6), dtype=np.uint8)
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()


def constant_output(py_image_filter, constant=42):
Expand Down Expand Up @@ -107,3 +112,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")
143 changes: 35 additions & 108 deletions Wrapping/Generators/Python/Tests/test_gil_release.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 18 additions & 7 deletions Wrapping/Generators/Python/Tests/test_gil_release_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,24 +221,25 @@ 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 = []


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")
Expand Down
Loading
Loading