Skip to content

Commit d004787

Browse files
authored
Merge pull request #153 from underworldcode/feature/swarm-routed-point-eval
Fix Swarm lifecycle: stop leaking transient swarms via the model registry
2 parents b39297d + ba30093 commit d004787

5 files changed

Lines changed: 317 additions & 15 deletions

File tree

pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pytest-mpi = ">=0.6,<0.7"
8989
pytest-timeout = ">=2.3,<3"
9090
pytest-forked = ">=1.6,<2"
9191
pytest-xdist = ">=3.8,<4"
92+
psutil = ">=5.9,<8" # leak-loop regression test in tests/test_0111_swarm_lifecycle.py
9293

9394
# Mesh generation
9495
pykdtree = ">=1.4,<2"

src/underworld3/function/_function.pyx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,7 @@ def global_evaluate_nd( expr,
506506

507507
# Invalidate cached data after bare-bones dm.migrate —
508508
# particle count and values changed but Swarm.migrate() was bypassed.
509-
evaluation_swarm._particle_coordinates._canonical_data = None
510-
for var in evaluation_swarm._vars.values():
511-
if hasattr(var, "_canonical_data"):
512-
var._canonical_data = None
509+
evaluation_swarm._invalidate_canonical_data()
513510

514511
# Pre-allocate with NaN so the shape is always correct. If any points
515512
# are lost during the migration round-trip, they remain NaN rather than

src/underworld3/model.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,17 @@ class Model(PintNativeModelMixin, BaseModel):
112112
# Declare private attributes for Pydantic v2
113113
_meshes: Any = PrivateAttr(default_factory=dict)
114114
_primary_mesh_id: Optional[int] = PrivateAttr(default=None)
115-
_swarms: Any = PrivateAttr(default_factory=dict)
115+
# WeakValueDictionary so dropping the user's last reference to a swarm
116+
# actually lets it be garbage-collected. Without this, transient swarms
117+
# used inside functions (global expression evaluation, checkpoint reads,
118+
# mesh-adapt transfers) accumulate forever via the registry's strong
119+
# reference and ``Swarm.__del__`` never fires. Length and membership
120+
# checks are dictionary-like; iteration is *not* stable in the way a
121+
# plain dict's is, because entries can disappear asynchronously as
122+
# their swarm value is collected. Call sites that need a stable
123+
# traversal (summaries, snapshots) should iterate a copy such as
124+
# ``list(self._swarms.items())``.
125+
_swarms: Any = PrivateAttr(default_factory=weakref.WeakValueDictionary)
116126
_variables: Dict[str, Any] = PrivateAttr(default_factory=dict)
117127
_solvers: Dict[str, Any] = PrivateAttr(default_factory=dict)
118128

@@ -210,6 +220,31 @@ def _register_swarm(self, swarm):
210220
swarm_id = id(swarm)
211221
self._swarms[swarm_id] = swarm
212222

223+
def _unregister_swarm(self, swarm):
224+
"""
225+
Internal method to drop a swarm and all its variables from the registry.
226+
227+
Without this, transient swarms (used for global expression evaluation,
228+
checkpoint reads, mesh adaptation transfers) accumulate in the registry
229+
forever, leaking the underlying PETSc DMs and field storage. Called
230+
from :meth:`Swarm.__del__`.
231+
"""
232+
swarm_id = id(swarm)
233+
self._swarms.pop(swarm_id, None)
234+
# Drop any variables that belong to this swarm. Building the list of
235+
# names first avoids mutating the dict during iteration.
236+
try:
237+
from .swarm import SwarmVariable
238+
except ImportError:
239+
return
240+
names_to_drop = [
241+
name for name, var in self._variables.items()
242+
if isinstance(var, SwarmVariable) and getattr(var, "_swarm_ref", None) is not None
243+
and var._swarm_ref() is swarm
244+
]
245+
for name in names_to_drop:
246+
self._variables.pop(name, None)
247+
213248
def _register_variable(self, name, variable):
214249
"""
215250
Internal method to register a variable with this model.
@@ -225,13 +260,15 @@ def _register_variable(self, name, variable):
225260
variable : MeshVariable or SwarmVariable
226261
Variable instance to register
227262
"""
228-
# For SwarmVariables, ensure we keep strong reference to swarm to prevent garbage collection
263+
# For SwarmVariables, make sure the parent swarm has been registered.
264+
# Registration is via WeakValueDictionary, so it does not pin the
265+
# swarm — when the user drops their last reference the swarm dies
266+
# and ``Swarm.__del__`` cleans up the variable side of the registry.
229267
from .swarm import SwarmVariable
230268

231269
if isinstance(variable, SwarmVariable):
232270
swarm = variable.swarm # This will raise if swarm already garbage collected
233271
swarm_id = id(swarm)
234-
# Ensure swarm is registered with strong reference
235272
if swarm_id not in self._swarms:
236273
self._register_swarm(swarm)
237274

@@ -4245,7 +4282,7 @@ def view(self, verbose: int = 0, show_materials: bool = True, show_petsc: bool =
42454282
swarm_count = len(self._swarms)
42464283
lines.append(f"**Swarms:** {swarm_count} registered")
42474284
if swarm_count > 0 and verbose >= 1:
4248-
for swarm_id, swarm in self._swarms.items():
4285+
for swarm_id, swarm in list(self._swarms.items()):
42494286
try:
42504287
if hasattr(swarm, "data") and swarm.data is not None:
42514288
particle_count = len(swarm.data)

src/underworld3/swarm.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2499,17 +2499,63 @@ def __init__(self, mesh, recycle_rate=0, verbose=False, clip_to_mesh=True):
24992499

25002500
super().__init__()
25012501

2502-
# Register with default model for orchestration
2503-
uw.get_default_model()._register_swarm(self)
2502+
# Register with the same model already captured in self._model_ref
2503+
# above (not a fresh ``get_default_model()`` call) so that
2504+
# ``__del__`` deregisters from the same registry it registered with,
2505+
# even if the default model is swapped mid-session.
2506+
model._register_swarm(self)
25042507

25052508
def __del__(self):
2506-
"""Cleanup swarm by unregistering from mesh to prevent memory leaks"""
2509+
"""Cleanup swarm: unregister from mesh and model, destroy the DM.
2510+
2511+
Three steps: drop the mesh-side weak-set entry, drop the model-side
2512+
registry entry (which also forgets any SwarmVariables that belonged
2513+
to this swarm), and call ``self.dm.destroy()`` to release the PETSc
2514+
DMSwarm and its registered fields.
2515+
2516+
Historically the model registry kept a strong reference to every
2517+
swarm and ``__del__`` did not call ``dm.destroy()`` — both leaks
2518+
accumulated quickly inside time-stepping loops that build transient
2519+
swarms (global expression evaluation, checkpoint reads, mesh
2520+
adaptation transfers). The registry is now a ``WeakValueDictionary``
2521+
and ``__del__`` cleans up its own resources.
2522+
"""
25072523
try:
25082524
if hasattr(self, "mesh") and self.mesh is not None:
25092525
self.mesh.unregister_swarm(self)
25102526
except (AttributeError, ReferenceError, RuntimeError):
25112527
# Mesh/Model may have already been garbage collected, which is fine
25122528
pass
2529+
try:
2530+
model = self._model_ref() if hasattr(self, "_model_ref") else None
2531+
if model is not None:
2532+
model._unregister_swarm(self)
2533+
except (AttributeError, ReferenceError, RuntimeError):
2534+
pass
2535+
try:
2536+
if hasattr(self, "dm") and self.dm is not None:
2537+
self.dm.destroy()
2538+
except Exception:
2539+
# DM may already have been destroyed (e.g. by Mesh.adapt) or
2540+
# PETSc may be finalising. Either way, nothing more we can do.
2541+
pass
2542+
2543+
def _invalidate_canonical_data(self):
2544+
"""Drop cached array views on the coordinates and every registered variable.
2545+
2546+
Required after any operation that bypasses :meth:`Swarm.migrate` but
2547+
still mutates particle layout — notably the bare ``self.dm.migrate(...)``
2548+
used by round-trip evaluation patterns. Calling :meth:`Swarm.migrate`
2549+
already does this internally; call this directly only when you used the
2550+
underlying DMSwarm migrate.
2551+
"""
2552+
if hasattr(self, "_particle_coordinates") and self._particle_coordinates is not None:
2553+
self._particle_coordinates._canonical_data = None
2554+
# ``self._vars`` may be a WeakValueDictionary whose values disappear
2555+
# asynchronously during GC; iterate a snapshot.
2556+
for var in list(self._vars.values()):
2557+
if hasattr(var, "_canonical_data"):
2558+
var._canonical_data = None
25132559

25142560
@property
25152561
def mesh(self):
@@ -3268,10 +3314,7 @@ def migrate(
32683314
# Invalidate all cached data after migration.
32693315
# Any particle movement (send, receive, or balanced swap) makes
32703316
# cached arrays stale — both size and values may have changed.
3271-
self._particle_coordinates._canonical_data = None
3272-
for var in self._vars.values():
3273-
if hasattr(var, "_canonical_data"):
3274-
var._canonical_data = None
3317+
self._invalidate_canonical_data()
32753318

32763319
return
32773320

0 commit comments

Comments
 (0)