Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン
#3,742 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 2 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
45/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
python, pytorch

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

説明

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.

主要言語
Python
スター
3.6k
フォーク
872
平均マージ
4日 19時間
マージ済み PR(30日)
70

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

NVIDIA/TransformerEngine のほかの issue

NVIDIA/TransformerEngine の issue をすべて見る

似ている issue

Python の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。