Skip to content

Commit 2289e6a

Browse files
cailmdaleyclaude
andcommitted
Add MPI world-size preflight check: fail loudly on "rank 0 of N singletons"
When the host MPI launcher and the container's MPI/PMIx stack are incompatible, every process initialises standalone (COMM_WORLD size 1, rank 0). ShapePipe then treats each as master, hands each the full job list, and runs N uncoordinated copies of the pipeline into the same output directory — silently, with exit 0. This is the failure the OpenMPI-5 image fix prevents on candide, but nothing guarded against a future recurrence on another cluster. check_mpi_world() compares the size that actually wired up (COMM_WORLD) against the size the launcher intended (OMPI_COMM_WORLD_SIZE, which is set per process even when the world fails to form) and aborts on a mismatch. Empirically verified on candide: SLURM_NTASKS is NOT reliable for this (reads 1 on remote-node ranks even in a healthy run) — OMPI_COMM_WORLD_SIZE is. Tested both ways on a real allocation: healthy OMPI-5 run passes and completes; OMPI-4 image under the OMPI-5 host launcher fires the check and exits non-zero (together with the exit-code fix). Also catches partial wire-up (N-1 of N). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 33494d7 commit 2289e6a

3 files changed

Lines changed: 115 additions & 1 deletion

File tree

src/shapepipe/pipeline/mpi_run.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,52 @@
66
77
"""
88

9+
import os
10+
911
from shapepipe.pipeline.worker_handler import WorkerHandler
1012

1113

14+
def check_mpi_world(comm):
15+
"""Check MPI World.
16+
17+
Verify that the MPI world formed at the size the launcher requested, and
18+
abort loudly otherwise.
19+
20+
This guards against the "N rank-0 singletons" failure: when the host MPI
21+
launcher and the container's MPI / PMIx stack are incompatible, each
22+
process initialises standalone (``COMM_WORLD`` size 1, rank 0). ShapePipe
23+
would then treat every process as master, hand each the full job list, and
24+
run that many uncoordinated copies of the pipeline into the same output
25+
directory -- silently, with exit code 0. Comparing the size that actually
26+
wired up against the size the launcher intended (``OMPI_COMM_WORLD_SIZE``,
27+
which is set per process even when the world fails to form) turns that
28+
silent corruption into an immediate, legible error.
29+
30+
Parameters
31+
----------
32+
comm : MPI.Comm
33+
MPI communicator instance (``MPI.COMM_WORLD``)
34+
35+
Raises
36+
------
37+
RuntimeError
38+
if the launcher requested more ranks than actually wired up
39+
40+
"""
41+
intended = os.environ.get("OMPI_COMM_WORLD_SIZE")
42+
actual = comm.Get_size()
43+
44+
if intended is not None and int(intended) != actual:
45+
raise RuntimeError(
46+
f"MPI world mismatch: the launcher requested {intended} ranks but "
47+
f"only {actual} wired up (MPI_COMM_WORLD size {actual}). This is "
48+
f"the 'rank 0 of N singletons' failure -- the host MPI launcher and "
49+
f"the container's MPI/PMIx stack are incompatible. Aborting rather "
50+
f"than running {intended} uncoordinated copies of the pipeline into "
51+
f"the same output directory."
52+
)
53+
54+
1255
def split_mpi_jobs(jobs, batch_size):
1356
"""Split MPI Jobs.
1457

src/shapepipe/run.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
from shapepipe.pipeline.dependency_handler import DependencyHandler
2121
from shapepipe.pipeline.file_handler import FileHandler
2222
from shapepipe.pipeline.job_handler import JobHandler
23-
from shapepipe.pipeline.mpi_run import split_mpi_jobs, submit_mpi_jobs
23+
from shapepipe.pipeline.mpi_run import (
24+
check_mpi_world,
25+
split_mpi_jobs,
26+
submit_mpi_jobs,
27+
)
2428

2529
try:
2630
from mpi4py import MPI
@@ -372,6 +376,11 @@ def run_mpi(pipe, comm):
372376
# Assign master node
373377
master = comm.rank == 0
374378

379+
# Fail loudly if the MPI world did not form at the size the launcher
380+
# requested (the "rank 0 of N singletons" launcher/container mismatch),
381+
# rather than silently running redundant copies of the pipeline.
382+
check_mpi_world(comm)
383+
375384
# Get the module to be run
376385
modules = pipe.modules if master else None
377386
modules = comm.bcast(modules, root=0)

tests/unit/test_mpi_world.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Guard against the silent "rank 0 of N singletons" MPI failure.
2+
3+
When the host MPI launcher and the container's MPI/PMIx stack are
4+
incompatible, every process initialises standalone (``COMM_WORLD`` size 1),
5+
and ShapePipe would otherwise run N uncoordinated copies of the pipeline into
6+
the same output directory -- silently, with exit code 0.
7+
``check_mpi_world`` turns that into an immediate error by comparing the size
8+
that actually wired up against the launcher's intended ``OMPI_COMM_WORLD_SIZE``.
9+
The intended/actual pairs below are the values measured on candide for a
10+
healthy run and for the OpenMPI-4-container / OpenMPI-5-host mismatch.
11+
"""
12+
13+
import pytest
14+
15+
from shapepipe.pipeline.mpi_run import check_mpi_world
16+
17+
18+
class _FakeComm:
19+
"""Minimal stand-in exposing only ``Get_size``."""
20+
21+
def __init__(self, size):
22+
23+
self._size = size
24+
25+
def Get_size(self):
26+
27+
return self._size
28+
29+
30+
@pytest.mark.parametrize(
31+
"intended, actual",
32+
[
33+
("4", 4), # healthy multi-node run
34+
(None, 1), # no launcher env -> legitimate single-rank run
35+
("1", 1), # explicit single rank
36+
],
37+
ids=["healthy", "no-launcher-env", "single-rank"],
38+
)
39+
def test_check_mpi_world_passes(monkeypatch, intended, actual):
40+
41+
if intended is None:
42+
monkeypatch.delenv("OMPI_COMM_WORLD_SIZE", raising=False)
43+
else:
44+
monkeypatch.setenv("OMPI_COMM_WORLD_SIZE", intended)
45+
46+
check_mpi_world(_FakeComm(actual))
47+
48+
49+
@pytest.mark.parametrize(
50+
"intended, actual",
51+
[
52+
("4", 1), # the measured singleton failure
53+
("4", 3), # partial wire-up
54+
],
55+
ids=["singletons", "partial-wireup"],
56+
)
57+
def test_check_mpi_world_aborts_on_mismatch(monkeypatch, intended, actual):
58+
59+
monkeypatch.setenv("OMPI_COMM_WORLD_SIZE", intended)
60+
61+
with pytest.raises(RuntimeError, match="MPI world mismatch"):
62+
check_mpi_world(_FakeComm(actual))

0 commit comments

Comments
 (0)