From cd726d6a0f0d0c7e0349d388f0bf17815fb0683d 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] 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()