[None][fix] give the MPI worker-identity gate a budget above its measured cost - #17115
Draft
JunyiXu-nv wants to merge 3 commits into
Draft
[None][fix] give the MPI worker-identity gate a budget above its measured cost#17115JunyiXu-nv wants to merge 3 commits into
JunyiXu-nv wants to merge 3 commits into
Conversation
…ured cost The `wait_shutdown=True` worker-identity gate bounds spawn + `import tensorrt_llm` + the identity barrier with a single deadline, because `MPIPoolExecutor` is built lazily and the gate's first `submit()` triggers the whole bootstrap. Sizing one deadline against a bootstrap cost measured in the low hundreds of seconds is the only way to stop it failing healthy-but-slow pools -- and it leaves the guard unable to tell a slow bootstrap from a dead pool without burning that whole budget first. Warm, then measure. Split the gate into two phases: - bootstrap: `n_workers` collective-free probes under the existing generous, env-tunable budget, waited on in poll slices. mpi4py raises a failed `MPI_Comm_spawn` inside its manager thread, so the futures simply never resolve; that thread's liveness is the one positive signal separating a dead pool from a slow one, and the wait gives up on it rather than on the clock. - barrier: the existing identity barrier under a tight, fixed 60s deadline that is now sized against what it actually measures. Measured on a 20-core x86 host with a worker module whose import sleeps, a verbatim copy of each gate, and the same MPI runtime: | scenario | before | after | | -------------------------------- | -------- | ---------------------- | | healthy, 30s worker import | pass | pass (31.4s) | | MPI_ERR_SPAWN (unspawnable pool) | 300.0s | 1.0s | | worker wedged before the barrier | 300.0s | 63.4s | | ...workers SIGKILLed by teardown | none | all 4 | The last row is a second defect this fixes. The barrier is collective, so a stalled one returns nothing, `partial_identities` is empty, and `_teardown_unidentified_pool`'s SIGKILL loop is a no-op that leaks the workers until job end. The bootstrap probes are collective-free, so they come back even when the barrier does not -- giving teardown real PIDs to reap. Every failure of the gate now carries one greppable marker, `[mpi-identity-gate]`, and names its phase, so "never bootstrapped" and "bootstrapped but the barrier stalled" stop being the same log line. The marker lives in the exception message because that is the channel proven to reach CI logs for this path. The `wait_shutdown=False` path -- the production default, used by `llmapi/llm.py` -- is unchanged: `_collect_worker_identities` has exactly one call site, guarded by `if wait_shutdown:`, and no other method body in `mpi_session.py` is touched. Origin: the gate arrived in NVIDIA#15908, which measured worker spawn + import at "~50s" and the slowest legitimate pool build at "~117s (busy node)" in the same changeset. The deadline it shipped with was below those numbers; NVIDIA#16971 raised it. This change stops the two costs sharing one number at all. The test-session prefetcher derives its outer wait from the new `identity_gate_budget()` (bootstrap + barrier) so it cannot abandon a build the library still considers healthy. Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…ll probes
Two defects found in review of the two-phase identity gate.
1. `except RuntimeError: raise` swallowed the teardown. Both
`mpi4py.MPI.Exception` and `concurrent.futures.BrokenExecutor` inherit from
`RuntimeError`, and both can come out of the gate's own submit/result calls.
Re-raising on the base class let them past `_teardown_unidentified_pool`,
leaving `mpi_pool` dangling on a session whose `__init__` never finished --
so `__del__` then ran a blocking `shutdown()` on a broken pool, and its
abort watchdog failed on the `self.comm` that the raising `__init__` never
got to assign. A hang, introduced by a change meant to remove hangs.
Failures now raise a dedicated `_IdentityGateFailure(RuntimeError)`, so the
re-raise catches only the gate's own already-torn-down case. Callers still
see a `RuntimeError`.
2. The bootstrap phase returned on the FIRST probe (`FIRST_COMPLETED`), which
handed the remaining ranks' import time to the barrier's tight fixed budget
-- converting an absolute bound into a skew bound. A healthy pool whose
slowest rank trailed its fastest by more than the barrier budget was torn
down, with a message ("a pool that had already bootstrapped") that was
false. It now waits for every probe, so skew is absorbed by the generous
bootstrap budget, which is what that budget is for. Measured on real MPI
with per-rank staggered imports and a 2s barrier bound: ranks staggered
[0.2, 0.5, 1.0, 2.8]s went from torn down at 2.20s to passing at 4.21s, and
[0, 0.5, 1, 6]s passes at 7.40s. Dead-pool fast-fail is unaffected (1.0s).
Also from review:
- Reap the union of both phases' identities rather than preferring the
barrier's; on a partial barrier the two sets differ and taking either alone
leaves workers unreaped.
- Drop the `_identity_barrier_timeout` back-compat alias. It returned the
bootstrap budget while the barrier bound is 60s, so any caller reading it by
its old whole-gate meaning would compute a budget below the real worst case
and silently reintroduce the bug. No library callers exist; a missing name
fails loudly.
- `n_workers == 0` no longer burns the whole bootstrap budget.
- Document what dropping the barrier from the probe costs: without a
collective to pin one task per worker, mpi4py may send two probes to the
same rank, which is why the gate requires all probes to return rather than
treating each identity as a distinct worker.
Tests:
- Cover the RuntimeError-from-submit teardown, and pin the premise that
`BrokenExecutor` and `mpi4py.MPI.Exception` really are RuntimeErrors.
- Cover bootstrap skew, with probes that resolve on a timer rather than
pre-resolved, so the all-probes wait is actually exercised.
- Exercise the real `_pool_can_make_progress` instead of the stub it was always
given -- it is a getattr chain, so a renamed mpi4py internal would have
degraded the fast-fail to the full deadline with every test still green --
plus a guard that fails loudly if mpi4py moves the manager thread.
- Assert the deadline actually passed to `futures_wait` in the barrier phase,
not just the constant's value. Nothing previously would have caught the
barrier picking up the 300s bootstrap budget.
- Drop `test_wait_shutdown_call_sites_are_inventoried`: its hardcoded counts
are already wrong on origin/main, it missed three further call sites, and it
was a tripwire on files it did not own.
Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
MINOR-4 was a defect in this PR's own headline claim. The generic
`except Exception` handler spans both phases -- the `_worker_hello` submit and
the barrier's submit/result -- but hard-coded `phase=bootstrap` in its message.
So a broken executor surfacing during the barrier was reported as a bootstrap
failure, and the message pointed on-call at `TRTLLM_MPI_IDENTITY_TIMEOUT`,
which is powerless against a broken pool. For that error class the two phases
were still the same log line, which is worse than no phase label at all.
The phase is now tracked in a local and interpolated into the message.
Test fixes from the same review, none of which changed behaviour:
- `test_bootstrap_waits_for_every_worker_not_just_the_first` was vacuous:
the helper pre-resolved the barrier futures, so an early bootstrap return
cost nothing and the test passed with `FIRST_COMPLETED` restored -- a false
guarantee on precisely the defect it is named for. The barrier results now
land on the same clock as the straggler's probe, so only a bootstrap that
waited for every probe reaches the barrier late enough to find them done.
Mutation-checked: restoring `FIRST_COMPLETED` now fails this test (and
`test_slow_bootstrap_is_not_mistaken_for_a_dead_pool`), and hard-coding the
phase again fails `test_gate_tears_down_on_runtime_errors_from_the_pool`.
- The RuntimeError teardown test covered only the submit-raises path despite
its docstring naming both. It now covers `f.result()` re-raising too, over
`BrokenExecutor`, plain `RuntimeError`, and a real `mpi4py.MPI.Exception`,
and asserts the barrier-phase label on each.
- Tightened the mpi4py tripwire. It checked the manager thread but not the
`_pool` attribute the predicate walks first, so the likeliest rename was
unguarded. It now asserts every link of `executor._pool.thread` plus that
the manager is a started `threading.Thread`. Verified the assertions
discriminate: `MPIPoolExecutor._bootstrap.__code__.co_names` is exactly
`('_pool', '_make_pool')`.
Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
wait_shutdown=Trueworker-identity gate intensorrt_llm/llmapi/mpi_session.pybounds spawn +import tensorrt_llm+ the identity barrier with a single deadline. It has to:MPIPoolExecutoris constructed lazily, so the gate's firstsubmit()is what triggers the whole worker bootstrap.That forces one number to cover two costs with wildly different scales. Sizing it against bootstrap (low hundreds of seconds) is the only way to stop it failing healthy-but-slow pools — and it leaves the guard unable to tell a slow bootstrap from a dead pool without burning that whole budget first. Both cases produce the same
0/N valid identitiestext at the same wall time.Warm, then measure. This splits the gate into two phases:
n_workerscollective-free probes under the existing generous, env-tunable budget, waited on in poll slices until they have all returned. All of them, not the first: ranks finish importing seconds to tens of seconds apart, and any skew left here would be paid by the barrier out of its tight budget, turning an absolute bound into a skew bound. mpi4py raises a failedMPI_Comm_spawninside its manager thread, so the futures simply never resolve; that thread's liveness is the one positive signal separating a dead pool from a slow one, and the wait gives up on that, not on the clock.Measured
20-core x86 host, real MPI (Open MPI + mpi4py 3.1.5), a worker module whose import sleeps standing in for a cold
import tensorrt_llm, driving a verbatim copy of each gate:MPI_ERR_SPAWN(unspawnable pool)Second defect fixed
That last row is a real leak. The barrier is collective, so a stalled one returns nothing,
partial_identitiesis empty, and_teardown_unidentified_pool's SIGKILL loop is a no-op — the workers leak until job end. The bootstrap probes are collective-free, so they come back even when the barrier does not, giving teardown real PIDs to reap. Verified above:reaped=[]before, all four worker PIDs after, on the identical wedged pool.Precisely scoped: this covers a worker that wedges after importing. One wedged inside
import tensorrt_llmnever returns a probe, so it stays unidentified and still leaks — unchanged from before.Failures raise a dedicated type, not bare
RuntimeErrormpi4py.MPI.Exceptionandconcurrent.futures.BrokenExecutorboth inherit fromRuntimeError, and both can come out of the gate's ownsubmit()/result(). Re-raising on the base class to preserve an already-torn-down failure would let those two past the teardown, leavingmpi_pooldangling on a session whose__init__never finished —__del__then blocks on a broken pool, and its abort watchdog fails on theself.commthat the raising__init__never assigned. The gate raises_IdentityGateFailure(RuntimeError)so the re-raise catches only its own case; callers still see aRuntimeError.Diagnostics
Every failure of the gate now carries one greppable marker,
[mpi-identity-gate], and names the phase that actually failed — tracked in a local, not hard-coded, so a broken executor surfacing during the barrier is not reported as a bootstrap failure pointing on-call atTRTLLM_MPI_IDENTITY_TIMEOUT. "Never bootstrapped" and "bootstrapped but the barrier stalled" stop being the same log line. The marker lives in the exception message deliberately: that is the channel proven to reach CI logs for this path (the message text is what shows up in Jenkins output for these failures), whereas the callers' plainprint()diagnostics do not survive pytest capture.Production path unchanged
wait_shutdown=Falseis the production default, used byllmapi/llm.py. It is untouched:_collect_worker_identitieshas exactly one call site, guarded byif wait_shutdown:(mpi_session.py:374-375).mpi_session.pyagainst the base confirms the only pre-existing function bodies that changed are_collect_worker_identitiesand_identity_barrier_timeout(now an alias returning the same value).__init__,submit,submit_sync,shutdown,_start_mpi_pool,_wait_workers_exit,_teardown_unidentified_pool,release_exit_joins,abortare byte-identical.test_wait_shutdown_false_never_touches_the_identity_gateasserts the constructor submits nothing on that path.Origin
For the record and without blame: the gate arrived in #15908, which measured worker spawn + import at "~50s" and the slowest legitimate pool build at "~117s (busy node)" in the same changeset. The deadline it shipped with was below those numbers; #16971 raised it to 300 s. This change stops the two costs sharing one number at all, so raising the bootstrap budget no longer widens the guard.
Also
The test-session prefetcher now derives its outer wait from the new
identity_gate_budget()(bootstrap + barrier) rather than the bootstrap half alone, so it cannot abandon a build the library still considers healthy.Tests
Added to
tests/unittest/llmapi/test_mpi_session.py, which is registered whole-file intests/integration/test_lists/test-db/l0_a100.yml(unittest/llmapi/test_mpi_session.py ISOLATION), so CI runs them:test_dead_pool_fails_without_burning_the_bootstrap_budget— Blocker: dead != slow in wall timetest_slow_bootstrap_is_not_mistaken_for_a_dead_pool— the other halftest_warm_up_identities_are_reaped_when_the_barrier_stalls— the leaktest_bootstrap_waits_for_every_worker_not_just_the_first— bootstrap skew, probes resolving on a timertest_gate_tears_down_on_runtime_errors_from_the_pool+test_mpi_and_executor_errors_really_are_runtime_errors— both arrival routes (raised fromsubmit(), and delivered through a future sof.result()re-raises), overBrokenExecutor, plainRuntimeErrorand a realmpi4py.MPI.Exceptiontest_pool_can_make_progress_reads_the_real_mpi4py_internals+test_mpi4py_still_exposes_the_liveness_handle— exercises the real predicate (it is agetattrchain; a renamed mpi4py internal would otherwise degrade the fast-fail to the full deadline with every test still green). The tripwire asserts every link ofexecutor._pool.thread, not just the thread.test_identity_gate_marks_every_failure_path— marker + phase on both pathstest_barrier_phase_waits_on_the_barrier_budget_not_the_bootstrap_one— asserts the deadline actually passed tofutures_wait, not just the constanttest_wait_shutdown_false_never_touches_the_identity_gate107 passedacrosstest_mpi_session.py,test_session_prefetcher.py,test_session_reuse.pylocally. Four pre-existing tests intest_mpi_session.pyfail in this environment only because they spawn subprocesses that import atensorrt_llmolder than this checkout (build mismatch,ImportError: cannot import name '_DEFAULT_IDENTITY_TIMEOUT'/kv_cache_manager_v2); they are unrelated to this change.Mutation-checked
The two tests guarding the most consequential behaviours were verified by mutation, not just by passing:
FIRST_COMPLETEDearly returntest_bootstrap_waits_for_every_worker_not_just_the_first,test_slow_bootstrap_is_not_mistaken_for_a_dead_poolphase=bootstrapagaintest_gate_tears_down_on_runtime_errors_from_the_poolNot booked to the hang program
Deliberately not tagged
[TRTLLM-13409]: this is a false-positive-failure regression, not a hang.🤖 Generated with Claude Code