[PyTorch] NCCL EP handle cache reaches its 4096 limit with only one live EpBuffer under allocator address churn
I maintainer di solito rispondono entro 2 giorni
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 45/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Ambito
- backend, distributed-systems
Direzione di ricerca
Start with transformer_engine/common/ep/ep_backend.cpp at prepare_handle_locked, then trace how the PyTorch EP path prepares and releases handles. Run the supplied audit.py and probe.py across four GPUs with the specified environment to reproduce address churn and the 4096 limit. Done means identifying and fixing the cache/lifetime mismatch so completed operations do not exhaust the handle cache; rerun the probe to confirm.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Describe the bug
TE 2.21.0+d77ef88c exhausts its native handle cache after 4096 distinct buffer
addresses, even when every previous EP operation has completed and its Python
handle tensor has died. We reproduced this using TE and PyTorch alone on four
GB300 GPUs, then measured the same mechanism in an eight-GPU Qwen3.5-VL training
failure. This appears to be a cache/lifetime mismatch in the PyTorch EP path.
transformer_engine/common/ep/ep_backend.cpp:434 in prepare_handle_locked:
Assertion failed: handles_.size() < cache_cap_locked().
EP: live handle limit (4096) reached; raise NVTE_EP_HANDLE_CACHE_SIZE
Steps/Code to reproduce bug
Save the two files below together. They are the unchanged source used in the
successful isolated diagnostic. Launch one process per GPU on four GPUs on one
node. The validated launcher was Slurm, providing SLURM_PROCID, SLURM_LOCALID
and SLURM_NTASKS=4. Execute the following on each of those four ranks:
set -e
export PYTHONUNBUFFERED=1
export EP_AUDIT_DIR=./ep-audit
export MASTER_ADDR=localhost
export MASTER_PORT=29509
export NCCL_EP_HT_EM_PULL_PUSH=1
export CUDA_DEVICE_MAX_CONNECTIONS=32
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
export NCCL_GRAPH_REGISTER=0
export NCCL_NVLS_ENABLE=0
export TORCH_NCCL_AVOID_RECORD_STREAMS=1
export TORCH_NCCL_HIGH_PRIORITY=1
# NVTE_EP_HANDLE_CACHE_SIZE must be unset; the probe checks this.
for mode in reuse fresh churn; do
timeout --signal=TERM --kill-after=15s 360s python probe.py --mode "$mode"
done
The probe checks the tested TE version and requires four ranks on one node. The
observer checks the pinned private Python dispatch signature and forwards all
arguments unchanged; the failing capacity check is in the native TE backend.
Its argument forwarding, return identity, exception propagation and weak-reference
lifetime behavior were also checked against the installed source signature before
the GPU experiments.
audit.py
"""Observe EP tensor lifetime without retaining tensors or changing the cache."""
import atexit
import functools
import inspect
import json
import os
import time
import weakref
from pathlib import Path
class Audit:
def __init__(self):
self.created = 0
self.dead = 0
self.peak_live = 0
self.attempts = 0
self.succeeded = 0
self.live = {}
self.prepared = set()
self.rank = int(os.environ.get("SLURM_PROCID", os.environ.get("RANK", "0")))
self.output = None
directory = os.environ.get("EP_AUDIT_DIR")
if directory:
Path(directory).mkdir(parents=True, exist_ok=True)
self.output = (Path(directory) / f"rank-{self.rank}-pid-{os.getpid()}.jsonl").open("a", buffering=1)
atexit.register(self.emit, "process_exit")
def snapshot(self):
return {
"created": self.created,
"dead": self.dead,
"live_tensors": len(self.live),
"live_addresses": len({item[1] for item in self.live.values()}),
"peak_live": self.peak_live,
"prepare_attempts": self.attempts,
"prepare_successes": self.succeeded,
"distinct_prepared_addresses": len(self.prepared),
}
def emit(self, event, **extra):
row = {"event": event, "rank": self.rank, "pid": os.getpid(), "time": time.time(), **self.snapshot(), **extra}
line = json.dumps(row, sort_keys=True)
print("EP_AUDIT " + line, flush=True)
if self.output:
self.output.write(line + "\n")
def observe(self, tensor):
self.created += 1
serial = self.created
ptr = tensor.data_ptr()
def released(_ref):
self.live.pop(serial, None)
self.dead += 1
self.live[serial] = (weakref.ref(tensor, released), ptr)
self.peak_live = max(self.peak_live, len(self.live))
def install(ep):
if getattr(ep, "_cm_ep_audited", False):
return ep._cm_ep_audit_state
original_init = ep.EpBuffer.__init__
original_dispatch = ep._ep_prepare_and_dispatch_fwd
original_finalize = ep.ep_finalize
expected = ("tokens", "topk_weights", "topk_idx", "buffer", "recv_tokens", "recv_topk_weights")
if tuple(inspect.signature(original_dispatch).parameters) != expected:
raise RuntimeError("EP observer requires the pinned TE prepare/dispatch signature")
state = Audit()
@functools.wraps(original_init)
def initialize(self, *args, **kwargs):
original_init(self, *args, **kwargs)
state.observe(self.handle_mem)
@functools.wraps(original_dispatch)
def dispatch(tokens, topk_weights, topk_idx, buffer, recv_tokens, recv_topk_weights):
ptr = buffer.handle_mem.data_ptr()
state.attempts += 1
sampled = state.attempts <= 4 or state.attempts % 128 == 0
extra = {
"ptr": ptr,
"seen_before": ptr in state.prepared,
"eager": buffer.eager,
"handle_bytes": buffer.handle_mem.numel(),
}
if sampled:
state.emit("before_prepare", **extra)
try:
result = original_dispatch(tokens, topk_weights, topk_idx, buffer, recv_tokens, recv_topk_weights)
except BaseException as exc:
state.emit("prepare_error", error=str(exc), **extra)
raise
state.succeeded += 1
state.prepared.add(ptr)
if sampled:
state.emit("after_prepare", **extra)
return result
@functools.wraps(original_finalize)
def finalize(*args, **kwargs):
state.emit("before_finalize")
result = original_finalize(*args, **kwargs)
state.prepared.clear()
state.emit("after_finalize")
return result
ep.EpBuffer.__init__ = initialize
ep._ep_prepare_and_dispatch_fwd = dispatch
ep.ep_finalize = finalize
ep._cm_ep_audited = True
ep._cm_ep_audit_state = state
state.emit(
"installed", te_ep_source=ep.__file__, cache_cap=os.environ.get("NVTE_EP_HANDLE_CACHE_SIZE", "4096(default)")
)
return state
probe.py
"""TE-only sequential dispatch/combine/backward with measured tensor lifetime.
The churn mode allocates unrelated byte tensors after completed EP buffers die.
It never edits EpBuffer storage, private cache state, or the default cache cap.
This models allocator address churn while keeping only one EP call in flight.
"""
import argparse
import gc
import os
import sys
from datetime import timedelta
from importlib.metadata import version
import torch
import torch.distributed as dist
from audit import install
from transformer_engine.pytorch import ep
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["reuse", "fresh", "churn"], required=True)
parser.add_argument("--steps", type=int, default=4352)
args = parser.parse_args()
rank = int(os.environ.get("SLURM_PROCID", os.environ.get("RANK", "0")))
world = int(os.environ.get("SLURM_NTASKS", os.environ.get("WORLD_SIZE", "1")))
local = int(os.environ.get("SLURM_LOCALID", os.environ.get("LOCAL_RANK", "0")))
if world != 4 or int(os.environ.get("SLURM_JOB_NUM_NODES", "1")) != 1:
raise RuntimeError(f"This probe requires four ranks on one node; got world={world}")
device = 0 if torch.cuda.device_count() == 1 else local
torch.cuda.set_device(device)
os.environ.update(RANK=str(rank), WORLD_SIZE=str(world))
if os.environ.get("NVTE_EP_HANDLE_CACHE_SIZE") is not None:
raise RuntimeError("This diagnostic requires the unmodified default handle cap")
if not version("transformer_engine").startswith("2.21.0+d77ef88c"):
raise RuntimeError("Unexpected TE revision")
dist.init_process_group("nccl", timeout=timedelta(seconds=120), device_id=torch.device("cuda", device))
state = install(ep)
state.emit(
"probe_start",
mode=args.mode,
steps=args.steps,
torch=version("torch"),
te=version("transformer_engine"),
world=world,
local_rank=local,
device=device,
)
tokens_count, hidden, topk, local_experts = 64, 512, 2, 2
ep.ep_bootstrap(
dist.group.WORLD,
num_experts=local_experts * world,
max_tokens_per_rank=tokens_count,
hidden_dim=hidden,
num_topk=topk,
recv_capacity_per_rank=None,
zero_copy=False,
)
indices = (
(
torch.arange(tokens_count, device="cuda")[:, None]
+ rank * tokens_count
+ torch.arange(topk, device="cuda")[None, :]
)
% (local_experts * world)
).long()
weights = torch.full((tokens_count, topk), 1 / topk, device="cuda", dtype=torch.float32)
original = (torch.arange(tokens_count * hidden, device="cuda").reshape(tokens_count, hidden) % 13 / 16).bfloat16()
fillers = []
retained_bytes = 0
reuse = None
completed = 0
hit_cap = False
def make_buffer():
return ep.EpBuffer(
top_k=topk,
max_tokens_per_rank=tokens_count,
hidden_dim=hidden,
num_local_experts=local_experts,
recv_capacity_per_rank=None,
alignment=0,
)
def one_step(buffer):
x = original.detach().clone().requires_grad_(True)
received, recv_weights, _counts = ep.ep_dispatch(buffer, x, indices, weights)
# Dispatch weights are FP32; combine's public API requires BF16 tokens.
weighted = (received * recv_weights[:, None]).to(received.dtype)
out = ep.ep_combine(buffer, weighted, num_local_tokens=tokens_count)
out.float().sum().backward()
torch.cuda.synchronize()
errors = torch.stack([(out - original).abs().max().float(), (x.grad - 1).abs().max().float()])
dist.all_reduce(errors, op=dist.ReduceOp.MAX)
if errors.max().item() > 0.0625:
raise AssertionError(f"Round-trip/gradient mismatch: {errors.tolist()}")
if args.mode == "reuse":
reuse = make_buffer()
for step in range(args.steps):
buffer = reuse if reuse is not None else make_buffer()
ptr, handle_bytes = buffer.handle_mem.data_ptr(), buffer.handle_mem.numel()
# Coordinate before a known cache miss at capacity. Only full ranks invoke
# the asserting operation, avoiding a peer hanging inside an EP collective.
full = ptr not in state.prepared and len(state.prepared) == 4096
any_full = torch.tensor(int(full), device="cuda")
dist.all_reduce(any_full, op=dist.ReduceOp.MAX)
if any_full.item():
native_error = "peer reached capacity"
if full:
try:
ep.ep_prepare(buffer, indices)
except RuntimeError as exc:
native_error = str(exc)
if "live handle limit (4096)" not in native_error:
raise
else:
raise AssertionError("Observed pointers did not predict native cache occupancy")
state.emit(
"native_cap_confirmed",
step=step,
local_full=full,
error=native_error,
retained_unrelated_bytes=retained_bytes,
handle_bytes=handle_bytes,
)
hit_cap = True
del buffer
break
one_step(buffer)
completed += 1
del buffer
if args.mode != "reuse" and state.live:
gc.collect()
if args.mode != "reuse" and state.live:
raise AssertionError(f"EP handle tensor survived a completed step: {state.snapshot()}")
if args.mode == "churn":
# Normal PyTorch allocation, unrelated to TE: occupies allocator space
# after the EP tensor is dead. Bound total storage to 2 GiB.
if retained_bytes + handle_bytes > 2 * 1024**3:
raise RuntimeError("Churn storage exceeded its diagnostic budget")
filler = torch.empty(handle_bytes, dtype=torch.uint8, device="cuda")
fillers.append(filler)
retained_bytes += handle_bytes
if step < 4 or (step + 1) % 128 == 0:
state.emit("step_completed", step=step + 1, retained_unrelated_bytes=retained_bytes)
state.emit(
"probe_result",
mode=args.mode,
completed=completed,
native_cap_reached=hit_cap,
retained_unrelated_bytes=retained_bytes,
mcore_imported="megatron.core" in sys.modules,
)
dist.barrier()
ep.ep_finalize()
dist.destroy_process_group()
if __name__ == "__main__":
main()
Expected behavior
Sequential supported EP calls with one live buffer should not exhaust
a cache described as a live-handle limit. A repair must preserve handles still
needed by backward or CUDA graphs. Raising the cap can postpone exhaustion; no
repair has been validated in this investigation.
Environment overview (please complete the following information)
- Environment location: Containerized Linux environment. The isolated reproducer uses four processes on one node.
- Method of Transformer Engine install: Preinstalled in the container. The diagnostic adds Python observer/reproducer code without installing or rebuilding TE. The original installation command is unavailable.
- Docker pull/run commands: The tested workload used the Slurm launch described above; no direct
docker runcommand was used. A stock upstream CI container has not been tested.
Environment details
Development builds were used; package build metadata is omitted below.
- OS version: Linux ARM64; exact container distribution/version was not captured.
- PyTorch version:
2.15.0a0, reporting public source revisionfd23299d9690b7cf08bba0b3e3f89195026c455a. - Python version: 3.12.3.
- Transformer Engine version:
2.21.0+d77ef88c, public revisiond77ef88cb2687a8b1943daf5e36a7956812f461f. - CUDA version: 13.5.
- CUDNN version: Backend 9.28.0; frontend 1.31.0.
- NCCL version: 2.32.3.
- Training integration only: Bridge
2b1c8ccd7285cfd09b82afd523859dbe04d754e4; MCorece852bfb93f364f64a556602fc71a5d9ed2c377e.
Device details
- GPU model: NVIDIA GB300.
- Isolated TE reproduction: Four GPUs on one node, one process per GPU.
- Training reproduction: Eight GPUs across two nodes, four GPUs per node.
Additional context
Isolated reproduction results
The source above executes sequential dispatch/combine/backward rounds.
It does not import MCore, capture CUDA graphs, change the cache cap or edit handle
storage. Weak references observe handle-tensor lifetime without retaining tensors.
All four ranks give identical results:
| Mode | Completed rounds per rank | Distinct prepared addresses | Peak live handle tensors | Outcome |
|---|---|---|---|---|
| Reuse one EpBuffer | 4352 | 1 | 1 | Completes |
| New EpBuffer each round; natural allocator reuse | 4352 | 1 | 1 | Completes |
| New EpBuffer plus unrelated allocation churn | 4096 | 4096 | 1 | Next prepare hits native cap |
In the churn case, ordinary unrelated torch.empty allocations occupy freed
allocator addresses after backward and CUDA synchronization. Total unrelated
storage is 58 MiB per rank. At the exception there are 4097 created handle
tensors, 4096 destroyed and only one live. Dropping the rejected buffer leaves
zero live handle tensors.
The probe coordinates ranks before the terminal call and deliberately invokes
public ep_prepare on the new buffer to avoid leaving peers inside dispatch.
This invokes the native backend and raises the capacity error shown above.
All completed rounds passed the defined all-rank output/input-gradient absolute
error check (tolerance 0.0625; per-step error values were not retained). All four
processes exit zero because the probe treats the expected native assertion as a
confirmed diagnostic result. The control modes do not reach the cap.
Same mechanism in training
Qwen3.5-VL-35B-A3B BF16 on 8 GB300 GPUs (2 nodes × 4), MBS4 / GBS512 /
sequence 4096, EP8, TP/PP/CP/ETP1, MTP1 and NCCL EP completes six iterations,
then fails during iteration 7. The EP call is eager despite partial CUDA graphs
elsewhere in the model. moe_ncclep_zero_copy=false; the cache cap is unchanged.
A weak-reference observer at the native failure on rank 7 records:
{
"created": 4202,
"dead": 4142,
"live_tensors": 60,
"peak_live": 80,
"prepare_attempts": 4202,
"prepare_successes": 4201,
"distinct_prepared_addresses": 4096,
"seen_before": false,
"eager": true
}
All eight ranks peak at 80 live handle tensors. The six completed iterations
report finite LM/MTP losses and zero skipped/NaN iterations. The workload does
not complete shutdown after the native exception and is eventually cancelled at
the 30-minute scheduler limit. The shutdown hang is a separate observation; the
handle-cap exception is the first recorded runtime failure.
Source mechanism and coverage
The pinned native cache
is keyed by the raw handle_mem address. A new address inserts another native
handle; same-address reuse does not grow the map. The inspected PyTorch path has
no per-buffer release when its tensor dies; entries persist until finalization,
except for replacement at the same address when configuration changes. The cap
therefore limits historical addresses in these runs, rather than concurrent
Python buffers. The observer counts distinct successful addresses; it does not
read the native map directly. The native assertion confirms the capacity boundary.
MCore allocates a fresh buffer per dispatch,
which exposes the address churn in training. The isolated case establishes that
MCore and CUDA graphs are not required to reproduce it.
The inspected upstream multi-iteration test
uses the same buffer for five rounds. Our reuse controls also pass for 4352 rounds.
That test does not cover this failure mode; we have not audited every upstream CI
workload or reproduced in the stock upstream CI image.
- Lingua principale
- Python
- Stelle
- 3.6k
- Fork
- 872
- Merge medio
- 4g 5h
- PR unite (30g)
- 62
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di NVIDIA/TransformerEngine
-
[PyTorch] fp8_cs_quantize fake implementation returns a vector inverse scale instead of a scalarForse di nuovo libera @sanjana658 l’ha presa 19 giorni fa e non c’è nessuna pull request aperta. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
NVIDIA/TransformerEngine#3636 · 2 commenti ·
I maintainer di solito rispondono entro 2 giorni
-
[Bug] Backend selection picks FA3 for training with head_dim_qk=192 / v_head_dim=128, but FA3 backward cannot run itForse di nuovo libera @modelpath-dev l’ha presa 53 giorni fa e non c’è nessuna pull request aperta. Apertaattention
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
NVIDIA/TransformerEngine#3481 · 4 commenti ·
I maintainer di solito rispondono entro 2 giorni
-
Increase MAX_TENSOR_NUMApertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
NVIDIA/TransformerEngine#2189 · 7 commenti · 5 reazioni ·
I maintainer di solito rispondono entro 2 giorni
-
[PyTorch] LayerNormLinear drops gradients from gathered LayerNorm output under TP/SPForse già presa @ksivaman l’ha presa 1 giorno fa. Aperta
NVIDIA/TransformerEngine#3741 · 1 assegnatario ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 25/100
NVIDIA/TransformerEngine#3729 ·
I maintainer di solito rispondono entro 2 giorni
Tutte le issue di NVIDIA/TransformerEngine
Issue simili
-
component:benchmark
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
agentic-os-org/ANOLISA#9177 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
distrochooser/distrochooser#520 ·
I maintainer di solito rispondono entro 2 giorni
-
Status: Needs confirmation Status: Needs triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 64/100
I maintainer di solito rispondono entro 1 giorno
-
[BUG] Published Docker images lack the redis extra: HEADROOM_COMPRESS_SESSION_STORE stops startupForse già presa @AlessandroSpallina l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
headroomlabs-ai/headroom#4513 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 65/100
codespell-project/codespell#4060 ·
I maintainer di solito rispondono entro 1 giorno