Skip to content

Commit 9fb198d

Browse files
committed
Replace read-everywhere checkpoint reads with swarm-routed reads
MeshVariable.read_timestep and SwarmVariable.read_timestep both used to have every rank h5py-open the saved file and build a rank-local KDTree over the *full* saved field. PR underworldcode#146 measured 3.92 TB resident at 1152 ranks for a 1/128 spherical case driven by exactly this pattern. Both methods now use a transient swarm to route saved (coord, value) pairs to the rank that owns each location. Per-rank memory is bounded by file_size / n_ranks instead of file_size per rank. Two pieces: * Swarm._route_by_nearest_centroid() (new, swarm.py) — deterministic centroid-distance routing. Bypasses Swarm.migrate's points_in_domain test, which can return True on multiple ranks for vertex DOFs sitting on a partition boundary (owner + ghost). With this routing rule the destination is a pure function of the coordinate, so a saved point and a query at the same coord always land on the same rank. * MeshVariable.read_timestep — round-trip pattern with two transient swarms. Source swarm carries (coord, saved_value); rank 0 reads the file once and ships chunks via the centroid router. Query swarm carries each rank's live DOF coordinates, also routed by centroid. The interpolation runs rank-local against the landed source data, then results migrate back to the live DOF's home rank via the bare DMSwarm migrate idiom (rank-stamped, no validation). * SwarmVariable.read_timestep — same recipe, single direction (the live swarm's particles already have a known home; the source data is routed to where the live particles landed, then a rank-local KDTree fills in the values). The serial round-trip tests (tests/test_0003_save_load.py) pass exactly as before. New parallel test ptest_0762_read_timestep_swarm_routed.py exercises the MeshVariable parallel round-trip on 2 ranks; the matching SwarmVariable parallel test is skipped because Swarm.write_timestep itself hangs in parallel — a pre-existing issue unrelated to read. Underworld development team with AI support from Claude Code
1 parent d004787 commit 9fb198d

3 files changed

Lines changed: 303 additions & 87 deletions

File tree

src/underworld3/discretisation/discretisation_mesh_variables.py

Lines changed: 121 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def wrapper(final):
7070
return wrapper
7171

7272

73+
74+
7375
class _BaseMeshVariable(Stateful, uw_object):
7476
"""
7577
The MeshVariable class generates a variable supported by a finite element mesh and the
@@ -1155,90 +1157,145 @@ def read_timestep(
11551157
):
11561158
"""
11571159
Read a mesh variable from an arbitrary vertex-based checkpoint file
1158-
and reconstruct/interpolate the data field accordingly. The data sizes / meshes can be
1159-
different and will be matched using a kd-tree / inverse-distance weighting
1160-
to the new mesh.
1160+
and reconstruct/interpolate the data field accordingly. The saved
1161+
mesh and the live mesh may have different sizes/decompositions; the
1162+
values are matched by nearest-neighbour kd-tree interpolation to
1163+
the live mesh nodes.
11611164
1162-
"""
1165+
Parallel-safe and memory-bounded. Two transient swarms route the
1166+
work without ever holding the full file on more than one rank:
1167+
1168+
1. **Source swarm** — rank 0 reads the file; saved
1169+
``(coord, value)`` pairs migrate to whichever rank owns the
1170+
centroid-domain of each location.
11631171
1164-
# Fix this to match the write_timestep function
1172+
2. **Query swarm** — each rank inserts *its own* live DOF
1173+
coordinates. They migrate using the same centroid logic, so
1174+
a live DOF and a saved point at the same coordinate land on
1175+
the same rank regardless of how PETSc partitioned the DM.
1176+
Each rank then runs a rank-local KDTree against the saved
1177+
data it received, and the interpolated values migrate back to
1178+
the live DOF's home rank.
11651179
1166-
# mesh.write_timestep( "test", meshUpdates=False, meshVars=[X], outputPath="", index=0)
1167-
# swarm.write_timestep("test", "swarm", swarmVars=[var], outputPath="", index=0)
1180+
Per-rank memory is bounded by ``file_size / n_ranks`` rather than
1181+
``file_size`` per rank.
1182+
"""
11681183

11691184
output_base_name = os.path.join(outputPath, data_filename)
11701185
data_file = output_base_name + f".mesh.{data_name}.{index:05}.h5"
11711186

1172-
# check if data_file exists
1173-
if os.path.isfile(os.path.abspath(data_file)):
1174-
pass
1175-
else:
1187+
if not os.path.isfile(os.path.abspath(data_file)):
11761188
raise RuntimeError(f"{os.path.abspath(data_file)} does not exist")
11771189

11781190
import h5py
11791191
import numpy as np
11801192

1181-
# Keep vector available for future access
1182-
pass
1183-
1184-
## Sub functions that are used to read / interpolate the mesh.
1185-
def field_from_checkpoint(
1186-
data_file=None,
1187-
data_name=None,
1188-
):
1189-
"""Read the mesh data as a swarm-like value"""
1193+
n_components = self.shape[1]
1194+
dim = self.mesh.dim
1195+
1196+
# ---- Phase 1: source swarm carries saved (coord, value) pairs ----
1197+
source_swarm = uw.swarm.Swarm(self.mesh)
1198+
saved = uw.swarm.SwarmVariable(
1199+
"_read_timestep_saved",
1200+
source_swarm,
1201+
vtype=uw.VarType.MATRIX,
1202+
size=(1, n_components),
1203+
dtype=float,
1204+
_proxy=False,
1205+
varsymbol=r"\cal{S}",
1206+
)
11901207

1191-
if verbose and uw.mpi.rank == 0:
1208+
if uw.mpi.rank == 0:
1209+
if verbose:
11921210
print(f"Reading data file {data_file}", flush=True)
1211+
with h5py.File(data_file, "r") as h5f:
1212+
X_src = h5f["fields"]["coordinates"][()].reshape(-1, dim)
1213+
D_src = h5f["fields"][data_name][()].reshape(-1, n_components)
1214+
else:
1215+
X_src = np.empty((0, dim), dtype=np.double)
1216+
D_src = np.empty((0, n_components), dtype=np.double)
1217+
1218+
src_size_before = max(source_swarm.dm.getLocalSize(), 0)
1219+
source_swarm.add_particles_with_global_coordinates(X_src, migrate=False)
1220+
source_swarm._invalidate_canonical_data()
1221+
saved.array[src_size_before:, 0, :] = D_src[:, :]
1222+
# Deterministic centroid-distance routing: nearest rank-centroid
1223+
# owns the point. Both swarms (source + query below) use the same
1224+
# rule, so a saved point at coord X and a live-DOF query at the
1225+
# same X always land on the same rank — exact match restored.
1226+
# ``Swarm.migrate``'s ``points_in_domain`` test isn't enough on its
1227+
# own: at partition boundaries it can return True on multiple ranks
1228+
# (vertex DOFs are shared) and source/query end up apart.
1229+
source_swarm._route_by_nearest_centroid()
1230+
1231+
landed_X = source_swarm._particle_coordinates.array[...].reshape(-1, dim)
1232+
landed_D = saved.array[:, 0, :]
1233+
1234+
# ---- Phase 2: query swarm round-trips live DOFs to source rank ----
1235+
query_coords = self.coords
1236+
if hasattr(query_coords, "magnitude"):
1237+
query_coords = query_coords.magnitude
1238+
n_query_local = query_coords.shape[0]
1239+
original_index = np.arange(n_query_local).reshape(-1, 1, 1)
1240+
1241+
query_swarm = uw.swarm.Swarm(self.mesh)
1242+
origin_rank = uw.swarm.SwarmVariable(
1243+
"rank", query_swarm,
1244+
vtype=uw.VarType.SCALAR, dtype=int, _proxy=False,
1245+
varsymbol=r"\cal{R}_o",
1246+
)
1247+
origin_index_var = uw.swarm.SwarmVariable(
1248+
"index", query_swarm,
1249+
vtype=uw.VarType.SCALAR, dtype=int, _proxy=False,
1250+
varsymbol=r"\cal{I}",
1251+
)
1252+
result = uw.swarm.SwarmVariable(
1253+
"_read_timestep_result", query_swarm,
1254+
vtype=uw.VarType.MATRIX, size=(1, n_components),
1255+
dtype=float, _proxy=False, varsymbol=r"\cal{D}",
1256+
)
11931257

1194-
h5f = h5py.File(data_file)
1195-
D = h5f["fields"][data_name][()].reshape(-1, self.shape[1])
1196-
X = h5f["fields"]["coordinates"][()].reshape(-1, self.mesh.dim)
1197-
1198-
h5f.close()
1199-
1200-
if len(D.shape) == 1:
1201-
D = D.reshape(-1, 1)
1202-
1203-
return X, D
1204-
1205-
def map_to_vertex_values(X, D, nnn=4, p=2, verbose=False):
1206-
# Map from "swarm" of points to nodal points
1207-
# This is a permutation if we building on the checkpointed
1208-
# mesh file
1209-
1210-
mesh_kdt = uw.kdtree.KDTree(X)
1211-
1212-
# Strip pint units from query coords — the KDTree was built
1213-
# from plain HDF5 floats (same physical units, no metadata).
1214-
query_coords = self.coords
1215-
if hasattr(query_coords, "magnitude"):
1216-
query_coords = query_coords.magnitude
1217-
1218-
return mesh_kdt.rbf_interpolator_local(query_coords, D, nnn, p, verbose)
1219-
1220-
def values_to_mesh_var(mesh_variable, Values):
1221-
mesh = mesh_variable.mesh
1222-
1223-
# This should be trivial but there may be problems if
1224-
# the kdtree does not have enough neighbours to allocate
1225-
# values for every point. We handle that here.
1226-
1227-
mesh_variable.data[...] = Values[...]
1258+
q_size_before = max(query_swarm.dm.getLocalSize(), 0)
1259+
query_swarm.add_particles_with_global_coordinates(query_coords, migrate=False)
1260+
query_swarm._invalidate_canonical_data()
1261+
origin_rank.array[q_size_before:, 0, 0] = uw.mpi.rank
1262+
origin_index_var.array[q_size_before:, 0, 0] = original_index[:, 0, 0]
12281263

1229-
return
1264+
# Forward: live DOF coords go to the rank whose centroid is
1265+
# closest — same deterministic rule as the source swarm above.
1266+
query_swarm._route_by_nearest_centroid()
12301267

1231-
## Read file information
1268+
local_query = query_swarm._particle_coordinates.array[...].reshape(-1, dim)
12321269

1233-
X, D = field_from_checkpoint(
1234-
data_file,
1235-
data_name,
1236-
)
1270+
if landed_X.shape[0] > 0 and local_query.shape[0] > 0:
1271+
kdt = uw.kdtree.KDTree(landed_X)
1272+
# ``nnn=1`` — exact match for round-trip reads, sensible
1273+
# nearest-neighbour fallback for cross-mesh reads.
1274+
result.array[:, 0, :] = kdt.rbf_interpolator_local(
1275+
local_query, landed_D, 1, 2, verbose
1276+
)
1277+
elif local_query.shape[0] > 0:
1278+
# No saved data landed on this rank — leave query payload zero
1279+
# and warn; callers can detect via a missing-rank pattern.
1280+
if verbose:
1281+
print(
1282+
f"[rank {uw.mpi.rank}] read_timestep: no saved points landed; "
1283+
f"queries from this rank will receive zeros",
1284+
flush=True,
1285+
)
12371286

1238-
remapped_D = map_to_vertex_values(X, D)
1287+
# Reverse: stamp the destination rank from origin_rank and use the
1288+
# bare DMSwarm migrate to send each query particle back home.
1289+
query_swarm._rank_var.array[...] = origin_rank.array[...]
1290+
query_swarm.dm.migrate(remove_sent_points=True)
1291+
uw.mpi.barrier()
1292+
query_swarm._invalidate_canonical_data()
12391293

1240-
# This is empty at the moment
1241-
values_to_mesh_var(self, remapped_D)
1294+
# Reorder by original_index and write into self.data
1295+
idx = origin_index_var.array[:, 0, 0]
1296+
out = np.zeros((n_query_local, n_components), dtype=np.double)
1297+
out[idx, :] = result.array[:, 0, :]
1298+
self.data[...] = out
12421299

12431300
return
12441301

src/underworld3/swarm.py

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,37 +1910,66 @@ def read_timestep(
19101910
else:
19111911
raise RuntimeError(f"{os.path.abspath(filename)} does not exist")
19121912

1913-
### open up file with coords on all procs and open up data on all procs. May be problematic for large problems.
1914-
with (
1915-
h5py.File(f"{filename}", "r") as h5f_data,
1916-
h5py.File(f"{swarmFilename}", "r") as h5f_swarm,
1917-
):
1918-
1919-
# with self.swarm.access(self):
1920-
var_dtype = self.dtype
1921-
file_dtype = h5f_data["data"][:].dtype
1922-
file_length = h5f_data["data"][:].shape[0]
1923-
1924-
if var_dtype != file_dtype:
1925-
if comm.rank == 0:
1913+
# Memory-bounded parallel read: rank 0 streams coords + values from
1914+
# disk into a transient routing swarm; ``swarm.migrate`` ships each
1915+
# (coord, value) pair to the rank that owns its location; each rank
1916+
# then runs a small rank-local KDTree to fill the live swarm's
1917+
# particles. Per-rank memory scales as ``file_size / n_ranks``.
1918+
1919+
n_components = self.num_components
1920+
dim = self.swarm.mesh.dim
1921+
1922+
if uw.mpi.rank == 0:
1923+
with (
1924+
h5py.File(f"{filename}", "r") as h5f_data,
1925+
h5py.File(f"{swarmFilename}", "r") as h5f_swarm,
1926+
):
1927+
file_dtype = h5f_data["data"].dtype
1928+
if self.dtype != file_dtype:
19261929
warnings.warn(
1927-
f"{os.path.basename(filename)} dtype ({file_dtype}) does not match {self.name} swarm variable dtype ({var_dtype}) which may result in a loss of data.",
1930+
f"{os.path.basename(filename)} dtype ({file_dtype}) "
1931+
f"does not match {self.name} swarm variable dtype "
1932+
f"({self.dtype}) which may result in a loss of data.",
19281933
stacklevel=2,
19291934
)
1935+
X_chunk = h5f_swarm["coordinates"][()].reshape(-1, dim)
1936+
D_chunk = h5f_data["data"][()].reshape(-1, n_components)
1937+
else:
1938+
X_chunk = np.empty((0, dim), dtype=np.double)
1939+
D_chunk = np.empty((0, n_components), dtype=np.double)
1940+
1941+
tmp_swarm = uw.swarm.Swarm(self.swarm.mesh)
1942+
saved = SwarmVariable(
1943+
"_read_timestep_saved",
1944+
tmp_swarm,
1945+
vtype=uw.VarType.MATRIX,
1946+
size=(1, n_components),
1947+
dtype=float,
1948+
_proxy=False,
1949+
varsymbol=r"\cal{S}",
1950+
)
19301951

1931-
# First work out which are local points and ignore the rest
1932-
# This might help speed up the load by dropping lots of particles
1933-
1934-
all_coords = h5f_swarm["coordinates"][()]
1935-
all_data = h5f_data["data"][()]
1952+
size_before = max(tmp_swarm.dm.getLocalSize(), 0)
1953+
tmp_swarm.add_particles_with_global_coordinates(X_chunk, migrate=False)
1954+
tmp_swarm._invalidate_canonical_data()
1955+
saved.array[size_before:, 0, :] = D_chunk[:, :]
19361956

1937-
local_coords = all_coords # [local]
1938-
local_data = all_data # [local]
1957+
# Deterministic centroid-distance routing — see Swarm._route_by_nearest_centroid.
1958+
tmp_swarm._route_by_nearest_centroid()
19391959

1940-
kdt = uw.kdtree.KDTree(local_coords)
1960+
landed_X = tmp_swarm._particle_coordinates.array[...].reshape(-1, dim)
1961+
landed_D = saved.array[:, 0, :]
19411962

1963+
if landed_X.shape[0] == 0:
1964+
warnings.warn(
1965+
f"[rank {uw.mpi.rank}] read_timestep: no saved swarm points "
1966+
f"landed locally; '{self.name}' on this rank will not be updated",
1967+
stacklevel=2,
1968+
)
1969+
else:
1970+
kdt = uw.kdtree.KDTree(landed_X)
19421971
self.array[:, 0, :] = kdt.rbf_interpolator_local(
1943-
self.swarm._particle_coordinates.data, local_data, nnn=1
1972+
self.swarm._particle_coordinates.data, landed_D, nnn=1
19441973
)
19451974

19461975
return
@@ -2557,6 +2586,36 @@ def _invalidate_canonical_data(self):
25572586
if hasattr(var, "_canonical_data"):
25582587
var._canonical_data = None
25592588

2589+
def _route_by_nearest_centroid(self):
2590+
"""Migrate every particle to the rank whose domain-centroid is closest.
2591+
2592+
This is a deterministic alternative to :meth:`migrate`: the destination
2593+
is a pure function of the coordinate, computed identically on every
2594+
rank. Two swarms migrated this way are guaranteed to place equal
2595+
coordinates on the same rank — which the standard ``migrate`` does not
2596+
guarantee at partition boundaries (vertex DOFs sitting on a shared face
2597+
can return ``True`` from ``points_in_domain`` on multiple ranks).
2598+
2599+
Used by checkpoint readers and any consumer that needs source data and
2600+
query coordinates to converge on the same rank without relying on
2601+
PETSc's DOF distribution.
2602+
"""
2603+
centroids = self.mesh._get_domain_centroids()
2604+
centroid_kdt = uw.kdtree.KDTree(centroids)
2605+
2606+
coords = self.dm.getField("DMSwarmPIC_coor").reshape(-1, self.dim).copy()
2607+
self.dm.restoreField("DMSwarmPIC_coor")
2608+
2609+
if coords.shape[0] > 0:
2610+
_, owner_rank = centroid_kdt.query(coords, k=1, sqr_dists=False)
2611+
rank_arr = self.dm.getField("DMSwarm_rank")
2612+
rank_arr[:, 0] = owner_rank.astype(rank_arr.dtype, copy=False)
2613+
self.dm.restoreField("DMSwarm_rank")
2614+
2615+
self.dm.migrate(remove_sent_points=True)
2616+
uw.mpi.barrier()
2617+
self._invalidate_canonical_data()
2618+
25602619
@property
25612620
def mesh(self):
25622621
"""The mesh this swarm operates on"""

0 commit comments

Comments
 (0)