Skip to content

Commit 6c43b3e

Browse files
authored
[None][feat] Support externally provided MPI sessions with explicit ownership (NVIDIA#16053)
Signed-off-by: qgai <qgai@nvidia.com>
1 parent 44c3adf commit 6c43b3e

6 files changed

Lines changed: 130 additions & 52 deletions

File tree

tensorrt_llm/_torch/distributed/communicator.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,28 @@ def init_pp_comm(mapping):
12401240
global _pp_comm
12411241
if mpi_disabled():
12421242
_pp_comm = PPCommTorch(mapping)
1243+
elif isinstance(_pp_comm, PPCommNCCL) and \
1244+
_pp_comm.mapping.world_size == mapping.world_size:
1245+
# Reuse the existing world NCCL communicator across LLM instances that
1246+
# share the same worker processes (e.g. a reused MpiPoolSession). The
1247+
# underlying comm depends only on (world_size, rank) -- it is a world
1248+
# communicator, independent of the pp/tp/ep layout -- so only the
1249+
# routing mapping needs refreshing. Recreating it would drop the old
1250+
# comm and trigger a collective ncclCommDestroy at an unsynchronized
1251+
# point during the next model build, which can deadlock on reused
1252+
# workers. Single-LLM (production) runs are unaffected: _pp_comm starts
1253+
# as None, so the first call still constructs a fresh PPCommNCCL.
1254+
_pp_comm.mapping = mapping
12431255
else:
1256+
if _pp_comm is not None:
1257+
# Rebinding drops the old comm; its ncclCommDestroy runs at an
1258+
# unsynchronized point and can deadlock on reused worker processes
1259+
# (see the reuse branch above). Surface it instead of hanging
1260+
# silently -- pools sharing workers must keep one world_size.
1261+
logger.warning(
1262+
"init_pp_comm: replacing existing PP comm (world_size "
1263+
f"{_pp_comm.mapping.world_size} -> {mapping.world_size}) on a "
1264+
"live process; this can deadlock on reused MPI workers.")
12441265
_pp_comm = PPCommNCCL(mapping)
12451266
init_helix_cp_comm(mapping)
12461267

tensorrt_llm/executor/executor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ def create(
652652
return GenerationExecutor._create_ipc_executor(
653653
worker_kwargs,
654654
model_world_size=model_world_size,
655-
mpi_session=None, # use mpi4py
655+
mpi_session=mpi_session,
656656
postproc_worker_config=postproc_worker_config,
657657
is_llm_executor=is_llm_executor,
658658
use_worker=False)
@@ -662,13 +662,17 @@ def create(
662662
mpi_session = ProcessPoolExecutorSession(n_workers=1,
663663
mp_context=ctx)
664664
# TODO: add rpc worker here
665-
return GenerationExecutor._create_ipc_executor(
665+
executor = GenerationExecutor._create_ipc_executor(
666666
worker_kwargs,
667667
model_world_size=model_world_size,
668668
mpi_session=mpi_session,
669669
postproc_worker_config=postproc_worker_config,
670670
is_llm_executor=is_llm_executor,
671671
use_worker=False)
672+
# The session was created right here with no outer owner, so the
673+
# proxy must shut it down despite it arriving as "external".
674+
executor._owns_mpi_session = True
675+
return executor
672676

673677
def wait_first_completed(
674678
self, futures: List[GenerationResult]

tensorrt_llm/executor/proxy.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929

3030
from .._utils import customized_gc_thresholds, mpi_rank, nvtx_range_debug
3131
from ..llmapi.mpi_session import (MpiCommSession, MpiPoolSession, MpiSession,
32-
RemoteMpiCommSessionClient)
32+
RemoteMpiCommSessionClient,
33+
validate_session_world_size)
3334
from ..llmapi.tracer import enable_llm_tracer, get_tracer, global_tracer
3435
from ..llmapi.utils import (AsyncQueue, ManagedThread, _SyncQueue,
3536
enable_llm_debug, logger_debug, print_colored)
@@ -117,6 +118,7 @@ def __init__(
117118
self.worker_cls = worker_cls
118119

119120
mpi_process_pre_spawned: bool = get_spawn_proxy_process_env()
121+
self._owns_mpi_session = mpi_session is None
120122

121123
if mpi_session is None:
122124
if mpi_process_pre_spawned:
@@ -126,6 +128,10 @@ def __init__(
126128
logger_debug('create pool session ...\n', "yellow")
127129
self.mpi_session = MpiPoolSession(n_workers=model_world_size)
128130
else:
131+
# submit() launches one worker task per pool worker, so an
132+
# external session must match the model's world size exactly;
133+
# fail loudly instead of starting the wrong number of executors.
134+
validate_session_world_size(mpi_session, model_world_size)
129135
logger_debug('using external mpi session ...\n', "yellow")
130136
self.mpi_session = mpi_session
131137

@@ -406,11 +412,11 @@ def resource_governor_queue(self):
406412
return self._resource_governor_queue
407413

408414
def abort_request(self, request_id: int) -> None:
409-
''' Abort a request by sending a cancelling request to the request queue.
415+
"""Abort a request by sending a cancelling request to the request queue.
410416
411417
Args:
412418
request_id (int): The id of the request to abort.
413-
'''
419+
"""
414420
# NOTE, it just sends a cancelling request to the request queue, but it
415421
# may take a while for the request to be cancelled in the worker and
416422
# send back a finished result.
@@ -543,7 +549,10 @@ def mpi_done_callback(future: concurrent.futures.Future):
543549

544550
if ready_signal != GenerationExecutorProxy.READY_SIGNAL:
545551
logger.error(f"Executor worker initialization error: {error_trace}")
546-
self.mpi_session.shutdown_abort(reason=ready_signal)
552+
# Only abort a session this proxy created; an externally owned
553+
# (shared) session must stay alive for its owner to tear down.
554+
if self._owns_mpi_session:
555+
self.mpi_session.shutdown_abort(reason=ready_signal)
547556
raise RuntimeError(
548557
"Executor worker returned error") from ready_signal
549558

@@ -630,7 +639,8 @@ def shutdown(self):
630639
self._resource_governor_queue.close()
631640

632641
self.workers_started = False
633-
self.mpi_session.shutdown()
642+
if self._owns_mpi_session:
643+
self.mpi_session.shutdown()
634644

635645
# Process the errors in-case error during shutting down the threads
636646
self._handle_background_error()

tensorrt_llm/executor/rpc_proxy.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
import threading
1717
from typing import List, Optional, Union
1818

19-
from ..llmapi.mpi_session import MpiPoolSession, MpiSession
19+
from ..llmapi.mpi_session import (MpiPoolSession, MpiSession,
20+
validate_session_world_size)
2021
from ..llmapi.utils import logger_debug, print_colored
2122
from ..logger import logger
2223
from .executor import GenerationExecutor
@@ -42,13 +43,12 @@ def __init__(
4243
postproc_worker_config: Optional[PostprocWorkerConfig] = None,
4344
is_llm_executor: Optional[bool] = None,
4445
):
45-
"""
46-
Args:
47-
worker_kwargs: kwargs for the rpc worker
48-
model_world_size: the world size of the model
49-
mpi_session: the mpi session to use
50-
postproc_worker_config: the postproc worker config
51-
is_llm_executor: whether this is an llm executor
46+
"""Args:
47+
worker_kwargs: kwargs for the rpc worker
48+
model_world_size: the world size of the model
49+
mpi_session: the mpi session to use
50+
postproc_worker_config: the postproc worker config
51+
is_llm_executor: whether this is an llm executor
5252
"""
5353
GenerationExecutorRpcProxy.INSTANCE_COUNTER += 1
5454
self.init_rpc_executor()
@@ -81,11 +81,13 @@ def __init__(
8181
self._setup_mainloop_with_tasks()
8282

8383
def launch_workers(self):
84-
logger.debug(f"Launching workers")
84+
logger.debug("Launching workers")
8585
assert self.mpi_session is not None
86-
self.mpi_session.submit(RpcWorker.main_task,
87-
rpc_addr=self.rpc_addr,
88-
**self.worker_kwargs)
86+
# Keep the futures: on an externally owned (shared) session, shutdown
87+
# waits on them so worker teardown finishes before the pool is reused.
88+
self.worker_futures = self.mpi_session.submit(RpcWorker.main_task,
89+
rpc_addr=self.rpc_addr,
90+
**self.worker_kwargs)
8991

9092
def _setup_mainloop_with_tasks(self):
9193
"""Setup mainloop with tasks needed for RpcProxy.
@@ -256,7 +258,7 @@ def setup_engine_remote(self):
256258
return self.rpc_client.setup_engine().remote(need_response=True)
257259

258260
def shutdown_remote(self):
259-
logger_debug(f"Shutting down rpc remote", color="yellow")
261+
logger_debug("Shutting down rpc remote", color="yellow")
260262
self.rpc_client.shutdown().remote(need_response=False)
261263

262264
def abort_request(self, request_id: int) -> None:
@@ -266,8 +268,7 @@ def shutdown(self):
266268
if self._shutdown_event.is_set():
267269
return
268270
self._shutdown_event.set()
269-
logger_debug(f"Shutting down GenerationExecutorRpcProxy",
270-
color="yellow")
271+
logger_debug("Shutting down GenerationExecutorRpcProxy", color="yellow")
271272

272273
# 1. shutdown the rpc server (PyExecutor Rank 0 + RPC server)
273274
self.shutdown_remote()
@@ -294,9 +295,24 @@ def shutdown(self):
294295
# 3. shutdown the mpi session, this should wait until all the PyExecutor
295296
# processes are shutdown
296297
if self.mpi_session is not None:
297-
logger_debug(f"Shutting down mpi session", color="yellow")
298-
self.mpi_session.shutdown()
299-
logger_debug(f"Mpi session shutdown", color="yellow")
298+
if self._owns_mpi_session:
299+
logger_debug("Shutting down mpi session", color="yellow")
300+
self.mpi_session.shutdown()
301+
else:
302+
# Externally owned (shared) session: leave the pool alive, but
303+
# wait for this executor's worker tasks to finish so the next
304+
# LLM on the pool doesn't race with PyExecutor teardown. Block
305+
# without a timeout, mirroring the owned path above
306+
# (mpi_session.shutdown() also waits indefinitely); a timeout
307+
# that expires would just reintroduce the race it prevents.
308+
for future in getattr(self, "worker_futures", []):
309+
try:
310+
future.result()
311+
except Exception as e:
312+
logger.warning(
313+
f"RPC worker task raised during shutdown on "
314+
f"shared MPI session: {e}")
315+
logger_debug("Mpi session shutdown", color="yellow")
300316
self.mpi_session = None
301317

302318
self.rpc_client.close()
@@ -309,14 +325,20 @@ def __exit__(self, exc_type, exc_value, traceback):
309325

310326
def _create_mpi_session(self, model_world_size: int,
311327
mpi_session: Optional[MpiSession]):
328+
# Ownership is decided here, next to the create-vs-adopt branch: a
329+
# session this proxy created is shut down by it; an external one is
330+
# owned (and shut down) by the caller.
312331
mpi_process_pre_spawned: bool = get_spawn_proxy_process_env()
313332
if mpi_session is None:
333+
self._owns_mpi_session = True
314334
if mpi_process_pre_spawned:
315335
logger_debug('[proxy] create comm session ...\n', "yellow")
316336
self.mpi_session = create_mpi_comm_session(model_world_size)
317337
else:
318338
logger_debug('[proxy] create pool session ...\n', "yellow")
319339
self.mpi_session = MpiPoolSession(n_workers=model_world_size)
320340
else:
341+
validate_session_world_size(mpi_session, model_world_size)
342+
self._owns_mpi_session = False
321343
logger_debug('[proxy] using external mpi session ...\n', "yellow")
322344
self.mpi_session = mpi_session

tensorrt_llm/llmapi/llm.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@ def __init__(self,
314314
logger_debug(f"LLM.args.mpi_session: {self.args.mpi_session}\n",
315315
"yellow")
316316
self.mpi_session = self.args.mpi_session
317+
# Keep the live session on LLM only. LLM args are passed to model-build
318+
# tasks and executor workers, and MpiSession objects are not pickleable.
319+
self.args.mpi_session = None
320+
self._owns_mpi_session = self.mpi_session is None
317321

318322
# Build this LLM's post-processing hook for the in-proxy detok path (each
319323
# postproc worker builds its own). Resolving here fails fast on a bad
@@ -333,6 +337,8 @@ def __init__(self,
333337
logger.info(
334338
f'start MpiSession with {self.args.parallel_config.world_size} workers'
335339
)
340+
# _owns_mpi_session is already True here: this branch only runs
341+
# when no external session was supplied.
336342
if not self.mpi_session:
337343
mpi_process_pre_spawned: bool = get_spawn_proxy_process_env()
338344
if not mpi_process_pre_spawned:
@@ -365,7 +371,9 @@ def __init__(self,
365371
self._build_model()
366372

367373
except Exception:
368-
if self.mpi_session is not None:
374+
# _owns_mpi_session is assigned before this try block, so it is
375+
# always present here.
376+
if self.mpi_session is not None and self._owns_mpi_session:
369377
self.mpi_session.shutdown()
370378
raise
371379

@@ -1500,7 +1508,8 @@ def shutdown(self) -> None:
15001508
self._encoder_executor.shutdown()
15011509
self._encoder_executor = None
15021510

1503-
if hasattr(self, 'mpi_session') and self.mpi_session is not None:
1511+
if (hasattr(self, 'mpi_session') and self.mpi_session is not None
1512+
and getattr(self, "_owns_mpi_session", True)):
15041513
self.mpi_session.shutdown()
15051514
self.mpi_session = None
15061515

0 commit comments

Comments
 (0)