Skip to content

Commit 1f9bf15

Browse files
authored
Merge pull request #152 from underworldcode/bugfix/swarm-write-timestep-parallel-151
Fix swarm.write_timestep parallel deadlock (#151)
2 parents 229193e + 453e506 commit 1f9bf15

2 files changed

Lines changed: 172 additions & 35 deletions

File tree

src/underworld3/swarm.py

Lines changed: 87 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,39 +1802,74 @@ def save(
18021802
if filename.endswith(".h5") == False:
18031803
raise RuntimeError("The filename must end with .h5")
18041804

1805+
local_data = self.data[:]
1806+
local_n = local_data.shape[0]
1807+
n_components = self.num_components
1808+
18051809
if h5py.h5.get_config().mpi == True and not force_sequential:
1810+
# BUGFIX(#151): the previous parallel path called
1811+
# h5f.create_dataset("data", data=self.data[:])
1812+
# collectively, but each rank passed its own local-sized array.
1813+
# In parallel HDF5 every rank must specify the *same* dataset
1814+
# shape on a collective create_dataset; passing different shapes
1815+
# leaves HDF5's internal metadata inconsistent so the collective
1816+
# close never synchronises, producing a silent hang.
1817+
#
1818+
# Fix: allgather the per-rank sizes, create the dataset at the
1819+
# global shape, then each rank writes its own slice.
1820+
sizes = comm.allgather(local_n)
1821+
total_n = sum(sizes)
1822+
offset = sum(sizes[: comm.rank])
1823+
18061824
with h5py.File(f"{filename[:-3]}.h5", "w", driver="mpio", comm=comm) as h5f:
18071825
if compression == True:
1808-
h5f.create_dataset("data", data=self.data[:], compression=compressionType)
1826+
dset = h5f.create_dataset(
1827+
"data",
1828+
shape=(total_n, n_components),
1829+
dtype=local_data.dtype,
1830+
chunks=True,
1831+
compression=compressionType,
1832+
)
18091833
else:
1810-
h5f.create_dataset("data", data=self.data[:])
1834+
dset = h5f.create_dataset(
1835+
"data",
1836+
shape=(total_n, n_components),
1837+
dtype=local_data.dtype,
1838+
)
1839+
if local_n > 0:
1840+
dset[offset : offset + local_n] = local_data
18111841
else:
1842+
# Sequential fallback: rank 0 creates the file and writes its slab,
1843+
# then each higher rank appends in turn. Indentation here matters —
1844+
# the barrier/loop must be outside the rank-0 branch so all ranks
1845+
# synchronise (the previous version nested them inside, leaving
1846+
# higher ranks' data unwritten and rank 0 deadlocked at the
1847+
# barrier with no peers).
18121848
if comm.rank == 0:
18131849
with h5py.File(f"{filename[:-3]}.h5", "w") as h5f:
18141850
if compression == True:
18151851
h5f.create_dataset(
18161852
"data",
1817-
data=self.data[:],
1853+
data=local_data,
18181854
chunks=True,
1819-
maxshape=(None, self.data.shape[1]),
1855+
maxshape=(None, n_components),
18201856
compression=compressionType,
18211857
)
18221858
else:
18231859
h5f.create_dataset(
18241860
"data",
1825-
data=self.data[:],
1861+
data=local_data,
18261862
chunks=True,
1827-
maxshape=(None, self.data.shape[1]),
1863+
maxshape=(None, n_components),
18281864
)
1829-
comm.barrier()
1830-
for proc in range(1, comm.size):
1831-
if comm.rank == proc:
1832-
if self.local_size > 0:
1833-
with h5py.File(f"{filename[:-3]}.h5", "a") as h5f:
1834-
incoming_size = h5f["data"].shape[0]
1835-
h5f["data"].resize((h5f["data"].shape[0] + self.local_size), axis=0)
1836-
h5f["data"][incoming_size:] = self.data[:, ...]
1837-
comm.barrier()
1865+
1866+
comm.barrier()
1867+
for proc in range(1, comm.size):
1868+
if comm.rank == proc and local_n > 0:
1869+
with h5py.File(f"{filename[:-3]}.h5", "a") as h5f:
1870+
incoming_size = h5f["data"].shape[0]
1871+
h5f["data"].resize((incoming_size + local_n), axis=0)
1872+
h5f["data"][incoming_size:] = local_data
18381873
comm.barrier()
18391874

18401875
## Add swarm variable unit metadata to the file
@@ -3626,31 +3661,49 @@ def save(
36263661
warnings.warn("Compression may slow down write times", stacklevel=2)
36273662

36283663
if h5py.h5.get_config().mpi == True and not force_sequential:
3629-
# It seems to be a bad idea to mix mpi barriers with the access
3630-
# context manager so the copy-free version of this seems to hang
3631-
# when there are many active cores. This is probably why the parallel
3632-
# h5py write hangs
3633-
3664+
# BUGFIX(#151): the previous parallel path called
3665+
# h5f.create_dataset("coordinates", data=points_data_copy)
3666+
# collectively, but each rank passed its own local-sized array.
3667+
# In parallel HDF5 every rank must specify the *same* dataset
3668+
# shape on a collective create_dataset; passing different shapes
3669+
# leaves HDF5's internal metadata inconsistent so the collective
3670+
# close never synchronises, producing a silent hang.
3671+
#
3672+
# Fix: allgather the per-rank sizes, create the dataset at the
3673+
# global shape, then each rank writes its own slice.
36343674
points_data_copy = self._particle_coordinates.data[:].copy()
3675+
local_n = points_data_copy.shape[0]
3676+
cdim = points_data_copy.shape[1]
3677+
sizes = comm.allgather(local_n)
3678+
total_n = sum(sizes)
3679+
offset = sum(sizes[: comm.rank])
36353680

36363681
with h5py.File(f"{filename[:-3]}.h5", "w", driver="mpio", comm=comm) as h5f:
36373682
if compression == True:
3638-
h5f.create_dataset(
3683+
dset = h5f.create_dataset(
36393684
"coordinates",
3640-
data=points_data_copy,
3685+
shape=(total_n, cdim),
3686+
dtype=points_data_copy.dtype,
3687+
chunks=True,
36413688
compression=compressionType,
36423689
)
36433690
else:
3644-
h5f.create_dataset("coordinates", data=points_data_copy)
3691+
dset = h5f.create_dataset(
3692+
"coordinates",
3693+
shape=(total_n, cdim),
3694+
dtype=points_data_copy.dtype,
3695+
)
3696+
if local_n > 0:
3697+
dset[offset : offset + local_n] = points_data_copy
36453698

36463699
del points_data_copy
36473700

36483701
else:
3649-
# It seems to be a bad idea to mix mpi barriers with the access
3650-
# context manager so the copy-free version of this seems to hang
3651-
# when there are many active cores
3702+
# Sequential fallback: rank 0 creates the file and writes its slab,
3703+
# then each higher rank appends in turn.
36523704

36533705
points_data_copy = self.points[:].copy()
3706+
local_n = points_data_copy.shape[0]
36543707

36553708
if comm.rank == 0:
36563709
with h5py.File(f"{filename[:-3]}.h5", "w") as h5f:
@@ -3672,17 +3725,16 @@ def save(
36723725

36733726
comm.barrier()
36743727
for i in range(1, comm.size):
3675-
if comm.rank == i:
3728+
if comm.rank == i and local_n > 0:
3729+
# BUGFIX(#151): the previous version referenced an undefined
3730+
# ``data_copy`` here; passive swarms with a zero-particle
3731+
# rank would have raised NameError. Use the local
3732+
# ``points_data_copy`` we already have.
36763733
with h5py.File(f"{filename[:-3]}.h5", "a") as h5f:
3677-
h5f["coordinates"].resize(
3678-
(h5f["coordinates"].shape[0] + points_data_copy.shape[0]),
3679-
axis=0,
3680-
)
3681-
# passive swarm, zero local particles is not unusual
3682-
if data_copy.shape[0] > 0:
3683-
h5f["coordinates"][-points_data_copy.shape[0] :] = points_data_copy[:]
3734+
existing_size = h5f["coordinates"].shape[0]
3735+
h5f["coordinates"].resize((existing_size + local_n), axis=0)
3736+
h5f["coordinates"][existing_size:] = points_data_copy
36843737
comm.barrier()
3685-
comm.barrier()
36863738

36873739
del points_data_copy
36883740

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""MPI regression test for swarm.write_timestep deadlock (issue #151).
2+
3+
The bug: ``Swarm.save()`` and ``SwarmVariable.save()`` parallel paths called
4+
``h5f.create_dataset(name, data=local_data)`` collectively, but each rank
5+
passed its own local-sized array. In parallel HDF5 every rank must specify
6+
the *same* dataset shape on a collective ``create_dataset``; passing
7+
different shapes leaves HDF5's internal metadata inconsistent so the
8+
collective close never synchronises, producing a silent hang.
9+
10+
The fix uses ``allgather`` of per-rank sizes, creates the dataset at the
11+
global shape, then has each rank write its slice.
12+
13+
This test runs bknight1's reproducer from #151 and fails fast via the
14+
pytest timeout rather than blocking indefinitely.
15+
"""
16+
17+
import os
18+
import pytest
19+
20+
import underworld3 as uw
21+
from petsc4py import PETSc
22+
23+
24+
pytestmark = [
25+
pytest.mark.level_2,
26+
pytest.mark.mpi(min_size=2),
27+
pytest.mark.timeout(60),
28+
]
29+
30+
31+
@pytest.mark.mpi(min_size=2)
32+
def test_swarm_write_timestep_parallel(tmp_path_factory):
33+
"""swarm.write_timestep() must complete under MPI without deadlock.
34+
35+
Pre-fix at np>=2 with unequal per-rank particle counts (which is the
36+
common case), the parallel HDF5 collective close hung indefinitely.
37+
"""
38+
if uw.mpi.rank == 0:
39+
out_dir = tmp_path_factory.mktemp("swarm_io_151")
40+
else:
41+
out_dir = None
42+
out_dir = uw.mpi.comm.bcast(out_dir, root=0)
43+
out_dir = str(out_dir)
44+
45+
mesh = uw.meshing.UnstructuredSimplexBox(
46+
minCoords=(-1.0, 0.0),
47+
maxCoords=(1.0, 1.0),
48+
cellSize=0.2,
49+
regular=False,
50+
qdegree=3,
51+
)
52+
53+
swarm = uw.swarm.Swarm(mesh=mesh)
54+
material = swarm.add_variable(name="material", size=1, dtype=PETSc.IntType)
55+
swarm.populate(fill_param=4)
56+
57+
# Sanity: this is the failing case — local sizes should NOT all be equal.
58+
sizes = uw.mpi.comm.allgather(swarm.local_size)
59+
assert sum(sizes) > 0, "swarm.populate produced zero global particles"
60+
61+
# Pre-fix: this hangs forever. The pytest timeout (60s) catches that
62+
# rather than letting the run block indefinitely.
63+
swarm.write_timestep(
64+
filename="swarm",
65+
swarmname="swarm",
66+
index=0,
67+
outputPath=out_dir,
68+
swarmVars=[material],
69+
)
70+
71+
# Verify file content
72+
expected_h5 = os.path.join(out_dir, "swarm.swarm.00000.h5")
73+
expected_var = os.path.join(out_dir, "swarm.swarm.material.00000.h5")
74+
expected_xdmf = os.path.join(out_dir, "swarm.swarm.00000.xdmf")
75+
if uw.mpi.rank == 0:
76+
assert os.path.exists(expected_h5), expected_h5
77+
assert os.path.exists(expected_var), expected_var
78+
assert os.path.exists(expected_xdmf), expected_xdmf
79+
80+
import h5py
81+
with h5py.File(expected_h5, "r") as f:
82+
n_global = f["coordinates"].shape[0]
83+
assert n_global == sum(sizes), (
84+
f"saved coords shape {n_global} != sum of local sizes {sum(sizes)}"
85+
)

0 commit comments

Comments
 (0)