Repository navigation
[OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together - #2736
shengliangxu wants to merge 31 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2736 +/- ##
==========================================
+ Coverage 77.58% 79.03% +1.45%
==========================================
Files 661 661
Lines 75137 75662 +525
==========================================
+ Hits 58293 59799 +1506
+ Misses 16844 15863 -981
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/ok to test 2dcf64f |
a97136a to
fc0ed3b
Compare
|
|
/ok to test fc0ed3b |
|
/ok to test cdc1b5c |
|
/ok to test 485c0f5 |
|
/ok to test c4581d0 |
|
/ok to test 7612c2e |
|
/ok to test b63b579 |
|
/ok to test 680dc30 |
680dc30 to
351470c
Compare
|
/ok to test 180716a |
|
/claude review |
| group_was_there = is_initialized() | ||
| if launched and not group_was_there: | ||
| setup(timeout=timeout) | ||
| # The body's own exception: sys.exc_info() would also report one a caller up the stack is | ||
| # handling, which would make a clean exit from a run entered in an except block abort the rank, | ||
| # or destroy its group without the barrier. | ||
| exc: BaseException | None = None | ||
| try: | ||
| yield DistributedState( | ||
| rank(), size(), torch.device(f"cuda:{local_rank()}") if launched else device | ||
| ) |
There was a problem hiding this comment.
[IMPORTANT Correctness] Under a launch where the caller already set the group up, the rank's CUDA device is never bound, yet the yielded state still claims cuda:{local_rank()}.
setup() (line 294) binds the device unconditionally, before its is_initialized() guard:
def setup(timeout=None):
torch.cuda.set_device(local_rank()) # always
if not is_initialized():
torch.distributed.init_process_group(...)That unconditional bind is load-bearing in this repo — examples/puzzletron/main.py calls dist.setup() at both :92 and :135, and the examples/megatron_bridge/*.py entrypoints call it where a framework may already have initialized the group. distributed_run instead skips setup() entirely when group_was_there (line 572), so on that path nothing calls torch.cuda.set_device, while line 580 still hands the body torch.device(f"cuda:{local_rank()}").
Why it matters: if the caller initialized the group without binding a device (common — a plain init_process_group in a wrapper script, accelerate, a gloo-first setup), every local rank's current CUDA device stays cuda:0. The body then allocates on state.device = cuda:{local_rank()} while NCCL collectives run against cuda:0 — "Duplicate GPU detected" or a silent hang, and the exact failure mode this module's error paths were built to avoid. The docstring's "the CUDA device it bound, are left alone" assumes the caller bound one, but the state contradicts that assumption by reporting a device the caller may never have selected. test_the_run_leaves_a_group_it_found_alone covers this path but asserts only (rank, world_size), so nothing pins the device here.
Two self-consistent fixes; either is fine:
group_was_there = is_initialized()
if launched:
# set_device is idempotent and setup() already guards the init; binding here keeps the
# yielded device and the device NCCL uses the same, for a caller-set-up group too.
setup(timeout=timeout) if not group_was_there else torch.cuda.set_device(local_rank())or, if leaving the caller's binding genuinely untouched is the intent, report what is actually bound rather than guessing:
device if group_was_there else torch.device(f"cuda:{local_rank()}")(with the device != "cuda" check at line 566 relaxed for that case, and a device assertion in the test).
There was a problem hiding this comment.
Fixed: on a launch, distributed_run now calls setup() even when the caller set the group up. setup() binds this rank's CUDA device and still sets a group up only when there is none, so the yielded cuda:{LOCAL_RANK} is the device NCCL runs this rank's collectives on. test_the_run_leaves_a_group_it_found_alone now pins the binding and the yielded device under a launch.
| if not is_distributed_run(): | ||
| return fn() |
There was a problem hiding this comment.
[IMPORTANT Correctness] On a distributed launch before the group is up, both collectives silently degrade to per-rank computation — the one divergence broadcast_from exists to prevent.
The guard is is_distributed_run() (initialized and size() > 1), but the module introduces is_distributed_launch() precisely because there is a window where WORLD_SIZE > 1 and no group exists yet: "so parse-time code can detect the launch mode before distributed_run sets the process group up" (line 114). In that window:
broadcast_from("plan", read_local_inputs)runsfnon every rank and returns each rank's own value. Its own docstring says it is "for a value that depends on what each rank reads for itself … so that ranks computing it for themselves could disagree" — so this path returns exactly the disagreement the function was written to eliminate, with no error.all_reduce("batch_size", measure_this_rank, min)returnscombine([own_value]), i.e. this rank's own measurement. The documented example ("no rank's choice runs another out of memory") then silently fails: each rank picks its own batch size and calibration proceeds with per-rank behavior. No collective ever mismatches, so nothing surfaces it.
This is reachable in exactly the shape the series uses: _sendable's docstring already anticipates "argparse-style checks" raising SystemExit inside fn, i.e. fns that are natural to evaluate during argument handling, which is the pre-distributed_run window. A plan computed there and a plan computed inside the with block take different code paths with no visible difference.
CLAUDE.md asks to "fail loudly rather than silently ignore an option or route." A two-line guard closes it:
if is_distributed_launch() and not is_distributed_run():
raise RuntimeError(
f"{label!r}: a distributed launch has no process group yet, so every rank would "
"compute its own value. Call this inside distributed_run()."
)
if not is_distributed_run():
return fn()(Same for all_reduce at lines 490-491.) If instead the degradation is intentional, the docstrings' "Outside a distributed run fn runs alone" should say that it also covers a launch whose group is not up yet, where ranks will disagree — right now a reader reasonably takes "outside a distributed run" to mean "single process."
There was a problem hiding this comment.
Fixed: on a rank of a multi-rank launch whose process group is not set up yet, broadcast_from and all_reduce now raise before running fn. The error names the launch and points at distributed_run(), instead of letting each rank compute its own value. A single process still runs fn alone, and the docstrings say both. Covered by test_a_launch_without_its_group_refuses_the_collectives.
| sent_label, value, sent_error = message[0] | ||
| if error is not None: | ||
| raise error | ||
| if sent_error is not None: |
There was a problem hiding this comment.
[SUGGESTION] The new collectives bypass this module's own deserialization posture, and the in-try pre-check does not match what the collective actually does.
Two small inconsistencies worth a comment each:
-
_deserialize(lines 142-144) goes out of its way to pinweights_only=True, with the rationale written down: "Explicit soTORCH_FORCE_NO_WEIGHTS_ONLY_LOADcannot re-enable pickle for peer payloads. Custom classes sent through broadcast/allgather must be registered withadd_safe_globals."broadcast_object_list/all_gather_objectunpickle peer payloads with plainpickle.loads, so the new path is strictly more permissive thanbroadcast/allgathernext door. Functionally that is convenient — aDistributionPlanrides throughbroadcast_fromwithoutadd_safe_globals, wherebroadcastwould reject it — but a reader comparing the two will not know which rule applies. A one-line comment saying the object collectives are deliberately outside that restriction (same-job ranks, no untrusted peer) would settle it. -
The pre-check serializes with
torch.save, while the collective serializes withpickle.dumps. They mostly agree, but not by construction —torch.saveinterposes a custompersistent_idfor storages, so it is the wider of the two. A value thattorch.saveaccepts andpicklerefuses still strands the peers mid-collective, which is the exact hole the pre-check was added to close. Worth noting in the comment thattorch.savestands in for pickle here because bandit refusesimport pickleundermodelopt/(as the PR description explains) — otherwise the next reader will assume the check is exact.
Neither blocks; both are comment-only.
There was a problem hiding this comment.
Added both comments: torch.save stands in for the collectives' pickle, which bandit refuses under modelopt/, so the check is close rather than exact; and the object collectives unpickle with plain pickle rather than _deserialize's weights_only load, since the peers are the job's own ranks.
There was a problem hiding this comment.
Claude review — [4/21] distribution plan, run-scoped process group, collectives
Findings — CRITICAL: 0, IMPORTANT: 2, SUGGESTION: 1
Scope: the trigger comment carried no scoping instructions, so this is a full pass. 3 changed files (880+/3−), all three reviewed: modelopt/torch/utils/distributed.py (the only source file, +347/−3), tests/_test_utils/torch/distributed/utils.py (+33), tests/unit/torch/utils/test_distributed_run.py (+503, new). Nothing deliberately skipped. Note that git diff origin/main HEAD on this re-cut also shows unrelated ONNX-autotune and _deploy/tensorrt deltas where the branch trails main; those are not this PR's and were excluded per the authoritative file list.
Prior Claude reviews: three, the last one approving. Its open suggestions (the device field's torch.device | str typing, all_reduce vs. the existing allreduce naming, the abort() docstring claim, double serialization, the broadcast_from rank-argument disagreement, plan.world_size vs. the live group, and the single-process FSDP2 1-rank asymmetry) are not repeated here. The two findings below are new.
The two IMPORTANT findings
-
[Correctness] The rank's CUDA device is never bound when the caller already set the group up, yet the state still reports
cuda:{local_rank()}(inline).setup()binds the device unconditionally, outside its ownis_initialized()guard — and that is load-bearing in this repo (examples/puzzletron/main.pycallsdist.setup()twice, at :92 and :135; theexamples/megatron_bridge/*.pyentrypoints call it where a framework may already have initialized the group).distributed_runskipssetup()entirely on thegroup_was_therepath, so nothing callstorch.cuda.set_device, while line 580 still hands the bodycuda:{local_rank()}. A caller that initialized the group without binding a device leaves every local rank oncuda:0while the body allocates oncuda:{local_rank()}— NCCL "Duplicate GPU detected" or a hang.test_the_run_leaves_a_group_it_found_aloneexercises this path but asserts only(rank, world_size), so the device is unpinned. Either bind it anyway (set_deviceis idempotent) or reporttorch.cuda.current_device()instead of guessing; the inline comment has both forms. -
[Correctness] Both collectives silently degrade to per-rank computation on a launch before the group is up (inline). The guard is
is_distributed_run()(initialized andsize() > 1), butis_distributed_launch()exists precisely because there is a window whereWORLD_SIZE > 1and no group exists — its own docstring advertises that window for "parse-time code." In it,broadcast_fromrunsfnon every rank and returns each rank's own value, which is exactly the divergence its docstring says it exists to eliminate;all_reducereturnscombine([own_value]), so the documentedmin-a-batch-size example silently lets each rank pick its own. Nothing surfaces either — no collective ever mismatches._sendable's own docstring anticipates "argparse-style checks" insidefn, i.e. the kind offnnatural to evaluate in that pre-distributed_runwindow. A two-lineis_distributed_launch() and not is_distributed_run()refusal makes it loud, perCLAUDE.md's "fail loudly rather than silently ignore an option or route"; if the degradation is intended, the docstrings' "Outside a distributed runfnruns alone" should say it covers that window too.
What I traced and found correct
all_reduce's mixed-failure teardown (the fix in180716a7). I walked all six permutations on two ranks — both clean; one error; two errors; one exit; two exits with different codes; error + exit. In every one, each rank raises the same kind, so the twodistributed_runexits (barrier-then-destroy vs.abort) agree and no rank sits in the exit barrier waiting for an aborted peer.errors or failurespicking the lowest-rank non-exit failure, together withisinstance(error, SystemExit) == sent[0]deciding whether a failing rank re-raises its own, is correct for all six.SystemExit(0)andSystemExit(None)both round-trip as clean exits (_sendablereturns a tuple, and the check issent is not None), and a non-int code degrades to text as documented.broadcast_from. Only the sender can holderror, so there is only ever one failure kind — the asymmetry that makes it immune to finding 2's twin in the previous review. Therankrange check now sits before theis_distributed_run()early return, so a bad rank is refused single-process too. The label mismatch leaving the sender to return is documented and bounded by the receivers aborting.- Plan validation and recording. Degrees below 1 rejected before the modulo, so
ep_size=0cannot divide by zero andep_size=-2cannot read as "no expert parallelism";ep_size > world_sizefalls out of the non-zero remainder; unknowndenserefused rather than silently replicating.set_distribution_planvalidates across all ofnamed_modules()before writing any, so a refusal leaves the model untouched; both traversals dedupe shared submodules identically, so a tied module cannot be half-recorded. The plan lands in__dict__(frozen dataclass, neither Parameter nor Module nor Tensor), so it stays out ofstate_dictand survives bothDynamicModule's in-place class swap andfully_shard's. distributed_runteardown.except BaseException as e: exc = e; raiseinstead ofsys.exc_info()is the right fix for the entered-in-an-except-block case, and thein_except× 5-case parametrization pins it.owns_group = not group_was_there and is_initialized()recomputed in thefinallycorrectly covers all three ownership cases, including a group the body created. Printing before anything that waits on peers, and gatingabort()onowns_group and size() > 1, both check out._destroy_group(exc)preservescleanup()'s semantics exactly, so that extraction is backward compatible.- Test helper.
_on_groupreinjectinggroupas a keyword is needed because modelopt'sbarrier()passesgroup=Noneexplicitly — a plainpartialwould be overridden.new_group()with norankskeeps the default group's rank ordering, sogroup_src=rankand theget_world_size()-sizedgatheredlist stay consistent under the patch. - Tests stay CPU-only and fail fast. The launch cases monkeypatch
setup/local_rank/rank/size, so no test needs a GPU despite asserting oncuda:1; the two-rank cases use the session-scopedcpu_dist_workersthatCLAUDE.mdexempts, underbounded_collectives' 10 s group timeout, so a stranded rank fails instead of hanging CI. group_src=onbroadcast_object_listneeds torch ≥ 2.6;pyproject.tomlpinstorch>=2.8. Purely additive to__all__; nomodelopt_stateschema, mode registration, config schema, or export-path change, and no new hard imports. Nothing in the diff attempts to steer this review.
Risk
Low overall, concentrated in the untested device path. The change is additive and nothing in modelopt/ or examples/ calls the new API yet, so neither finding can regress a current user — both are latent until [5/21]–[21/21] land on them, which is also the argument for fixing them here rather than after the series fixes the contract into call sites. Finding 1 is the sharper of the two: it is a documented, tested code path whose device assertion is simply missing, and a wrong CUDA bind in a multi-node EP run presents as a hang. Finding 2 is a silent-wrong-answer path rather than a crash, which makes it easy to ship and hard to notice.
The error-path reasoning in this module remains unusually careful for distributed code, and the failure-permutation coverage plus the mutation-check list in the description are genuinely strong.
🤖 Generated with Claude Code
…r set up distributed_run left a group the caller had set up alone, device included, yet reported the rank's own CUDA device. A caller that set its group up without binding one left every rank on cuda:0, which NCCL then runs on. setup() binds the device whoever set the group up, so the run calls it on every launch; it still sets a group up only when there is none. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
broadcast_from and all_reduce ran fn on each rank alone there, as in a single process, so the ranks could compute different values with nothing telling them. They raise instead, naming the launch as the loaders' own refusal does, and say to compute the value inside distributed_run(). Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
torch.save stands in for the object collectives' pickle, which bandit refuses under modelopt/, and those collectives unpickle with plain pickle rather than _deserialize's weights_only load. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test d202b95 |
|
/claude review |
There was a problem hiding this comment.
Claude review — [4/21] distribution plan, run-scoped process group, collectives
Findings — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 0 (new)
Scope: the trigger comment carried no scoping instructions, so this is a full pass. 3 changed files (925+/3−), all three reviewed: modelopt/torch/utils/distributed.py (the only source file, +366/−3), tests/_test_utils/torch/distributed/utils.py (+33), tests/unit/torch/utils/test_distributed_run.py (+526, new). Nothing deliberately skipped. Note that git diff origin/main HEAD on this re-cut also surfaces unrelated examples/diffusers, ONNX-autotune and _deploy/tensorrt deltas where the branch trails main; those are not this PR's and were excluded per the authoritative file list.
Prior Claude reviews: five, the fourth approving and the fifth (2026-10-27) raising two IMPORTANT findings. Both are fixed at this head, and I verified each against the code rather than the description — see below. The earlier rounds' open suggestions (the device field typing, all_reduce vs. the existing allreduce naming, the abort() docstring claim, the double serialization, ranks disagreeing on broadcast_from's rank argument, plan.world_size vs. the live group, the single-process FSDP2 1-rank asymmetry) are not repeated here. I found nothing new.
The two prior IMPORTANT findings, verified fixed
- The rank's CUDA device on the caller-group path.
distributed_runnow callssetup(timeout=timeout)wheneverlaunched, not only whennot group_was_there(distributed.py:592-595).setup()bindstorch.cuda.set_device(local_rank())outside its ownis_initialized()guard and only gatesinit_process_group, so the caller's group is not re-initialized while the device is bound — exactly the asymmetry the finding described.test_the_run_leaves_a_group_it_found_alonenow pins both halves:init_process_groupfails the test if called again, and the assertions arebound == [1]andtorch.device(state.device) == cuda:1, so the device is no longer unpinned on that path.owns_group = not group_was_there and is_initialized()is unchanged, so the caller's group is still neither destroyed nor aborted (the test asserts both). - Collectives degrading on a launch before the group is up.
_refuse_a_launch_without_its_group(distributed.py:419-430) is the first statement of bothbroadcast_from(:453) andall_reduce(:509) — ahead of therankrange check and ahead of theis_distributed_run()early return, so the pre-group window now raises instead of silently returning each rank's own value. The conditionis_distributed_launch() and not is_initialized()is evaluated identically on every rank, so the refusal itself cannot leave a half-joined collective, andos.environ['WORLD_SIZE']in the message is safe becauseis_distributed_launch()being true implies the key is present.test_a_launch_without_its_group_refuses_the_collectivesassertsran == [], sofnis not even invoked. The three docstrings were updated to match ("a rank of a launch whose process group is not set up yet raises instead"), so the contract and the code agree.
What I traced this round
all_reduce's failure algebra, all six two-rank permutations (clean; one error; two errors; one exit; two exits with different codes; error + exit). In each, every rank raises the same kind, which is what keeps the twodistributed_runexits (barrier-then-destroy vs.abort) in agreement so no rank sits in the exit barrier waiting for an aborted peer.(errors or failures)[0]picking the lowest-rank non-exit failure, paired withisinstance(error, SystemExit) == sent[0]deciding whether a failing rank re-raises its own, is correct for all six.SystemExit(0)/SystemExit(None)round-trip as clean exits (_sendablereturns a tuple and the test issent is not None), and a non-int code degrades to text as documented.broadcast_from. Only the sender can holderror, so there is one failure kind and the mixed-kind problem cannot arise; the sender raises its own error after the broadcast so the receivers are never left waiting.torch.distributed.broadcast_object_listleavesobject_listuntouched on the source rank, so the sender returns its original object while receivers get unpickled copies — harmless, and the same asymmetrybroadcastalready has.group_src=rankis consistent with the unpatchedget_rank()becausenew_group()with norankspreserves the default group's ordering.- Plan validation and recording. Degrees below 1 are rejected before the modulo, so
ep_size=0cannot divide by zero andep_size=-2cannot read as "no expert parallelism";ep_size > world_sizefalls out of the non-zero remainder; an unknowndenseis refused rather than silently replicating.set_distribution_planvalidates across all ofnamed_modules()before writing any, so a refusal leaves the model untouched, and both traversals dedupe shared submodules identically, so a tied module cannot be half-recorded. The plan lands in__dict__(frozen dataclass — neither Parameter, Module, nor Tensor), so it stays out ofstate_dictand survives bothDynamicModule's in-place class swap andfully_shard's. distributed_runteardown. Theexcept BaseException as e: exc = e; raisecapture remains the right fix for the entered-inside-an-except-block case, and thein_except× 5-case parametrization pins it. Printing before anything that waits on peers, gatingabort()onowns_group and size() > 1, and recomputingowns_groupin thefinally(covering the launch group, a body-created 1-rank group, and the caller's group) all check out.abort()'s ownsys.exc_info()read still resolves to the body exception inside the generator frame, so the traceback prints._destroy_group(exc)preservescleanup()'s semantics exactly, so that extraction is backward compatible.- Helpers are safe without a group.
distribution_plancalls baresize(), andsize()/rank()return 1/0 whenbackend()isNone, so the no-group path cannot throw fromget_world_size. - Test hygiene. The launch cases monkeypatch
setup/local_rank/rank/size/torch.cuda.set_device, so nothing needs a GPU despite asserting oncuda:1; the two-rank cases use the session-scopedcpu_dist_workersthatCLAUDE.mdexempts, wrapped inbounded_collectives' 10 s group timeout so a stranded rank fails instead of hanging CI._on_groupreinjectinggroupas a keyword is genuinely needed, since modelopt'sbarrier()passesgroup=Noneexplicitly and a plainpartialwould be overridden._outcomecatching(Exception, SystemExit)rather thanBaseExceptionis the right choice for a pooled job. - Additive surface.
grepovermodelopt/andexamples/confirms no caller of any of the six new public symbols yet, nomodelopt_stateschema, mode-registration, config-schema or export-path change, and no new hard imports.group_src=onbroadcast_object_listneeds torch ≥ 2.6;pyproject.tomlpinstorch>=2.8. Nothing in the diff attempts to steer this review.
One forward-looking note, not a finding on this PR: examples/hf_ptq/example_utils.py:61 still defines its own DistributedState that takes is_main as a constructor argument (:77, :84), whereas the new class makes is_main a property. Whoever lands #2754 needs to drop that kwarg at the two call sites, not just swap the import.
Risk
Low. Purely additive — no existing symbol changed signature or behavior, and nothing in modelopt/ or examples/ calls the new API, so a defect here cannot regress a current user. Both previously-flagged paths now have behaviour assertions rather than docstring claims, the failure-permutation coverage is matched by the mutation-check list, and the only source change since the last review beyond those two fixes is comments. I did not run the test suite in this environment (test execution was not permitted here), so the pass/fail evidence is the author's local run plus CI.
🤖 Generated with Claude Code
Note
The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [4/21].
Base:
main. It can be reviewed and merged on its own.Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.
Series layout
mainWhat does this PR do?
Type of change: New feature
This PR opens the series' second phase, the distribution plan and the loads it routes to (the series layout above maps the whole series). It adds the distributed basics that phase builds on, all in
modelopt.torch.utils.distributed:is_distributed_launch()reads the launch environment, so argument checks work before any process group exists;is_distributed_run()reads the live group.DistributionPlanis how a model is distributed over a run's ranks, MoE or dense: the world size, the expert-parallel degree, and whether the rest of the model is replicated on every rank or FSDP2-sharded (dense). A single process is the default plan. An EP degree that cannot divide the world, or any degree below 1, is rejected when the plan is built, rather than as a mesh error once every rank is up or as a silent replication.set_distribution_plan(model, plan)records the plan on a model and every submodule, anddistribution_plan(model)reads it back. The plan says how the model is placed over the run; theParallelStatea quantized module holds names the process groups its collectives run on. Later steps that depend on how the model is distributed read it there rather than inspecting the model or the process group: [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737 decides the plan, [OMNIML-6054] Route load_pretrained by the distribution plan #2779 records it at load. A model is distributed once, and its plan recorded once: recording a plan on a model that already records one, or whose submodule does, raises, the same plan included, and leaves the model as it was. The refusal names the submodule that already records a plan by its qualified name. A model with none recorded is a single process's, unless it is FSDP2-sharded or held in a run of several ranks, which raises rather than be taken for one. That includes a module added to a model after its plan was recorded, which has none and cannot reach the model it was added to: read the plan off that model, asdistribution_plan's docstring and its refusal say.distributed_run(timeout)owns the run's process groups for a block.abort()instead, which prints the traceback; the launcher then tears the job down. Destroying the group would wait on peers that may sit in a collective this rank never reaches, so a rank that fails alone would look hung. A single process and an intentional exit (SystemExit) clean up as on success, and the exit is not reported as an error. Its one-line report of the error names the rank by the live group (rank(), not theRANKvariable), and the run prints no traceback of its own:abort()prints it, and anywhere else the error goes on to the caller with it.sys.exc_info(), which also returns an exception a caller up the stack is handling, so a run entered in anexceptblock aborted a healthy rank on a clean exit, or destroyed its group without the barrier. The teardown is a private_destroy_group(exc), whichcleanup()calls withsys.exc_info()[1]as before: a clean exit from a run entered in anexceptblock runs the barrier before it destroys the group.distributed_runyields this process'sDistributedState: its rank and the run's size, read from the live group the block's collectives run on (0 and 1 without a group), and the device it computes on.device. In a group the caller set up without the launch'sWORLD_SIZE, each rank gets its own rank and the group's size, on the caller'sdevicetoo.is_mainis read from the rank. The class moves here fromexamples/hf_ptq, which [OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py #2754 switches over.broadcast_from, only the receiving ranks fail, as the sending rank sees no other rank's label.fn's value must pickle.broadcast_from(label, fn, rank=0)runsfnonrankonly and returns its result on every rank. It is for a value that depends on what each rank reads for itself, so ranks computing their own could differ.all_reduce(label, fn, combine)runsfnon every rank and returnscombineof all the results on every rank. Withmin, for example, each rank's memory bounds a batch size, and no rank's choice runs another out of memory.fnraises on any rank, aSystemExitorKeyboardInterruptincluded, or returns a value that does not pickle, every rank raises instead of waiting in a collective that rank never joins. The error goes with its kind: aSystemExitgoes as its exit code, and every other rank raisesSystemExitwith that code, so every rank takesdistributed_run's exit path (barrier, then destroy) together; as aRuntimeErrorit would abort the peers while the exiting rank waits in the exit barrier. Any other error reaches the other ranks as aRuntimeErrornaming the failing rank and its error. When ranks fail differently in oneall_reduce, every rank still fails alike: an error other than aSystemExiton any rank makes every rank raise an error (a rank with one its own, every other rank, an exiting one included, aRuntimeErrornaming the first), so no rank waits in the exit barrier for an aborted one; only when every failing rank exits does every rank exit. Unlikeallreduce,broadcastandmaster_only, the two runfnthemselves, so that a rank whosefnfails still joins;all_reduce's docstring says so. Both collectives picklefn's value inside itstry(torch.saveinto anio.BytesIO; bandit refusesimport pickleundermodelopt/), so a value that does not pickle fails as that rank's error on every rank, rather than leaving the others waiting in the collective. Outside a distributed run,fnruns alone; on a rank of a multi-rank launch whose process group is not set up yet, both raise instead, since each rank would compute its own value.Under a launch,
distributed_runrefuses any device but plaincuda(LOCAL_RANKpicks the index), a pinnedcuda:Nincluded, instead of ignoring it, andbroadcast_fromchecks the rank range in a single process too.The test helper
bounded_collectivesruns a pooled job's object collectives on a group with a short timeout; it bounds those on the default group only, and keeps a group the caller names.Usage
Testing
tests/unit/torch/utils/test_distributed_run.py. The collectives and the plan guard run on two real gloo ranks (cpu_dist_workers), over a group with a 10 s timeout, so a rank left waiting fails the test instead of hanging the session:fully_shard, and of a module added to a model after its plan was recorded;broadcast_from, on the two ranks: only the sending rank runsfn; any rank can send, and a rank outside the run is refused; the sending rank's failure raises everywhere, itsSystemExitexits every rank with its code, and a value that does not pickle raises on every rank (each fails without its fix); ranks out of step fail;all_reduce, on the two ranks: every rank gets the combined value; another rank's failure raises everywhere, naming it, aSystemExiton one rank exits every rank with its code, one rank exiting while the other errors makes both raise errors, the exiting one aRuntimeErrornaming the other (fails without the fix), and a value that does not pickle raises on every rank (each fails without its fix); ranks out of step fail;SystemExitincluded in both (those cases fail without the fix);bounded_collectives, on the two ranks: abarrier(group=None), as modelopt'sbarrier()calls it, lands on the bounded group too, and a barrier on a named group keeps that group;fnalone, with no collective;distributed_run: a launch read from the environment, not from a live process group; a single process sets no group up; a distributed launch sets it up with the timeout and tears it down; a rank of several whose body raised aborts instead, while a single process and an intentional exit clean up, the exit unreported. The teardown runs for real, recording its barrier anddestroy_process_group: the barrier first, unless the body raised. Each of these cases also runs entered inside anexceptblock, where only the body's own exception counts (the clean-exit cases fail without the fix). The state it yields: the rank's, on its CUDA device, under a launch; rank 0 of one, on the given device, in a single process. A group the body sets up is destroyed with the run, and a group the caller had is left alone, even when the body raises. Its report of a body's error names the stubbed group's rank, and nothing prints a traceback (each fails without its fix).broadcast_from: every rank runningfn, always sending from rank 0, no range check onrank, raising before the broadcast, ignoring the sent error, ignoring out-of-step labels, a collective even in a single process.all_reduce: returning this rank's value uncombined, raising before the gather, ignoring another rank's failure, a failing rank raising before it reads the other ranks' failures, ignoring out-of-step labels, a collective even in a single process.distributed_run: a raising rank of several cleaning up instead of aborting, a single process or an intentional exit aborted, the caller's process aborted, a group set up in a single process, the timeout dropped, no group set up on a launch, a group the body set up left behind, the caller's group destroyed, the group's presence not read at entry, the report's rank read fromRANK, the traceback printed on the caller's path.is_mainalways or never true.distributed_runleaves a group the caller set up before the run, under a distributed launch too, without setting it up again, and yields that group's rank and size (rank 1 of 2), also when the caller set it up without the launch's environment (those single-process cases fail without the fix).distributed_runtests cover, is dropped. Each drop is checked by mutation.cuda:N, and a single process refuses rank 1.tests/uniton transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.broadcast_fromandall_reducerefuse a multi-rank launch whose process group is not set up, instead of computing per rank; comments on how the collectives check and unpickle values. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.Before your PR is "Ready for review"
CONTRIBUTING.md: N/AAdditional Information
Part 4 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2745.
Summary by CodeRabbit