Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

[PyTorch] NCCL EP handle cache reaches its 4096 limit with only one live EpBuffer under allocator address churn

Aperta
#3,742 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

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
Stack tecnologico
python, pytorch

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

bug

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 run command was used. A stock upstream CI container has not been tested.

Environment details

Development builds were used; package build metadata is omitted below.

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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di NVIDIA/TransformerEngine

Tutte le issue di NVIDIA/TransformerEngine

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.