DeepSpeed cannot start without mpi4py on a machine with no launcher - #8444
DeepSpeed cannot start without mpi4py on a machine with no launcher#8444alanhuangyoo wants to merge 5 commits into
Conversation
init_distributed fills in the distributed environment when a launcher did not, and its only route for that was mpi_discovery, which imports mpi4py. So `python train.py` on a machine with one accelerator and no launcher ends at ModuleNotFoundError: No module named 'mpi4py' before deepspeed.initialize returns. Reproduced on both an Apple M5 (MPS, ZeRO 0-3) and an H20 (CUDA, ZeRO-1); neither had mpi4py installed, which is the default for a machine that was never going to run an MPI job. comm/utils.py already reads a missing launcher as rank 0 of a world of 1 - get_local_rank_from_launcher and its siblings say so in a comment - so put that same reading in the environment the backend is initialized from. Nothing changes for anyone who has mpi4py: mpi_discovery still runs first and only an ImportError from it is caught. If that fires while an MPI launcher's rank variable is set, the run stops with a message naming mpi4py rather than silently becoming one rank of a many-rank job. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Two things I checked afterwards that bear on the review. mpi4py is an optional extra, not a core dependency. It appears in exactly one place in the requirements:
Why CI never caught it. # tests/unit/common.py
set_dist_env = True
...
if self.set_dist_env:
os.environ['MASTER_ADDR'] = '127.0.0.1'
os.environ['MASTER_PORT'] = str(master_port)
...
os.environ['RANK'] = str(local_rank)so the It is a real test of a real thing, it just is not a test of this. The no-launcher-and-no-mpi4py combination has no coverage, which is what the new file adds.
(Editing this comment — my shell ate the inline code spans in the paragraph above when I first posted it.) |
|
The comparison I promised. Same machine, same command, only The same four errors on both sides, so they are pre-existing on this machine and not something this PR introduces. The Also ran the new file on its own on both machines: 9 passed on the H20 (Linux, CUDA) and 9 passed on the Mac (macOS, MPS). |
| # Rank variables the MPI launchers export: OpenMPI, MPICH and Intel MPI, PMIx, MVAPICH, and | ||
| # Slurm's srun. Used only to tell an MPI job that is missing mpi4py from a machine that has no | ||
| # launcher at all, so an unrecognized launcher still reaches mpi_discovery as before. | ||
| MPI_RANK_ENV_VARS = ("OMPI_COMM_WORLD_RANK", "PMI_RANK", "PMIX_RANK", "MV2_COMM_WORLD_RANK", "SLURM_PROCID") |
There was a problem hiding this comment.
I ran this against 5ef94c45e100ff9f53da880caa5a45b2693803e6 in a clean container (python:3.12-slim, CPU torch, no mpi4py, DS_ACCELERATOR=cpu).
in_mpi_job() keys on rank variables, and SLURM_PROCID is one of them. If srun -n1 python train.py sets it, which is my reading of srun's environment rather than something I could run here, then a one-task step is a single process on one device and takes the raise instead of the path this PR adds:
# SLURM_PROCID=0, SLURM_NTASKS=1, no launcher vars, no mpi4py
in_mpi_job() -> True
RESULT: ImportError -> An MPI job is running but mpi4py is not installed, so the rank
and world size cannot be discovered from it. Install mpi4py, or set RANK, WORLD_SIZE, ...
# identical, minus SLURM_PROCID
in_mpi_job() -> False
ENV AFTER -> {'RANK': '0', 'WORLD_SIZE': '1', 'LOCAL_RANK': '0',
'MASTER_ADDR': '127.0.0.1', 'MASTER_PORT': '29500'}
A rank variable says a launcher is present, not that the world is bigger than one. The size variables sitting next to them do say it: SLURM_NTASKS, OMPI_COMM_WORLD_SIZE, PMI_SIZE. Would keying the raise on one of those being above 1 be closer to what you mean here, so a one-task step still gets the single-process path?
There was a problem hiding this comment.
You are right, and the distinction you drew is the one I got wrong: a rank variable says a launcher is present, not that the world is bigger than one. srun -n1 is a launcher and a single process at the same time, and that is exactly the case this PR exists to serve, so keying the refusal on SLURM_PROCID sent it to the error instead of the fallback.
Changed to read the size variables the same launchers export alongside their rank ones — OMPI_COMM_WORLD_SIZE, PMI_SIZE, PMIX_SIZE, MV2_COMM_WORLD_SIZE, SLURM_NTASKS — and refuse only when one of them is above 1. Same machine as before, no mpi4py:
srun -n1 (SLURM_PROCID=0 SLURM_NTASKS=1) trains, losses=[0.05809, 0.04466, 0.03354]
srun -n4 (SLURM_PROCID=0 SLURM_NTASKS=4) ImportError naming mpi4py
mpirun -n2 (OMPI_COMM_WORLD_RANK=0 SIZE=2) ImportError naming mpi4py
no launcher trains, same losses
Four tests added for it: each size variable above 1 marks a multi-rank job, each one at 1 does not, a rank variable on its own does not, and an unparseable size does not. Plus the end-to-end one for your case — SLURM_PROCID=0, SLURM_NTASKS=1, mpi4py absent — asserting the fallback is reached rather than the raise. 17 passing in the file.
PMIX_SIZE I have kept on the list for symmetry with the rank set, though I have not confirmed PMIx exports it under that name and could not test it here. If you know it is spelled differently I will fix it; a wrong name there fails safe, since it only means that launcher is read from whichever of the others it also sets.
Thanks — you also read this in a clean container without mpi4py, which is the setup that makes the bug visible at all, and that is the second time on my PRs that the container is what caught the thing I could not see from my own machine.
There was a problem hiding this comment.
I read 114ba85 rather than ran it, and keying on the size variables is right for the srun -n1 case.
On PMIX_SIZE: it is not an environment variable, so it is not a spelling to correct. I cloned openpmix (c1e1744) and PRRTE (bfab800) and enumerated every literal name either passes to a setenv variant. The only PMIx names that reach a launched process are PMIX_RANK and PMIX_NAMESPACE, set by the server fork setup in src/server/pmix_server.c:1617 and :1624, and nothing with SIZE, NPROC, NTASK or WORLD in the name is set anywhere in either tree:
$ grep -rhoiE '[a-z_]*setenv\(\s*&?"[A-Z0-9_]+"' --include='*.c' --include='*.h' . \
| grep -oE '"[A-Z0-9_]+"' | sort | uniq -c | sort -rn | head -3
17 "PMIX_RANK"
16 "PMIX_NAMESPACE"
9 "PMIX_SERVER_URI51"
$ grep -rniE '[a-z_]*setenv\(\s*&?"[A-Z0-9_]*(SIZE|NPROC|NTASK|WORLD)[A-Z0-9_]*"' \
--include='*.c' --include='*.h' . | wc -l
0
The PMIX_SIZE a grep does find is the size_t data type, registered as PMIX_REGISTER_TYPE("PMIX_SIZE", PMIX_SIZE, ...) in src/mca/bfrops/*/bfrop_pmix*.c.
That matters beyond the dead entry, because PMIX_RANK was the one covering a job launched directly by PMIx (prterun, prun). srun --mpi=pmix still sets SLURM_NTASKS and Open MPI's mpirun still sets OMPI_COMM_WORLD_SIZE, so those stay covered. The case that changes is a multi-rank prterun job with no mpi4py: it used to raise, and now no size variable is set at all, so it reaches single_process_discovery and every rank comes up as world size 1.
I do not think the environment can settle that one, since prterun -n1 sets PMIX_RANK too and there is no size variable to separate the two. Dropping PMIX_SIZE and saying in the comment that a PMIx-direct launch is not detected would at least be accurate about the boundary.
There was a problem hiding this comment.
That is a better piece of work than the PR it is reviewing. I took the openpmix and PRRTE finding on your evidence rather than re-running the greps — the PMIX_REGISTER_TYPE("PMIX_SIZE", PMIX_SIZE, ...) explanation is the kind of thing that is obviously right once someone points at it, and I had put the name in for symmetry with the rank set without checking that it exists.
The part I want to push back on slightly is the conclusion. Dropping PMIX_SIZE and documenting the boundary is accurate, but it leaves the PMIx-direct case doing the thing this PR is meant to prevent: prterun -n4 with no mpi4py silently becoming four world-size-1 runs. You are right that the environment cannot separate prterun -n4 from prterun -n1. But the two failures are not symmetric — silently splitting a four-rank job is unrecoverable and looks like a slow run, while refusing a one-rank job costs an error message that names mpi4py and says what to set.
So 90820bf reads sizes when a launcher reports one, and refuses when a launcher is present and reports none:
no launcher trains
srun -n1 SLURM_NTASKS=1 trains
srun -n4 SLURM_NTASKS=4 ImportError naming the size
mpirun -n1 OMPI_..._SIZE=1 trains
mpirun -n2 OMPI_..._SIZE=2 ImportError naming the size
prterun PMIX_RANK, no size ImportError naming the missing size
PMIX_SIZE is gone; PMIX_RANK is back, but only in the rank list that answers "is a launcher present", never as a stand-in for the size. The message for that case says explicitly that a PMIx-direct launch reports no size, so someone hitting it on prterun -n1 knows why and what to do.
If you think refusing prterun -n1 is the wrong side of that trade I will switch it to your version — it is a judgement about which failure a user would rather have, and you have looked at this launcher landscape more carefully than I have.
One other thing your comment caused, indirectly. Building the three-way split, I shadowed init_distributed's own world_size parameter with a local of the same name and handed None to the backend, which broke the plain no-launcher case entirely. All 20 tests still passed, because every one of them stopped at the helpers or at a path where the shadowed value happened to be a valid int. I found it running the six-case matrix by hand. There is now a test that goes through init_distributed with a bare environment and fails on that version with world_size must be an integer. None.
A rank variable says a launcher is present, not that the world is bigger than one. srun -n1 sets SLURM_PROCID for a single-task step, which is one process on one device and is exactly the case this PR exists to serve, so keying on the rank sent it to the error instead of the fallback. Read the size variables the same launchers export alongside their rank ones - OMPI_COMM_WORLD_SIZE, PMI_SIZE, PMIX_SIZE, MV2_COMM_WORLD_SIZE, SLURM_NTASKS - and only refuse when one of them is above 1. srun -n1 trains srun -n4 ImportError naming mpi4py mpirun -n2 ImportError naming mpi4py no launcher trains Caught by ebarkhordar in review. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
2306b54 to
114ba85
Compare
PMIX_SIZE is not an environment variable - it is a size_t type name registered with PMIX_REGISTER_TYPE - so the entry was dead, and dropping it lost the coverage PMIX_RANK had been giving. A PMIx-direct launch, prterun or prun, sets PMIX_RANK and PMIX_NAMESPACE and no size at all, so reading only sizes turned a multi-rank job with no mpi4py into every rank coming up as world size 1. prterun -n1 and prterun -n4 are indistinguishable from the environment, so this refuses both rather than guessing: falling back silently splits a four-rank job into four world-size-1 runs, while refusing costs the one-rank case an error that names mpi4py and is recoverable. no launcher trains srun -n1 SLURM_NTASKS=1 trains srun -n4 SLURM_NTASKS=4 ImportError naming the size mpirun -n1 OMPI_..._SIZE=1 trains mpirun -n2 OMPI_..._SIZE=2 ImportError naming the size prterun PMIX_RANK, no size ImportError naming the missing size Also adds an end-to-end test for the bare environment. The helper tests all passed against a version of this that shadowed init_distributed's own world_size parameter and handed None to the backend, because none of them reached the backend; the new test does, and fails on that version with 'world_size must be an integer. None'. PMIX_SIZE identified as a dead entry by ebarkhordar, who enumerated the setenv literals in openpmix and PRRTE. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
The red
The open question from earlier still stands, and it is the only thing I think needs a decision: whether refusing |
python train.pyon a machine with one accelerator and no launcher ends beforedeepspeed.initializereturns:Reproduced on two machines that have nothing else in common — an Apple M5 (MPS, ZeRO 0/1/2/3) and an H20 (CUDA, ZeRO-1). Neither had mpi4py installed, which is the default state of a machine that was never going to run an MPI job.
Why
init_distributedfills in the distributed environment when a launcher did not:and
mpi_discoveryopens withfrom mpi4py import MPI. It is the only route out of that branch, so a machine with no launcher and no MPI has to have mpi4py installed to get past it — and the error it gets instead says nothing about what to do.That case is not exotic. It is DeepSpeed on a workstation or a laptop, which the MPS accelerator added in #8293 exists to serve, and it is also the first thing anyone hits trying DeepSpeed on a single GPU without
deepspeed/torchrun.comm/utils.pyalready has the answer to what that situation means:No launcher is a single-process job. That reading just never reached the environment the backend is initialized from.
The change
single_process_discoverysetsRANK=0,WORLD_SIZE=1,LOCAL_RANK=0,MASTER_ADDR=127.0.0.1andMASTER_PORT, withsetdefaultso a partially populated environment is left as the caller set it.mpi_discoverystill runs first and unchanged, and only anImportErrorout of it is caught — so nothing changes for anyone who has mpi4py, including an unrecognized MPI launcher. If that ImportError fires while one ofOMPI_COMM_WORLD_RANK,PMI_RANK,PMIX_RANK,MV2_COMM_WORLD_RANKorSLURM_PROCIDis set, the run stops with a message naming mpi4py rather than silently becoming one rank of a many-rank job — which is the one outcome worse than today's crash.Before and after
Apple M5, MPS, no launcher, no environment variables:
ModuleNotFoundError: No module named 'mpi4py'losses=[0.05809, 0.04466, 0.03354]The losses are identical to the same script run with
RANK/WORLD_SIZE/... set by hand, which is the path this PR does not touch.H20, CUDA, no launcher:
Tests
tests/unit/comm/test_single_process_discovery.py, 9 cases, CPU-only and no distributed context needed: an environment with no MPI variables is not an MPI job; each of the five launcher rank variables marks one; the fallback fills all five variables and leaves any the caller already set; and an MPI job whose mpi4py is missing raises with a message naming mpi4py and does not fill the environment.On master the file cannot be imported, so it fails there for the trivial reason rather than the interesting one. The evidence that matters is the before/after table above, which is a real
deepspeed.initializeon two accelerators.tests/unit/comm/passes on the branch. yapf clean; flake8 reports 51 pre-existingF824 global ... is unusedwarnings oncomm.py, identical in count on master.