From 6d28a5c65abf2ffca66fe53015ae1a486db75483 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:08:50 +0000 Subject: [PATCH 1/3] Fix RecursionError in set_rnd on cyclic object graphs (#8087) monai.data.utils.set_rnd recursively walks obj.__dict__ to seed randomizable components. When a dataset holds an OmegaConf/Hydra config (whose child nodes back-reference their parent), or any object graph with a reference cycle, the recursion never terminates and raises RecursionError while building a DataLoader with num_workers=0. Track visited object ids in an internal _seen set and skip already-visited objects, breaking the cycle while still seeding every reachable randomizable component exactly once. Signed-off-by: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> --- monai/data/utils.py | 14 +++++++++++--- tests/data/test_dataloader.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 64bd79c7128..4cf9b244cd7 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -686,28 +686,36 @@ def worker_init_fn(worker_id: int) -> None: set_rnd(worker_info.dataset, seed=worker_info.seed) # type: ignore[union-attr] -def set_rnd(obj, seed: int) -> int: +def set_rnd(obj, seed: int, _seen: set[int] | None = None) -> int: """ Set seed or random state for all randomizable properties of obj. Args: obj: object to set seed or random state for. seed: set the random state with an integer seed. + _seen: internal set of already-visited object ids, used to guard against + infinite recursion on cyclic object graphs (e.g. OmegaConf/Hydra + configs whose child nodes back-reference their parent, see issue #8087). """ + if _seen is None: + _seen = set() if isinstance(obj, (tuple, list)): # ZipDataset.data is a list _seed = seed for item in obj: - _seed = set_rnd(item, seed=seed) + _seed = set_rnd(item, seed=seed, _seen=_seen) return seed if _seed == seed else seed + 1 # return a different seed if there are randomizable items if not hasattr(obj, "__dict__"): return seed # no attribute + if id(obj) in _seen: + return seed # already visited: avoid infinite recursion on cyclic references + _seen.add(id(obj)) if hasattr(obj, "set_random_state"): obj.set_random_state(seed=seed % MAX_SEED) return seed + 1 # a different seed for the next component for key in obj.__dict__: if key.startswith("__"): # skip the private methods continue - seed = set_rnd(obj.__dict__[key], seed=seed) + seed = set_rnd(obj.__dict__[key], seed=seed, _seen=_seen) return seed diff --git a/tests/data/test_dataloader.py b/tests/data/test_dataloader.py index 32e624a8603..b22b799bfd1 100644 --- a/tests/data/test_dataloader.py +++ b/tests/data/test_dataloader.py @@ -12,6 +12,7 @@ from __future__ import annotations import sys +import types import unittest import numpy as np @@ -99,5 +100,36 @@ def test_zipdataset(self): assert_allclose(np.stack(output).flatten()[:7], np.array([594, 170, 594, 170, 594, 170, 524])) +class _CyclicConfigDataset(torch.utils.data.Dataset): + """ + Dataset holding an attribute whose object graph contains a reference cycle. + + This mirrors OmegaConf/Hydra configs, whose child nodes hold a back-reference + to their parent node. Seeding such a dataset used to recurse forever in + ``monai.data.utils.set_rnd`` (see issue #8087). + """ + + def __init__(self): + parent = types.SimpleNamespace() + child = types.SimpleNamespace() + parent.child = child + child.parent = parent # reference cycle, as in an OmegaConf parent/child graph + self.cfg = parent + + def __len__(self): + return 4 + + def __getitem__(self, index): + return torch.tensor([index]) + + +class TestLoaderRecursion(unittest.TestCase): + def test_cyclic_reference_no_recursion(self): + # Constructing the loader seeds the dataset (num_workers=0). A reference cycle in the + # dataset's attributes must not raise RecursionError while walking the object graph. + dataloader = DataLoader(_CyclicConfigDataset(), batch_size=1, num_workers=0, shuffle=False) + self.assertEqual(len(list(dataloader)), 4) + + if __name__ == "__main__": unittest.main() From 58853d83ec29e61f8b09314bc82b4be4d3fa56ba Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 20 Aug 2026 02:26:58 +0000 Subject: [PATCH 2/3] Fix cyclic list handling in set_rnd (#9056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track container identities before recursion and preserve seed advancement across cyclic references. RED→GREEN: RecursionError and 42 != 43 → 9 focused tests passing. Full min-dependency suite: 9655 passed, 2492 skipped. --- monai/data/utils.py | 10 +++++--- tests/data/test_dataloader.py | 44 ++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 4cf9b244cd7..5f1f8f93882 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -700,10 +700,14 @@ def set_rnd(obj, seed: int, _seen: set[int] | None = None) -> int: if _seen is None: _seen = set() if isinstance(obj, (tuple, list)): # ZipDataset.data is a list - _seed = seed + if id(obj) in _seen: + return seed + _seen.add(id(obj)) + has_randomizable = False for item in obj: - _seed = set_rnd(item, seed=seed, _seen=_seen) - return seed if _seed == seed else seed + 1 # return a different seed if there are randomizable items + item_seed = set_rnd(item, seed=seed, _seen=_seen) + has_randomizable = has_randomizable or item_seed != seed + return seed + 1 if has_randomizable else seed if not hasattr(obj, "__dict__"): return seed # no attribute if id(obj) in _seen: diff --git a/tests/data/test_dataloader.py b/tests/data/test_dataloader.py index b22b799bfd1..ea1ba430349 100644 --- a/tests/data/test_dataloader.py +++ b/tests/data/test_dataloader.py @@ -20,6 +20,7 @@ from parameterized import parameterized from monai.data import CacheDataset, DataLoader, Dataset, ZipDataset +from monai.data.utils import set_rnd from monai.transforms import Compose, DataStatsd, Randomizable, SimulateDelayd from monai.utils import convert_to_numpy, set_determinism from tests.test_utils import assert_allclose @@ -28,6 +29,11 @@ TEST_CASE_2 = [[{"label": torch.as_tensor([[3], [2]])}, {"label": np.asarray([[1], [2]])}]] +_CYCLIC_DATASET_SIZE = 4 +_CYCLIC_BATCH_SIZE = 1 +_CYCLIC_NUM_WORKERS = 0 +_CYCLIC_TEST_SEED = 42 + class TestDataLoader(unittest.TestCase): def test_values(self): @@ -117,18 +123,50 @@ def __init__(self): self.cfg = parent def __len__(self): - return 4 + return _CYCLIC_DATASET_SIZE def __getitem__(self, index): return torch.tensor([index]) +class _SeedRecorder: + def __init__(self): + self.seed = None + + def set_random_state(self, seed): + self.seed = seed + + class TestLoaderRecursion(unittest.TestCase): def test_cyclic_reference_no_recursion(self): # Constructing the loader seeds the dataset (num_workers=0). A reference cycle in the # dataset's attributes must not raise RecursionError while walking the object graph. - dataloader = DataLoader(_CyclicConfigDataset(), batch_size=1, num_workers=0, shuffle=False) - self.assertEqual(len(list(dataloader)), 4) + dataloader = DataLoader( + _CyclicConfigDataset(), batch_size=_CYCLIC_BATCH_SIZE, num_workers=_CYCLIC_NUM_WORKERS, shuffle=False + ) + self.assertEqual(len(list(dataloader)), _CYCLIC_DATASET_SIZE) + + def test_cyclic_list_reference_no_recursion(self): + """Test seeding a dataset whose configuration list contains itself.""" + dataset = _CyclicConfigDataset() + dataset.cfg = [] + dataset.cfg.append(dataset.cfg) + dataloader = DataLoader(dataset, batch_size=_CYCLIC_BATCH_SIZE, num_workers=_CYCLIC_NUM_WORKERS, shuffle=False) + self.assertEqual(len(list(dataloader)), _CYCLIC_DATASET_SIZE) + + def test_cyclic_list_preserves_seed_advancement(self): + """Test a cyclic list does not erase seed advancement from an earlier item.""" + dataset = _CyclicConfigDataset() + nested_randomizable = _SeedRecorder() + following_randomizable = _SeedRecorder() + dataset.cfg = [nested_randomizable] + dataset.cfg.append(dataset.cfg) + dataset.following_randomizable = following_randomizable + + set_rnd(dataset, seed=_CYCLIC_TEST_SEED) + + self.assertEqual(nested_randomizable.seed, _CYCLIC_TEST_SEED) + self.assertEqual(following_randomizable.seed, _CYCLIC_TEST_SEED + 1) if __name__ == "__main__": From 4f52753dd44132be5c89e8f96355b68836e1276a Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 20 Aug 2026 02:28:08 +0000 Subject: [PATCH 3/3] DCO Remediation Commit for Ousama Ben Younes I, Ousama Ben Younes , hereby add my Signed-off-by to this commit: 58853d83ec29e61f8b09314bc82b4be4d3fa56ba Signed-off-by: Ousama Ben Younes