Skip to content

Commit 16d94f3

Browse files
apboseApurba Boseclaude
authored
[cherry-pick][release/2.13] fix(distributed): subgroup NCCL regression + nccl_utils TRT 11 cleanup (#4407) (#4408)
Co-authored-by: Apurba Bose <abose@umb-b200-138.cl1u1.colossus.nvidia.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b2925c commit 16d94f3

2 files changed

Lines changed: 119 additions & 98 deletions

File tree

py/torch_tensorrt/distributed/_nccl_utils.py

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,31 @@
77
88
Background:
99
-----------
10-
TensorRT's dlopen("libnccl.so") may load a different NCCL library than PyTorch,
11-
causing crashes when sharing NCCL communicators.
10+
TensorRT 11.0+ (ncclWrapper.cpp) searches for NCCL in this order:
1211
13-
- PyTorch loads NCCL via RPATH baked at compile time (libnccl.so.2)
14-
- TensorRT lazy-loads NCCL via dlopen("libnccl.so") at runtime
12+
for (char const* name : {"libnccl.so.2", "libnccl.so"})
13+
dlopen(name, ...)
1514
16-
The mismatch occurs because:
17-
1. pip's nvidia-nccl-cu* package only ships libnccl.so.2 (no libnccl.so symlink)
18-
2. TRT specifically looks for libnccl.so, misses pip's copy, falls back to system NCCL
15+
Because it tries "libnccl.so.2" first, it matches the exact soname of PyTorch's
16+
already-loaded NCCL library. glibc returns the same handle, so TRT and PyTorch
17+
share one NCCL instance — no symlink required.
18+
19+
The only remaining requirement is that the pip nccl directory is in
20+
LD_LIBRARY_PATH so a cold dlopen (library not yet loaded) can find the file.
21+
Pre-loading libnccl.so.2 with RTLD_GLOBAL before TRT's first dlopen call
22+
guarantees soname reuse regardless of LD_LIBRARY_PATH timing.
23+
24+
Note on older TRT (< 11.0):
25+
Pre-11.0 runtimes only called dlopen("libnccl.so"), which does not match
26+
PyTorch's libnccl.so.2 soname. A libnccl.so symlink was required.
27+
ensure_nccl_symlink() is kept for that case but is no longer called by
28+
setup_nccl_for_torch_tensorrt() on TRT 11.0+.
1929
2030
Environments:
2131
-------------
2232
- NGC containers: No action needed (both use system NCCL)
23-
- pip install torch: Requires symlink + LD_LIBRARY_PATH setup
24-
25-
Future:
26-
-------
27-
TensorRT 11.0 will support TRT_NCCL_LIBRARY env var, eliminating the need for
28-
symlink workarounds.
33+
- pip install torch + TRT 11.0+: LD_LIBRARY_PATH + libnccl.so.2 pre-load only
34+
- pip install torch + TRT < 11.0: call ensure_nccl_symlink() manually first
2935
"""
3036

3137
import ctypes
@@ -120,34 +126,36 @@ def check_nccl_library_path() -> bool:
120126

121127
def setup_nccl_for_torch_tensorrt() -> None:
122128
"""
123-
Setup NCCL library for TensorRT distributed inference.
129+
Setup NCCL library for TensorRT 11.0+ distributed inference.
130+
131+
TRT 11.0+ tries dlopen("libnccl.so.2") before "libnccl.so", so it matches
132+
PyTorch's already-loaded library by soname and reuses the same instance.
133+
This function ensures that reuse works by:
124134
125-
This function:
126-
1. Detects if nvidia.nccl pip package is installed
127-
2. Creates libnccl.so symlink if needed
128-
3. Pre-loads libnccl.so via ctypes (helps Python runtime path)
129-
4. Updates LD_LIBRARY_PATH for dynamic loaders
135+
1. Prepending the pip nccl directory to LD_LIBRARY_PATH so a cold dlopen
136+
can find libnccl.so.2 if it isn't yet in-process.
137+
2. Pre-loading libnccl.so.2 with RTLD_GLOBAL so TRT's subsequent
138+
dlopen("libnccl.so.2") gets the same handle via soname reuse rather
139+
than opening a second copy.
130140
131-
Note: TRT's internal loader (libLoader.cpp) reads LD_LIBRARY_PATH at
132-
process launch time, not when updated via os.environ. For the C++ TRT
133-
runtime path, LD_LIBRARY_PATH must be set before the process starts:
141+
No symlink is created. For NGC containers (system NCCL) this is a no-op.
134142
135-
NCCL_LIB=$(python -c "from torch_tensorrt.distributed._nccl_utils import get_nccl_library_path; print(get_nccl_library_path())")
136-
LD_LIBRARY_PATH="$NCCL_LIB:$LD_LIBRARY_PATH" python script.py
143+
Note: for the LD_LIBRARY_PATH update to be guaranteed effective on the
144+
very first TRT dlopen, set it before process start:
137145
138-
For NGC containers (system NCCL), this is a no-op.
146+
NCCL_LIB=$(python -c "from torch_tensorrt.distributed._nccl_utils \\
147+
import get_nccl_library_path; print(get_nccl_library_path())")
148+
LD_LIBRARY_PATH="$NCCL_LIB:$LD_LIBRARY_PATH" torchrun ...
139149
"""
140150
global _nccl_setup_checked
141151

142-
# Only check once per process
143152
if _nccl_setup_checked:
144153
return
145154
_nccl_setup_checked = True
146155

147156
nccl_lib_dir = get_nccl_library_path()
148157

149158
if nccl_lib_dir is None:
150-
# NGC container or system NCCL - no action needed
151159
logger.debug(
152160
"nvidia.nccl package not found. "
153161
"Assuming system NCCL is used by both PyTorch and TensorRT."
@@ -156,12 +164,8 @@ def setup_nccl_for_torch_tensorrt() -> None:
156164

157165
logger.debug(f"Found nvidia.nccl package at: {nccl_lib_dir}")
158166

159-
# Ensure symlink exists
160-
symlink_ok = ensure_nccl_symlink(nccl_lib_dir)
161-
162-
# Ensure LD_LIBRARY_PATH includes the NCCL directory so TRT's dlopen("libnccl.so")
163-
# finds the same library PyTorch already loaded. dlopen() reads LD_LIBRARY_PATH
164-
# dynamically, so updating os.environ here takes effect for subsequent loads.
167+
# Prepend pip nccl dir to LD_LIBRARY_PATH so a cold dlopen("libnccl.so.2")
168+
# finds the right file when the library isn't yet loaded in-process.
165169
ld_library_path = os.environ.get("LD_LIBRARY_PATH", "")
166170
if nccl_lib_dir not in ld_library_path:
167171
os.environ["LD_LIBRARY_PATH"] = (
@@ -171,18 +175,19 @@ def setup_nccl_for_torch_tensorrt() -> None:
171175
else:
172176
logger.debug(f"LD_LIBRARY_PATH already includes NCCL directory: {nccl_lib_dir}")
173177

174-
if symlink_ok:
175-
# Pre-load libnccl.so into the process with RTLD_GLOBAL so that TRT's
176-
# subsequent dlopen("libnccl.so") inside setCommunicator() finds the
177-
# already-loaded library rather than searching LD_LIBRARY_PATH again.
178-
nccl_so = os.path.join(nccl_lib_dir, "libnccl.so")
178+
# Pre-load libnccl.so.2 with RTLD_GLOBAL so TRT's dlopen("libnccl.so.2")
179+
# gets soname reuse (same handle) instead of loading a second instance.
180+
nccl_so_2 = os.path.join(nccl_lib_dir, "libnccl.so.2")
181+
if os.path.exists(nccl_so_2):
179182
try:
180-
ctypes.CDLL(nccl_so, mode=ctypes.RTLD_GLOBAL)
181-
logger.debug(f"Pre-loaded NCCL library: {nccl_so}")
183+
ctypes.CDLL(nccl_so_2, mode=ctypes.RTLD_GLOBAL)
184+
logger.debug(f"Pre-loaded NCCL library: {nccl_so_2}")
182185
except OSError as e:
183-
logger.warning(f"Failed to pre-load NCCL library {nccl_so}: {e}")
186+
logger.warning(f"Failed to pre-load NCCL library {nccl_so_2}: {e}")
187+
else:
188+
logger.warning(f"libnccl.so.2 not found at {nccl_so_2}")
184189

185-
logger.debug("NCCL library setup complete")
190+
logger.debug("NCCL library setup complete")
186191

187192

188193
def initialize_nccl_comm(device: Optional[int] = None) -> None:

tests/py/dynamo/distributed/test_native_nccl.py

Lines changed: 72 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
import sys
4646
import threading
4747
import unittest
48-
from contextlib import nullcontext
4948
from typing import Any
5049
from unittest.mock import MagicMock, patch
5150

@@ -93,6 +92,21 @@ def has_nccl_collectives() -> bool:
9392
return False
9493

9594

95+
def _cpp_runtime_available() -> bool:
96+
"""Return True when the C++ Torch-TensorRT runtime extension is loaded.
97+
98+
When True, _TRTEngine registers neither its opaque type nor the Python
99+
tensorrt::execute_engine custom op (see _TRTEngine.py:1376), so
100+
TestPythonRuntimePickle cannot run and must be skipped.
101+
"""
102+
try:
103+
from torch_tensorrt._features import ENABLED_FEATURES
104+
105+
return bool(ENABLED_FEATURES.torch_tensorrt_runtime)
106+
except Exception:
107+
return False
108+
109+
96110
# ---------------------------------------------------------------------------
97111
# Shared test fakes
98112
# ---------------------------------------------------------------------------
@@ -1204,7 +1218,6 @@ def _run(self, model: nn.Module, inputs: list[torch.Tensor]) -> None:
12041218
backend="torch_tensorrt",
12051219
dynamic=False,
12061220
options={
1207-
"use_python_runtime": True,
12081221
"min_block_size": 1,
12091222
"use_distributed_mode_trace": True,
12101223
},
@@ -1323,6 +1336,10 @@ def test_group_name_survives_context_exit(self) -> None:
13231336
not has_nccl_collectives(),
13241337
"Skipped: No NCCL collective support (neither native TRT collectives nor TRT-LLM).",
13251338
)
1339+
@unittest.skipIf(
1340+
_cpp_runtime_available(),
1341+
"Skipped: Python runtime (_TRTEngine) op not registered when C++ extension is available.",
1342+
)
13261343
class TestPythonRuntimePickle(unittest.TestCase):
13271344
"""Verifies that _nccl_comm is stripped on pickle and reset on unpickle.
13281345
@@ -1344,8 +1361,12 @@ def tearDownClass(cls) -> None:
13441361
dist.destroy_process_group()
13451362

13461363
def _compile_small_model(self) -> Any:
1347-
"""Return a compiled module backed by a Python ``TRTEngine``."""
1348-
import torch_tensorrt
1364+
"""Return a compiled module backed by a Python ``TRTEngine``.
1365+
1366+
This class is only reached when ENABLED_FEATURES.torch_tensorrt_runtime=False
1367+
(C++ extension not installed), so setup_engine() naturally instantiates
1368+
_TRTEngine without any patching needed.
1369+
"""
13491370

13501371
class LinearModel(nn.Module):
13511372
def forward(self, x: torch.Tensor) -> torch.Tensor:
@@ -1359,21 +1380,18 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
13591380
backend="torch_tensorrt",
13601381
dynamic=False,
13611382
options={
1362-
"use_python_runtime": True,
13631383
"min_block_size": 1,
13641384
},
13651385
)
1366-
_ = trt_model(inp) # trigger compilation
1386+
_ = trt_model(inp) # trigger compilation → setup_engine() → _TRTEngine
13671387
return trt_model
13681388

13691389
@classmethod
13701390
def _find_python_trt_engine(cls, obj: Any) -> Any:
13711391
"""Walk a compiled module and return the Python ``TRTEngine`` instance.
13721392
1373-
With the unified runtime, the wrapping ``TorchTensorRTModule`` is the
1374-
same regardless of backend; ``use_python_runtime=True`` causes its
1375-
``.engine`` attribute to be a Python ``TRTEngine`` (vs the C++
1376-
``torch.classes.tensorrt.Engine`` otherwise).
1393+
Only meaningful when C++ extension is absent (ENABLED_FEATURES.torch_tensorrt_runtime=False),
1394+
which is enforced by the class-level skipIf decorator.
13771395
"""
13781396
from torch_tensorrt.dynamo.runtime._TorchTensorRTModule import (
13791397
TorchTensorRTModule,
@@ -1633,7 +1651,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
16331651
backend="torch_tensorrt",
16341652
dynamic=False,
16351653
options={
1636-
"use_python_runtime": True,
16371654
"min_block_size": 1,
16381655
"use_distributed_mode_trace": True,
16391656
},
@@ -1736,7 +1753,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
17361753
backend="torch_tensorrt",
17371754
dynamic=False,
17381755
options={
1739-
"use_python_runtime": True,
17401756
"min_block_size": 1,
17411757
"use_distributed_mode_trace": True,
17421758
},
@@ -1770,6 +1786,10 @@ def _multirank_distributed_mode_subgroup(
17701786
# New subgroup with all ranks (different group object from WORLD)
17711787
subgroup = dist.new_group(ranks=list(range(world_size)))
17721788
sg_name = subgroup.group_name
1789+
# Force subgroup NCCL comm init — PyTorch creates ncclComm_t lazily on first
1790+
# collective; bind_nccl_comm() in execute_engine reads getCommPtr() which is 0
1791+
# until at least one collective has run on the group.
1792+
dist.barrier(group=subgroup)
17731793

17741794
class AllReduceModel(nn.Module):
17751795
def forward(self, x: torch.Tensor) -> torch.Tensor:
@@ -1791,7 +1811,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
17911811
backend="torch_tensorrt",
17921812
dynamic=False,
17931813
options={
1794-
"use_python_runtime": True,
17951814
"min_block_size": 1,
17961815
"use_distributed_mode_trace": True,
17971816
},
@@ -1872,6 +1891,9 @@ def _multirank_distributed_mode_context_switch(
18721891
sg2 = dist.new_group(ranks=list(range(world_size)))
18731892
sg1_name = sg1.group_name
18741893
sg2_name = sg2.group_name
1894+
# Force both subgroup NCCL comms to init before TRT tries to bind them.
1895+
dist.barrier(group=sg1)
1896+
dist.barrier(group=sg2)
18751897

18761898
class AllReduceModel(nn.Module):
18771899
def __init__(self, name: str) -> None:
@@ -1893,7 +1915,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
18931915
backend="torch_tensorrt",
18941916
dynamic=False,
18951917
options={
1896-
"use_python_runtime": True,
18971918
"min_block_size": 1,
18981919
"use_distributed_mode_trace": True,
18991920
},
@@ -1947,50 +1968,41 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
19471968
expected_sum = world_size * (world_size + 1) // 2
19481969
expected = torch.full((1, 4), float(expected_sum), device=device)
19491970

1950-
for label, use_python_runtime in [("cpp", False), ("python", True)]:
1951-
# ---- Step 1: compile + run with default world group ----
1952-
model = AllReduceModel(world_name).to(device).eval()
1971+
# ---- Step 1: compile + run with default world group ----
1972+
model = AllReduceModel(world_name).to(device).eval()
19531973

1954-
with distributed_context(world_group):
1955-
trt_model = torch.compile(
1956-
model,
1957-
backend="torch_tensorrt",
1958-
dynamic=False,
1959-
options={
1960-
"use_python_runtime": use_python_runtime,
1961-
"min_block_size": 1,
1962-
"use_distributed_mode_trace": True,
1963-
},
1964-
)
1965-
with torch.no_grad():
1966-
out_world = trt_model(inp)
1974+
with distributed_context(world_group):
1975+
trt_model = torch.compile(
1976+
model,
1977+
backend="torch_tensorrt",
1978+
dynamic=False,
1979+
options={
1980+
"min_block_size": 1,
1981+
"use_distributed_mode_trace": True,
1982+
},
1983+
)
1984+
with torch.no_grad():
1985+
out_world = trt_model(inp)
19671986

1968-
_check_close(out_world, expected, f"[{label}] world group rank={rank}")
1987+
_check_close(out_world, expected, f"world group rank={rank}")
19691988

1970-
# ---- Step 2: migrate to subgroup via distributed_context(subgroup, model) ----
1971-
# This calls set_distributed_mode() which resets nccl_initialized on the
1972-
# C++ engine, and keeps _state.pg = subgroup active for the Python runtime's
1973-
# lazy setup_nccl_comm() call.
1974-
with distributed_context(subgroup, trt_model) as migrated_model:
1975-
with torch.no_grad():
1976-
out_sub = migrated_model(inp)
1989+
# ---- Step 2: migrate to subgroup via distributed_context(subgroup, model) ----
1990+
# set_distributed_mode() resets nccl_initialized on the C++ engine so
1991+
# bind_nccl_comm() re-runs on the next forward with the new group name.
1992+
with distributed_context(subgroup, trt_model) as migrated_model:
1993+
with torch.no_grad():
1994+
out_sub = migrated_model(inp)
19771995

1978-
_check_close(out_sub, expected, f"[{label}] migrated to subgroup rank={rank}")
1996+
_check_close(out_sub, expected, f"migrated to subgroup rank={rank}")
19791997

1980-
# ---- Step 3: set_distributed_mode (persistent, outside context) ----
1981-
subgroup2 = dist.new_group(ranks=list(range(world_size)))
1982-
torch_tensorrt.distributed.set_distributed_mode(subgroup2, trt_model)
1983-
# _state.pg is NOT set here — Python runtime falls back to world group
1984-
# for lazy setup_nccl_comm; C++ runtime uses the pinned group name.
1985-
# For C++ runtime only (Python runtime needs _state.pg active):
1986-
if not use_python_runtime:
1987-
with torch.no_grad():
1988-
out_pin = trt_model(inp)
1989-
_check_close(
1990-
out_pin, expected, f"[{label}] set_distributed_mode rank={rank}"
1991-
)
1998+
# ---- Step 3: set_distributed_mode (persistent, outside context) ----
1999+
subgroup2 = dist.new_group(ranks=list(range(world_size)))
2000+
torch_tensorrt.distributed.set_distributed_mode(subgroup2, trt_model)
2001+
with torch.no_grad():
2002+
out_pin = trt_model(inp)
2003+
_check_close(out_pin, expected, f"set_distributed_mode rank={rank}")
19922004

1993-
print(f"[Rank {rank}] PASS _multirank_pg_migration [{label}]", flush=True)
2005+
print(f"[Rank {rank}] PASS _multirank_pg_migration", flush=True)
19942006

19952007

19962008
# ============================================================================
@@ -2096,7 +2108,7 @@ def test_distributed_mode_tp_model(self) -> None:
20962108
@requires_nccl()
20972109
@skip_if_lt_x_gpu(2)
20982110
def test_distributed_mode_subgroup(self) -> None:
2099-
"""distributed_context() with a non-default TP subgroup routes NCCL correctly."""
2111+
"""C++ runtime with a non-default TP subgroup routes NCCL correctly."""
21002112
device = self._init_dist()
21012113
_multirank_distributed_mode_subgroup(self.rank, self.world_size, device)
21022114

@@ -2112,7 +2124,7 @@ def test_cpp_runtime_bind_nccl(self) -> None:
21122124
@requires_nccl()
21132125
@skip_if_lt_x_gpu(2)
21142126
def test_distributed_mode_context_switch(self) -> None:
2115-
"""Switching distributed_context between two subgroups routes to correct communicator."""
2127+
"""C++ runtime: switching distributed_context between two subgroups routes correctly."""
21162128
device = self._init_dist()
21172129
_multirank_distributed_mode_context_switch(self.rank, self.world_size, device)
21182130

@@ -2140,8 +2152,12 @@ def run_multirank_tests() -> None:
21402152
_multirank_all_gather_correctness,
21412153
_multirank_reduce_scatter_all_reduce_ops,
21422154
_multirank_all_to_all_correctness,
2143-
_multirank_scatter_correctness,
2144-
_multirank_gather_correctness,
2155+
lambda r, ws, dev: [
2156+
_multirank_scatter_correctness(i, r, ws, dev) for i in range(ws)
2157+
],
2158+
lambda r, ws, dev: [
2159+
_multirank_gather_correctness(i, r, ws, dev) for i in range(ws)
2160+
],
21452161
_multirank_distributed_mode_tp_model,
21462162
_multirank_distributed_mode_subgroup,
21472163
_multirank_cpp_runtime_bind_nccl,

0 commit comments

Comments
 (0)