Skip to content

Commit 5395848

Browse files
committed
Fix memory leak in optional_import traceback handling (#7480, #7727)
When an import fails, `optional_import` captured the live traceback object and stored it in a `_LazyRaise` closure. The traceback held references to stack frames containing CUDA tensors, preventing garbage collection and causing GPU memory leaks. Replace the live traceback with a string-formatted copy via `traceback.format_exception()` and clear the original with `import_exception.__traceback__ = None`. The formatted traceback is appended to the error message so debugging information is preserved. Also move three hot-path `optional_import` calls in `monai/transforms/utils.py` (cucim.skimage, cucim morphology EDT) from function bodies to module level, eliminating repeated leaked tracebacks on every invocation. Fixes #7480, fixes #7727 Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk>
1 parent daaedaa commit 5395848

3 files changed

Lines changed: 54 additions & 19 deletions

File tree

monai/transforms/utils.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@
8585
cp, has_cp = optional_import("cupy")
8686
cp_ndarray, _ = optional_import("cupy", name="ndarray")
8787
exposure, has_skimage = optional_import("skimage.exposure")
88+
_cucim_skimage, _has_cucim_skimage = optional_import("cucim.skimage")
89+
_cucim_morphology_edt, _has_cucim_morphology = optional_import(
90+
"cucim.core.operations.morphology", name="distance_transform_edt"
91+
)
8892

8993
__all__ = [
9094
"allow_missing_keys_mode",
@@ -1147,11 +1151,10 @@ def get_largest_connected_component_mask(
11471151
"""
11481152
# use skimage/cucim.skimage and np/cp depending on whether packages are
11491153
# available and input is non-cpu torch.tensor
1150-
skimage, has_cucim = optional_import("cucim.skimage")
1151-
use_cp = has_cp and has_cucim and isinstance(img, torch.Tensor) and img.device != torch.device("cpu")
1154+
use_cp = has_cp and _has_cucim_skimage and isinstance(img, torch.Tensor) and img.device != torch.device("cpu")
11521155
if use_cp:
11531156
img_ = convert_to_cupy(img.short()) # type: ignore
1154-
label = skimage.measure.label
1157+
label = _cucim_skimage.measure.label
11551158
lib = cp
11561159
else:
11571160
if not has_measure:
@@ -1204,13 +1207,13 @@ def keep_merge_components_with_points(
12041207
margins: include points outside of the region but within the margin.
12051208
"""
12061209

1207-
cucim_skimage, has_cucim = optional_import("cucim.skimage")
1208-
1209-
use_cp = has_cp and has_cucim and isinstance(img_pos, torch.Tensor) and img_pos.device != torch.device("cpu")
1210+
use_cp = (
1211+
has_cp and _has_cucim_skimage and isinstance(img_pos, torch.Tensor) and img_pos.device != torch.device("cpu")
1212+
)
12101213
if use_cp:
12111214
img_pos_ = convert_to_cupy(img_pos.short()) # type: ignore
12121215
img_neg_ = convert_to_cupy(img_neg.short()) # type: ignore
1213-
label = cucim_skimage.measure.label
1216+
label = _cucim_skimage.measure.label
12141217
lib = cp
12151218
else:
12161219
if not has_measure:
@@ -2463,10 +2466,7 @@ def distance_transform_edt(
24632466
Returned only when `return_indices` is True and `indices` is not supplied. dtype np.float64.
24642467
24652468
"""
2466-
distance_transform_edt, has_cucim = optional_import(
2467-
"cucim.core.operations.morphology", name="distance_transform_edt"
2468-
)
2469-
use_cp = has_cp and has_cucim and isinstance(img, torch.Tensor) and img.device.type == "cuda"
2469+
use_cp = has_cp and _has_cucim_morphology and isinstance(img, torch.Tensor) and img.device.type == "cuda"
24702470
if not return_distances and not return_indices:
24712471
raise RuntimeError("Neither return_distances nor return_indices True")
24722472

@@ -2499,7 +2499,7 @@ def distance_transform_edt(
24992499
indices_ = convert_to_cupy(indices)
25002500
img_ = convert_to_cupy(img)
25012501
for channel_idx in range(img_.shape[0]):
2502-
distance_transform_edt(
2502+
_cucim_morphology_edt(
25032503
img_[channel_idx],
25042504
sampling=sampling,
25052505
return_distances=return_distances,

monai/utils/module.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import pdb
1818
import re
1919
import sys
20+
import traceback as traceback_mod
2021
import warnings
2122
from collections.abc import Callable, Collection, Hashable, Iterable, Mapping
2223
from functools import partial, wraps
@@ -368,8 +369,9 @@ def optional_import(
368369
OptionalImportError: from torch.nn.functional import conv1d (requires version '42' by 'min_version').
369370
"""
370371

371-
tb = None
372+
had_exception = False
372373
exception_str = ""
374+
tb_str = ""
373375
if name:
374376
actual_cmd = f"from {module} import {name}"
375377
else:
@@ -384,8 +386,12 @@ def optional_import(
384386
if name: # user specified to load class/function/... from the module
385387
the_module = getattr(the_module, name)
386388
except Exception as import_exception: # any exceptions during import
387-
tb = import_exception.__traceback__
389+
tb_str = "".join(
390+
traceback_mod.format_exception(type(import_exception), import_exception, import_exception.__traceback__)
391+
)
392+
import_exception.__traceback__ = None
388393
exception_str = f"{import_exception}"
394+
had_exception = True
389395
else: # found the module
390396
if version_args and version_checker(pkg, f"{version}", version_args):
391397
return the_module, True
@@ -394,7 +400,7 @@ def optional_import(
394400

395401
# preparing lazy error message
396402
msg = descriptor.format(actual_cmd)
397-
if version and tb is None: # a pure version issue
403+
if version and not had_exception: # a pure version issue
398404
msg += f" (requires '{module} {version}' by '{version_checker.__name__}')"
399405
if exception_str:
400406
msg += f" ({exception_str})"
@@ -407,10 +413,9 @@ def __init__(self, *_args, **_kwargs):
407413
+ "\n\nFor details about installing the optional dependencies, please visit:"
408414
+ "\n https://monai.readthedocs.io/en/latest/installation.html#installing-the-recommended-dependencies"
409415
)
410-
if tb is None:
411-
self._exception = OptionalImportError(_default_msg)
412-
else:
413-
self._exception = OptionalImportError(_default_msg).with_traceback(tb)
416+
if tb_str:
417+
_default_msg += f"\n\nOriginal traceback:\n{tb_str}"
418+
self._exception = OptionalImportError(_default_msg)
414419

415420
def __getattr__(self, name):
416421
"""

tests/utils/test_optional_import.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
from __future__ import annotations
1313

14+
import gc
1415
import unittest
16+
import weakref
1517

1618
from parameterized import parameterized
1719

@@ -75,6 +77,34 @@ def versioning(module, ver, a):
7577
nn, flag = optional_import("torch", "1.1", version_checker=versioning, name="nn", version_args=test_args)
7678
self.assertTrue(flag)
7779

80+
def test_no_traceback_leak(self):
81+
"""Verify optional_import does not retain references to stack frames (issue #7480)."""
82+
83+
class _Marker:
84+
pass
85+
86+
def _do_import():
87+
marker = _Marker()
88+
ref = weakref.ref(marker)
89+
# Call optional_import for a module that does not exist.
90+
# If the traceback is leaked, `marker` stays alive via frame references.
91+
mod, flag = optional_import("nonexistent_module_for_leak_test")
92+
self.assertFalse(flag)
93+
return ref
94+
95+
ref = _do_import()
96+
gc.collect()
97+
self.assertIsNone(ref(), "optional_import is leaking frame references via traceback")
98+
99+
def test_failed_import_shows_traceback_string(self):
100+
"""Verify the error message includes the original traceback as a string."""
101+
mod, flag = optional_import("nonexistent_module_for_tb_test")
102+
self.assertFalse(flag)
103+
with self.assertRaises(OptionalImportError) as ctx:
104+
mod.something
105+
self.assertIn("Original traceback", str(ctx.exception))
106+
self.assertIn("ModuleNotFoundError", str(ctx.exception))
107+
78108

79109
if __name__ == "__main__":
80110
unittest.main()

0 commit comments

Comments
 (0)