mirror of
https://github.com/microsoft/mscclpp.git
synced 2026-07-15 11:44:56 +00:00
## Summary
Adds a unified low-latency (LL) expert-parallel benchmark for comparing
mscclpp EP against NVIDIA NCCL-EP on equal footing, and fixes an LL
combine
performance regression in the feature/ep kernels.
## Commits
- **ep/ll: scale combine grid to numCombinedTokens (fix combine
regression).**
The LL combine host launched a fixed grid of
`ceil(numExperts/kNumWarpGroups)`
(= 43 SMs for 128 experts), but `combineRecv`'s per-token weighted
reduction
strides `tokenIdx` by the grid size, so it only used 43 blocks for 128
tokens.
Scale the grid to `numCombinedTokens` (capped by the device SM count).
Extra
blocks are recv-only (send is guarded by `responsibleExpertIdx <
numExperts`)
and every block still hits `cg::this_grid().sync()`. Measured (e128 t128
d7168
k8, 1-node): combine avg 56 → 43 us (-24%), min 50 → 38 us. Bit-exact
(`test_low_latency_multirank.py`).
- **ep(bench): unified driver + three backends** — mscclpp (Python
MoECommunicator), mscclpp-cpp (pure C++ MoERuntime), nccl-ep
(`ep_bench`).
- **ep(bench): pure-C++ LL benchmark** (`mscclpp_ep_bench.cu`) calling
`MoERuntime::dispatch/combine` directly, with CUPTI kernel timing, built
via CMake.
## Build
No impact on the core library build: the benchmark's
`test/python/ep/CMakeLists.txt` is standalone (no `add_subdirectory`
from any
parent CMake) and this PR does not touch the top-level `CMakeLists.txt`
/
`pyproject.toml` / `setup.py`. The only library change is the combine
grid size.
The Python driver (`run_ep_bench.py`) and the mscclpp Python backend
(`ep_bench_ll.py`) need **no build**. Only the **mscclpp-cpp** backend
needs a
one-time standalone build (it recompiles the two LL translation units +
links the installed `libmscclpp.so`):
```bash
cmake -S test/python/ep -B build \
-DMSCCLPP_EP_NUM_MAX_NVL_PEERS=4 -DCMAKE_CUDA_ARCHITECTURES=100 # GB200 sm_100
cmake --build build -j
# -> build/mscclpp_ep_bench
```
Requires nvcc/CUDA, MPI, and CUPTI (all `find_package REQUIRED`).
## Usage
Common workload: `-e 128 -t 128 -d 7168 -k 8 -w 10 -i 100`. The driver
prints a
unified summary with **host-observed** and **kernel-only** (CUPTI) rows.
```bash
# mscclpp (Python MoECommunicator) — no build needed
python test/python/ep/run_ep_bench.py --ep-lib mscclpp -e 128 --cupti-inproc
# mscclpp-cpp (pure C++ MoERuntime) — after the Build step above
python test/python/ep/run_ep_bench.py --ep-lib mscclpp-cpp -e 128 -t 128 -d 7168 -k 8 -w 10 -i 100
# 2 / 4-node (one NVLink domain): list peer IPs; the driver builds the hostfile + mpirun
python test/python/ep/run_ep_bench.py --ep-lib mscclpp-cpp -e 128 -t 128 -d 7168 -k 8 -w 10 -i 100 \
--nodes "10.0.0.1 10.0.0.2 10.0.0.3 10.0.0.4"
# nccl-ep (NVIDIA reference ep_bench)
python test/python/ep/run_ep_bench.py --ep-lib nccl-ep -e 128 -t 128 -d 7168 -k 8 -w 10 -i 100 \
--nccl-lib-path /path/to/nccl/build/lib
# all backends side-by-side
python test/python/ep/run_ep_bench.py --ep-lib all -e 128 -t 128 -d 7168 -k 8 -w 10 -i 100 \
--nccl-lib-path /path/to/nccl/build/lib
# add --kernel-only for just the CUPTI rows, --dry-run to print the commands
```
Useful flags: `--nodes "<ip ...>"`, `--nproc-per-node 4`,
`--kernel-only`,
`--dry-run`, `--cupti-inproc`, `--mscclpp-cpp-bench <path>`,
`--nccl-ep-bench <path>`,
`--nccl-lib-path <dir>`, `--hpcx <dir>`, `--iface <nic>`.
## Validation
Combine fix bit-exact and benchmarked 1/2/4-node on GB200 NVL72
(sm_100);
dispatch/combine host + kernel times on par with NCCL-EP at all scales.
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
611 lines
27 KiB
Python
611 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) Microsoft Corporation.
|
|
# Licensed under the MIT License.
|
|
"""Unified low-latency EP benchmark for MSCCL++ EP — an apples-to-apples port of
|
|
NCCL-EP's ``contrib/nccl_ep/ep_bench.cu`` low-latency (LL) flow, with the NCCL-EP
|
|
API (``ncclEpDispatch`` / ``ncclEpCombine``) replaced by the MSCCL++ EP high-level
|
|
``MoECommunicator.dispatch`` / ``MoECommunicator.combine`` (feature/ep API).
|
|
|
|
Why this exists
|
|
---------------
|
|
``ep_bench`` is the reference NCCL-EP micro-benchmark. To compare MSCCL++ EP
|
|
against it fairly we must measure *the same thing the same way*. This script is a
|
|
line-for-line reimplementation of ``ep_bench``'s LL measurement methodology, only
|
|
swapping the collective API underneath:
|
|
|
|
* **Paired** dispatch→sync→combine→sync→barrier per iteration (``runPairedBenchmark``).
|
|
* **Per-iteration CUDA events** recorded on the stream *around each kernel launch*;
|
|
the ``cudaStreamSynchronize`` and ``MPI_Barrier`` (here ``dist.barrier``) happen
|
|
**outside** the timed region, exactly as in ``ep_bench``.
|
|
* **Skip the first timed iteration** (warmup outlier) — matches ``ep_bench``'s
|
|
``calc_stats`` which trims ``times[0]`` when ``num_iters > 1``.
|
|
* **Byte accounting** identical to ``calculateLowLatencyBytes``:
|
|
``bytes = num_valid_selections * hidden * 2`` (BF16) for *both* dispatch and
|
|
combine, where ``num_valid_selections = count(topk_idx >= 0)``.
|
|
* **Cross-rank reduction** identical to ``printLowLatencyResults``: latency
|
|
``avg = mean``, ``min = MIN``, ``max = MAX``; per-rank throughput min/max are
|
|
tagged with the owning rank (``MPI_MINLOC`` / ``MPI_MAXLOC`` analog).
|
|
* **Output** mirrors ``ep_bench``'s ``=== Summary (Low Latency, across N ranks) ===``
|
|
block so the two runs can be diffed directly.
|
|
|
|
CLI mirrors ``ep_bench``'s LL-relevant flags (long + short):
|
|
-t/--num-tokens tokens per rank (ep_bench LL default 128)
|
|
-d/--hidden hidden dim (7168)
|
|
-k/--num-topk top-k experts per token (8)
|
|
-e/--num-experts global experts (256)
|
|
-w/--num-warmup warmup iterations (10)
|
|
-i/--num-iters timed iterations (50)
|
|
|
|
Fidelity note
|
|
-------------
|
|
``ep_bench`` is C++/MPI; MSCCL++ EP's LL API is Python/torch, so this harness is
|
|
Python. The *measurement* is identical: both bracket the same dispatch/combine
|
|
kernels with CUDA events and report GPU-side host-observed time. The only
|
|
difference is host-side launch latency, which sits *outside* the recorded events
|
|
for the async kernels and is the same definitional gap ``ep_bench`` has (larger
|
|
in Python, but not counted in the kernel elapsed time). For a pure kernel number,
|
|
run under ``nsys``/CUPTI as with ``ep_bench``'s ``--- Kernel-only ---`` section.
|
|
|
|
Launch
|
|
------
|
|
Manual per-rank env (DSM hostnames break torchrun rendezvous on these nodes):
|
|
RANK=.. LOCAL_RANK=.. WORLD_SIZE=.. MASTER_ADDR=.. MASTER_PORT=.. \
|
|
python ep_bench_ll.py -t 128 -d 7168 -k 8 -e 256 -w 10 -i 50
|
|
Single node (4/8 GPU):
|
|
torchrun --standalone --nproc_per_node=4 ep_bench_ll.py -e 128
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import random
|
|
|
|
# Quiet ProcessGroupNCCL's heartbeat monitor before importing torch.distributed
|
|
# (same rationale as test_low_latency_multirank.py).
|
|
os.environ.setdefault("TORCH_NCCL_ENABLE_MONITORING", "0")
|
|
|
|
import torch
|
|
import torch.distributed as dist
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# CLI — mirrors ep_bench.cu's getopt flags for the LL path.
|
|
# ----------------------------------------------------------------------------
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(
|
|
description="MSCCL++ EP low-latency benchmark (ep_bench parity)",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
# Env fallbacks keep the existing MSCCLPP_EP_BENCH_* launchers working.
|
|
p.add_argument(
|
|
"-a",
|
|
"--algorithm",
|
|
default="ll",
|
|
choices=["ll", "low-latency"],
|
|
help="algorithm mode (only LL is implemented here)",
|
|
)
|
|
p.add_argument(
|
|
"-t",
|
|
"--num-tokens",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_TOKENS", "128")),
|
|
help="tokens per rank (ep_bench LL max_tokens_per_rank)",
|
|
)
|
|
p.add_argument(
|
|
"-d",
|
|
"--hidden",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_HIDDEN", "7168")),
|
|
help="hidden dimension",
|
|
)
|
|
p.add_argument(
|
|
"-k",
|
|
"--num-topk",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_TOPK", "8")),
|
|
help="top-k experts per token",
|
|
)
|
|
p.add_argument(
|
|
"-e",
|
|
"--num-experts",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_EXPERTS", "256")),
|
|
help="global number of experts",
|
|
)
|
|
p.add_argument(
|
|
"-w",
|
|
"--num-warmup",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_WARMUP", "10")),
|
|
help="warmup iterations",
|
|
)
|
|
p.add_argument(
|
|
"-i",
|
|
"--num-iters",
|
|
type=int,
|
|
default=int(os.environ.get("MSCCLPP_EP_BENCH_ITERS", "50")),
|
|
help="timed iterations",
|
|
)
|
|
p.add_argument(
|
|
"--no-kernel-timing",
|
|
dest="kernel_timing",
|
|
action="store_false",
|
|
help="disable the CUPTI/torch.profiler kernel-only measurement pass "
|
|
"(on by default, mirrors ep_bench's CUPTI KernelTimer)",
|
|
)
|
|
p.add_argument(
|
|
"--cupti-region",
|
|
action="store_true",
|
|
help="bracket ONLY the timed loop with cudaProfilerStart/Stop (for nsys "
|
|
"--capture-range=cudaProfilerApi) so an external CUPTI collector times "
|
|
"exactly the post-warmup dispatch/combine kernels, like ep_bench's "
|
|
"KernelTimer.start()-after-warmup. Skips the in-process torch.profiler "
|
|
"pass; kernel numbers come from nsys.",
|
|
)
|
|
p.add_argument(
|
|
"--cupti-inproc",
|
|
action="store_true",
|
|
help="use the in-process CUPTI collector (libcupti_kernel_timer.so, a faithful "
|
|
"port of ep_bench's KernelTimer): CUPTI Activity API records per-kernel GPU "
|
|
"time over the post-warmup timed loop, near-zero host perturbation, and works "
|
|
"multinode without nsys. Uses CUPTI_ACTIVITY_KIND_KERNEL (which -- unlike "
|
|
"CONCURRENT_KERNEL -- captures mscclpp's cudaLaunchCooperativeKernel LL kernels); "
|
|
"matches the mangled name substring dispatch/combine. Replaces the torch.profiler pass.",
|
|
)
|
|
p.add_argument("--seed", type=int, default=0xB3C4, help="per-rank RNG seed base")
|
|
return p.parse_args()
|
|
|
|
|
|
def init_dist():
|
|
rank = int(os.environ["RANK"])
|
|
world_size = int(os.environ["WORLD_SIZE"])
|
|
local_rank = int(os.environ.get("LOCAL_RANK", rank))
|
|
torch.cuda.set_device(local_rank)
|
|
dist.init_process_group(
|
|
backend="nccl",
|
|
init_method=f"tcp://{os.environ.get('MASTER_ADDR', '127.0.0.1')}:{os.environ.get('MASTER_PORT', '29500')}",
|
|
world_size=world_size,
|
|
rank=rank,
|
|
)
|
|
return rank, world_size, local_rank, dist.new_group(list(range(world_size)))
|
|
|
|
|
|
def _reduce_scalar(value: float, op, group) -> float:
|
|
t = torch.tensor([value], dtype=torch.float64, device="cuda")
|
|
dist.all_reduce(t, op=op, group=group)
|
|
return t.item()
|
|
|
|
|
|
def _gather_scalars(value: float, num_ranks: int, group) -> list:
|
|
t = torch.tensor([value], dtype=torch.float64, device="cuda")
|
|
out = [torch.zeros_like(t) for _ in range(num_ranks)]
|
|
dist.all_gather(out, t, group=group)
|
|
return [float(x.item()) for x in out]
|
|
|
|
|
|
def _profile_paired_kernels(dispatch_fn, combine_fn, iters: int, stream, group, rank: int):
|
|
"""Kernel-only dispatch/combine device time (us/iter) via torch.profiler.
|
|
|
|
Mirrors ep_bench's CUPTI ``KernelTimer``: it profiles the SAME paired
|
|
``dispatch -> sync -> combine -> sync -> barrier`` loop used for the
|
|
host-observed measurement. Profiling the *paired* loop (rather than isolated
|
|
dispatch-only / combine-only loops) is essential: the LL dispatch kernel
|
|
ends with a cross-rank receive spin-wait, and without the per-iter barrier
|
|
the ranks drift out of lockstep so that spin balloons to milliseconds on the
|
|
laggards. The barrier keeps every rank aligned at each iteration boundary, so
|
|
the recv-wait stays bounded -- exactly why ep_bench times the paired loop.
|
|
|
|
Kernels are bucketed by name substring ``dispatch`` / ``combine`` (the mscclpp
|
|
LL kernels demangle to ``mscclpp::ep::low_latency::dispatch<...>`` /
|
|
``::combine<...>``), matching ep_bench's ``get_avg_us("dispatch"/"combine")``.
|
|
All other device activity (the pacing barrier's NCCL kernel, memcpy/memset)
|
|
is ignored.
|
|
"""
|
|
from torch.profiler import profile, ProfilerActivity
|
|
|
|
torch.cuda.synchronize()
|
|
with profile(activities=[ProfilerActivity.CUDA]) as prof:
|
|
for _ in range(iters):
|
|
dout = dispatch_fn()
|
|
stream.synchronize()
|
|
combine_fn(dout)
|
|
stream.synchronize()
|
|
dist.barrier(group=group)
|
|
torch.cuda.synchronize()
|
|
|
|
disp_us = 0.0
|
|
comb_us = 0.0
|
|
dbg = []
|
|
for e in prof.key_averages():
|
|
dev_us = getattr(e, "self_device_time_total", None)
|
|
if dev_us is None:
|
|
dev_us = getattr(e, "self_cuda_time_total", 0.0)
|
|
if not dev_us or dev_us <= 0:
|
|
continue
|
|
low = str(e.key).lower()
|
|
if "memcpy" in low or "memset" in low:
|
|
continue # CUPTI KernelTimer counts KERNEL activities only
|
|
if "dispatch" in low:
|
|
disp_us += dev_us
|
|
elif "combine" in low:
|
|
comb_us += dev_us
|
|
dbg.append((dev_us, str(e.key)))
|
|
|
|
if os.environ.get("MSCCLPP_EP_KDEBUG", "0") == "1" and rank == 0:
|
|
dbg.sort(reverse=True)
|
|
print(f"[kdebug] top device activities (self device us/iter over {iters} iters):", flush=True)
|
|
for us, name in dbg[:10]:
|
|
print(f" {us / iters:8.2f} us/iter {name[:90]}", flush=True)
|
|
|
|
return disp_us / iters, comb_us / iters
|
|
|
|
|
|
class _InProcCupti:
|
|
"""In-process CUPTI kernel timer, a faithful analog of ep_bench's KernelTimer.
|
|
|
|
Loads ``libcupti_kernel_timer.so`` (built from cupti_kernel_timer.cpp, sitting
|
|
next to this file) via ctypes and drives the CUPTI Activity API directly:
|
|
``start()`` after warmup, ``stop()`` after the timed loop, then
|
|
``avg_us("dispatch"/"combine")`` buckets recorded kernels by mangled-name
|
|
substring -- exactly ep_bench's methodology, with near-zero host perturbation
|
|
(out-of-band buffer callbacks), so the LL dispatch recv-spin is measured
|
|
cleanly rather than being serialized by an in-process tracer.
|
|
"""
|
|
|
|
def __init__(self):
|
|
import ctypes
|
|
import os as _os
|
|
|
|
so = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "libcupti_kernel_timer.so")
|
|
self.lib = ctypes.CDLL(so)
|
|
self.lib.kt_start.restype = ctypes.c_int
|
|
self.lib.kt_stop.restype = ctypes.c_int
|
|
self.lib.kt_get_avg_us.restype = ctypes.c_double
|
|
self.lib.kt_get_avg_us.argtypes = [ctypes.c_char_p]
|
|
self.lib.kt_get_count.restype = ctypes.c_long
|
|
self.lib.kt_get_count.argtypes = [ctypes.c_char_p]
|
|
|
|
def start(self) -> int:
|
|
return int(self.lib.kt_start())
|
|
|
|
def stop(self) -> int:
|
|
return int(self.lib.kt_stop())
|
|
|
|
def avg_us(self, substr: str) -> float:
|
|
return float(self.lib.kt_get_avg_us(substr.encode()))
|
|
|
|
def count(self, substr: str) -> int:
|
|
return int(self.lib.kt_get_count(substr.encode()))
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
rank, num_ranks, local_rank, group = init_dist()
|
|
from mscclpp import CommGroup
|
|
import mscclpp.ep as ep
|
|
from mscclpp.ep._cpp import get_low_latency_rdma_size_hint
|
|
|
|
ep_group = CommGroup(torch_group=group)
|
|
|
|
num_tokens = args.num_tokens
|
|
hidden = args.hidden
|
|
num_topk = args.num_topk
|
|
num_experts = args.num_experts
|
|
warmup = args.num_warmup
|
|
iters = args.num_iters
|
|
assert num_experts % num_ranks == 0, "num_experts must be divisible by num_ranks"
|
|
num_local_experts = num_experts // num_ranks
|
|
|
|
# bf16 precision anchor (same convention as test_low_latency_multirank.py).
|
|
rank_offset = 128
|
|
assert num_ranks - rank_offset < 257, "too many ranks for bf16 precision anchor"
|
|
|
|
torch.manual_seed(args.seed + rank)
|
|
random.seed(args.seed + rank)
|
|
|
|
# ---- Inputs (mirror ep_bench setupLowLatencyTensors: BF16 tokens + routing).
|
|
x = torch.ones((num_tokens, hidden), dtype=torch.bfloat16, device="cuda") * (rank - rank_offset)
|
|
x[:, -128:] = torch.arange(num_tokens, device="cuda").to(torch.bfloat16).view(-1, 1)
|
|
scores = torch.randn((num_tokens, num_experts), dtype=torch.float32, device="cuda").abs() + 1
|
|
topk_idx = torch.topk(scores, num_topk, dim=-1, largest=True, sorted=True)[1].to(torch.int64)
|
|
topk_weights = torch.randn((num_tokens, num_topk), dtype=torch.float32, device="cuda").abs()
|
|
|
|
# ep_bench byte accounting: num_valid_selections = count(topk_idx >= 0). We
|
|
# keep every selection valid (a full LL load), so this equals num_tokens*top_k.
|
|
num_valid_selections = int((topk_idx >= 0).sum().item())
|
|
disp_bytes = num_valid_selections * hidden * 2 # BF16
|
|
comb_bytes = num_valid_selections * hidden * 2 # BF16 (symmetric, per ep_bench)
|
|
|
|
num_rdma_bytes = get_low_latency_rdma_size_hint(num_tokens, hidden, num_ranks, num_experts)
|
|
if rank == 0:
|
|
print(
|
|
f"[cfg] algorithm=LOW_LATENCY num_ranks={num_ranks} tokens/rank={num_tokens} hidden={hidden} "
|
|
f"num_experts={num_experts} top_k={num_topk} warmup={warmup} iters={iters} "
|
|
f"num_rdma_bytes={num_rdma_bytes}",
|
|
flush=True,
|
|
)
|
|
|
|
# High-level MoE communicator (feature/ep). LOW_LATENCY mode selects the LL
|
|
# backend; dispatch/combine run the full (send+recv) op inline on the stream.
|
|
moe_comm = ep.MoECommunicator(
|
|
comm=ep_group,
|
|
num_experts=num_experts,
|
|
num_local_experts=num_local_experts,
|
|
hidden_size=hidden,
|
|
topk=num_topk,
|
|
max_tokens_per_rank=num_tokens,
|
|
mode=ep.MoEMode.LOW_LATENCY,
|
|
num_rdma_qps_per_rank=max(1, num_experts // num_ranks),
|
|
)
|
|
assert moe_comm.is_available()
|
|
if rank == 0:
|
|
print(f"[cfg] MoECommunicator is_internode={moe_comm.is_internode()}", flush=True)
|
|
|
|
# ---- Hoist dispatch/combine output tensors out of the timed loop (ep_bench
|
|
# preallocates all EP tensors before benchmarking; matching that keeps the
|
|
# timed region kernel-bound rather than allocator-bound). The communicator
|
|
# owns its src_info/layout_range/count buffers internally; we only supply the
|
|
# dispatch output buffer and the combine output tensor.
|
|
output_buffer = torch.empty(
|
|
(num_local_experts, num_ranks * num_tokens, hidden), dtype=torch.bfloat16, device="cuda"
|
|
)
|
|
out = torch.empty((num_tokens, hidden), dtype=torch.bfloat16, device="cuda")
|
|
|
|
def dispatch_fn():
|
|
# MoECommunicator.dispatch runs the full (send+recv) LL dispatch inline on
|
|
# the stream and returns (DispatchOutput, DispatchHandle) -- the analog of
|
|
# ncclEpDispatch + ncclEpComplete.
|
|
return moe_comm.dispatch(x, topk_idx, topk_weights, output_buffer=output_buffer)
|
|
|
|
def combine_fn(dout):
|
|
dispatch_out, handle = dout
|
|
moe_comm.combine(dispatch_out.tokens, handle, out=out)
|
|
|
|
stream = torch.cuda.current_stream()
|
|
|
|
# ---- runPairedBenchmark: warmup (paired), then per-iter timed (paired). ----
|
|
for _ in range(warmup):
|
|
dout = dispatch_fn()
|
|
stream.synchronize()
|
|
combine_fn(dout)
|
|
stream.synchronize()
|
|
dist.barrier(group=group)
|
|
|
|
# CUPTI/nsys region: capture ONLY the post-warmup timed kernels, matching
|
|
# ep_bench's KernelTimer.start() (called after warmup). An external nsys run
|
|
# with --capture-range=cudaProfilerApi records exactly the dispatch/combine
|
|
# kernels between these two calls.
|
|
_cupti = bool(getattr(args, "cupti_region", False))
|
|
if _cupti:
|
|
torch.cuda.synchronize()
|
|
dist.barrier(group=group)
|
|
torch.cuda.cudart().cudaProfilerStart()
|
|
|
|
# In-process CUPTI collector (ep_bench KernelTimer analog). start() after
|
|
# warmup, stop() after the timed loop -- same window as the CUDA events.
|
|
_inproc = None
|
|
if bool(getattr(args, "cupti_inproc", False)):
|
|
try:
|
|
_inproc = _InProcCupti()
|
|
torch.cuda.synchronize()
|
|
dist.barrier(group=group)
|
|
_rc = _inproc.start()
|
|
if _rc != 0:
|
|
if rank == 0:
|
|
print(f"[warn] in-proc CUPTI kt_start rc={_rc}; disabling", flush=True)
|
|
_inproc = None
|
|
except Exception as exc:
|
|
if rank == 0:
|
|
print(f"[warn] in-proc CUPTI unavailable ({exc}); host-observed only", flush=True)
|
|
_inproc = None
|
|
|
|
d_start = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
|
|
d_end = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
|
|
c_start = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
|
|
c_end = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
|
|
|
|
for i in range(iters):
|
|
d_start[i].record(stream)
|
|
dout = dispatch_fn()
|
|
d_end[i].record(stream) # record before sync
|
|
stream.synchronize() # sync outside timing
|
|
c_start[i].record(stream) # record after sync, before combine
|
|
combine_fn(dout)
|
|
c_end[i].record(stream) # record before sync
|
|
stream.synchronize() # sync outside timing
|
|
dist.barrier(group=group) # keep ranks in lockstep, outside timing
|
|
|
|
torch.cuda.synchronize()
|
|
if _cupti:
|
|
torch.cuda.cudart().cudaProfilerStop()
|
|
ck_disp_us = ck_comb_us = 0.0
|
|
inproc_ok = False
|
|
if _inproc is not None:
|
|
_inproc.stop()
|
|
dist.barrier(group=group)
|
|
ck_disp_us = _inproc.avg_us("dispatch")
|
|
ck_comb_us = _inproc.avg_us("combine")
|
|
n_disp = _inproc.count("dispatch")
|
|
n_comb = _inproc.count("combine")
|
|
inproc_ok = ck_disp_us > 0 and ck_comb_us > 0
|
|
if os.environ.get("MSCCLPP_EP_KDEBUG", "0") == "1" and rank == 0:
|
|
print(
|
|
f"[kdebug inproc] dispatch: {ck_disp_us:.1f}us x{n_disp} " f"combine: {ck_comb_us:.1f}us x{n_comb}",
|
|
flush=True,
|
|
)
|
|
|
|
# ---- Collect per-iter times (ms->us) and trim the first (warmup outlier). --
|
|
disp_us = [d_start[i].elapsed_time(d_end[i]) * 1e3 for i in range(iters)]
|
|
comb_us = [c_start[i].elapsed_time(c_end[i]) * 1e3 for i in range(iters)]
|
|
tot_us = [d_start[i].elapsed_time(c_end[i]) * 1e3 for i in range(iters)]
|
|
if iters > 1:
|
|
disp_us, comb_us, tot_us = disp_us[1:], comb_us[1:], tot_us[1:]
|
|
|
|
def stats(times):
|
|
return sum(times) / len(times), min(times), max(times)
|
|
|
|
d_avg, d_min, d_max = stats(disp_us)
|
|
c_avg, c_min, c_max = stats(comb_us)
|
|
t_avg, t_min, t_max = stats(tot_us)
|
|
|
|
# per-rank throughput (GB/s) uses this rank's own byte count / its avg time.
|
|
d_tp = (disp_bytes / 1e9) / (d_avg * 1e-6)
|
|
c_tp = (comb_bytes / 1e9) / (c_avg * 1e-6)
|
|
t_tp = ((disp_bytes + comb_bytes) / 1e9) / (t_avg * 1e-6)
|
|
|
|
# ---- Cross-rank reduction (mirror printLowLatencyResults). ----
|
|
g_d_avg = _reduce_scalar(d_avg, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_d_min = _reduce_scalar(d_min, dist.ReduceOp.MIN, group)
|
|
g_d_max = _reduce_scalar(d_max, dist.ReduceOp.MAX, group)
|
|
g_c_avg = _reduce_scalar(c_avg, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_c_min = _reduce_scalar(c_min, dist.ReduceOp.MIN, group)
|
|
g_c_max = _reduce_scalar(c_max, dist.ReduceOp.MAX, group)
|
|
g_t_avg = _reduce_scalar(t_avg, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_t_min = _reduce_scalar(t_min, dist.ReduceOp.MIN, group)
|
|
g_t_max = _reduce_scalar(t_max, dist.ReduceOp.MAX, group)
|
|
|
|
# ---- Kernel-only pass (torch.profiler / Kineto-CUPTI) — ep_bench parity. ----
|
|
# Measures device-side kernel time (strips host launch latency). Dispatch and
|
|
# combine are profiled in isolation so no kernel-name matching is required.
|
|
kernel_ok = False
|
|
g_dk_avg = g_dk_min = g_dk_max = 0.0
|
|
g_ck_avg = g_ck_min = g_ck_max = 0.0
|
|
if args.kernel_timing and not _cupti and not bool(getattr(args, "cupti_inproc", False)):
|
|
try:
|
|
dk_us, ck_us = _profile_paired_kernels(dispatch_fn, combine_fn, iters, stream, group, rank)
|
|
torch.cuda.synchronize()
|
|
dist.barrier(group=group)
|
|
g_dk_avg = _reduce_scalar(dk_us, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_dk_min = _reduce_scalar(dk_us, dist.ReduceOp.MIN, group)
|
|
g_dk_max = _reduce_scalar(dk_us, dist.ReduceOp.MAX, group)
|
|
g_ck_avg = _reduce_scalar(ck_us, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_ck_min = _reduce_scalar(ck_us, dist.ReduceOp.MIN, group)
|
|
g_ck_max = _reduce_scalar(ck_us, dist.ReduceOp.MAX, group)
|
|
kernel_ok = g_dk_avg > 0 and g_ck_avg > 0
|
|
except Exception as exc: # profiler unavailable / hiccup: keep host numbers valid
|
|
if rank == 0:
|
|
print(f"[warn] kernel-only pass failed ({exc}); reporting host-observed only", flush=True)
|
|
|
|
# ---- In-process CUPTI reduction (ep_bench KernelTimer analog). ----
|
|
g_ik_d_avg = g_ik_d_min = g_ik_d_max = 0.0
|
|
g_ik_c_avg = g_ik_c_min = g_ik_c_max = 0.0
|
|
g_inproc_ok = 0
|
|
if bool(getattr(args, "cupti_inproc", False)):
|
|
g_ik_d_avg = _reduce_scalar(ck_disp_us, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_ik_d_min = _reduce_scalar(ck_disp_us if inproc_ok else 1e18, dist.ReduceOp.MIN, group)
|
|
g_ik_d_max = _reduce_scalar(ck_disp_us, dist.ReduceOp.MAX, group)
|
|
g_ik_c_avg = _reduce_scalar(ck_comb_us, dist.ReduceOp.SUM, group) / num_ranks
|
|
g_ik_c_min = _reduce_scalar(ck_comb_us if inproc_ok else 1e18, dist.ReduceOp.MIN, group)
|
|
g_ik_c_max = _reduce_scalar(ck_comb_us, dist.ReduceOp.MAX, group)
|
|
g_inproc_ok = int(_reduce_scalar(1.0 if inproc_ok else 0.0, dist.ReduceOp.MIN, group))
|
|
|
|
d_tp_all = _gather_scalars(d_tp, num_ranks, group)
|
|
c_tp_all = _gather_scalars(c_tp, num_ranks, group)
|
|
t_tp_all = _gather_scalars(t_tp, num_ranks, group)
|
|
|
|
if rank == 0:
|
|
# avg throughput uses rank-0 byte count / global avg time (as ep_bench does).
|
|
avg_d_tp = (disp_bytes / 1e9) / (g_d_avg * 1e-6)
|
|
avg_c_tp = (comb_bytes / 1e9) / (g_c_avg * 1e-6)
|
|
avg_t_tp = ((disp_bytes + comb_bytes) / 1e9) / (g_t_avg * 1e-6)
|
|
|
|
def minmax_rank(vals):
|
|
lo = min(range(num_ranks), key=lambda r: vals[r])
|
|
hi = max(range(num_ranks), key=lambda r: vals[r])
|
|
return vals[lo], lo, vals[hi], hi
|
|
|
|
d_lo, d_lo_r, d_hi, d_hi_r = minmax_rank(d_tp_all)
|
|
c_lo, c_lo_r, c_hi, c_hi_r = minmax_rank(c_tp_all)
|
|
t_lo, t_lo_r, t_hi, t_hi_r = minmax_rank(t_tp_all)
|
|
|
|
print(f"\n=== Summary (Low Latency, across {num_ranks} ranks) ===")
|
|
print("\n--- Host-observed performance ---")
|
|
print(f"Dispatch (BF16): avg={g_d_avg:.2f} us, min={g_d_min:.2f} us, max={g_d_max:.2f} us")
|
|
print(
|
|
f" throughput: avg={avg_d_tp:.2f} GB/s, "
|
|
f"min={d_lo:.2f} GB/s (rank {d_lo_r}), max={d_hi:.2f} GB/s (rank {d_hi_r})"
|
|
)
|
|
print(f"Combine (BF16): avg={g_c_avg:.2f} us, min={g_c_min:.2f} us, max={g_c_max:.2f} us")
|
|
print(
|
|
f" throughput: avg={avg_c_tp:.2f} GB/s, "
|
|
f"min={c_lo:.2f} GB/s (rank {c_lo_r}), max={c_hi:.2f} GB/s (rank {c_hi_r})"
|
|
)
|
|
print(f"Total (D+C): avg={g_t_avg:.2f} us, min={g_t_min:.2f} us, max={g_t_max:.2f} us")
|
|
print(
|
|
f" throughput: avg={avg_t_tp:.2f} GB/s, "
|
|
f"min={t_lo:.2f} GB/s (rank {t_lo_r}), max={t_hi:.2f} GB/s (rank {t_hi_r})"
|
|
)
|
|
|
|
print("\n--- Kernel-only performance (device kernel time via torch.profiler/CUPTI) ---")
|
|
if kernel_ok:
|
|
# The LL dispatch kernel ends with a cross-rank receive spin-wait, so
|
|
# its device time includes wait skew. torch.profiler's host tracing
|
|
# overhead makes one rank lag, inflating that rank's dispatch device
|
|
# time into the ms range; the cross-rank MIN (the rank that did not
|
|
# wait) is the representative kernel floor and matches ep_bench's
|
|
# low-perturbation CUPTI number. Combine has little recv-spin and is
|
|
# stable across ranks. throughput uses the representative (min) time.
|
|
print(
|
|
f"Dispatch: min={g_dk_min:.2f} us (representative) "
|
|
f"[avg={g_dk_avg:.2f}, max={g_dk_max:.2f} us -- inflated by profiler recv-spin skew]"
|
|
)
|
|
print(f" throughput @min: {(disp_bytes / 1e9) / (g_dk_min * 1e-6):.2f} GB/s")
|
|
print(f"Combine: avg={g_ck_avg:.2f} us, min={g_ck_min:.2f} us, max={g_ck_max:.2f} us")
|
|
print(
|
|
f" throughput: avg={(comb_bytes / 1e9) / (g_ck_avg * 1e-6):.2f} GB/s, "
|
|
f"min={(comb_bytes / 1e9) / (g_ck_min * 1e-6):.2f} GB/s, "
|
|
f"max={(comb_bytes / 1e9) / (g_ck_max * 1e-6):.2f} GB/s"
|
|
)
|
|
print(f"Total (D+C): {g_dk_min + g_ck_avg:.2f} us (dispatch min + combine avg)")
|
|
print(
|
|
" NOTE: for an authoritative low-perturbation kernel-only number, run under "
|
|
"nsys (as ep_bench's CUPTI path does); torch.profiler perturbs the LL recv-spin."
|
|
)
|
|
else:
|
|
print(" NOTE: kernel-only pass disabled or unavailable.")
|
|
|
|
if bool(getattr(args, "cupti_inproc", False)):
|
|
print("\n--- Kernel-only performance (in-process CUPTI Activity API, ep_bench KernelTimer analog) ---")
|
|
if g_inproc_ok:
|
|
# The LL dispatch kernel ends with a cross-rank receive spin-wait,
|
|
# so a lagging rank's device time includes wait skew (same effect
|
|
# as nsys's max outlier). The cross-rank MIN (the rank that did not
|
|
# wait) is the representative kernel floor; it matches the nsys
|
|
# CUPTI number and ep_bench's low-perturbation figure. Combine has
|
|
# little recv-spin and is stable across ranks.
|
|
print(
|
|
f"Dispatch: min={g_ik_d_min:.2f} us (representative) "
|
|
f"[avg={g_ik_d_avg:.2f}, max={g_ik_d_max:.2f} us -- recv-spin skew on lagging ranks]"
|
|
)
|
|
print(f" throughput @min: {(disp_bytes / 1e9) / (g_ik_d_min * 1e-6):.2f} GB/s")
|
|
print(f"Combine: avg={g_ik_c_avg:.2f} us, min={g_ik_c_min:.2f} us, max={g_ik_c_max:.2f} us")
|
|
print(
|
|
f" throughput: avg={(comb_bytes / 1e9) / (g_ik_c_avg * 1e-6):.2f} GB/s, "
|
|
f"min={(comb_bytes / 1e9) / (g_ik_c_max * 1e-6):.2f} GB/s, "
|
|
f"max={(comb_bytes / 1e9) / (g_ik_c_min * 1e-6):.2f} GB/s"
|
|
)
|
|
print(f"Total (D+C): {g_ik_d_min + g_ik_c_avg:.2f} us (dispatch min + combine avg)")
|
|
else:
|
|
print(" NOTE: in-process CUPTI collector unavailable (see [warn] above).")
|
|
|
|
print(
|
|
f"\nByte counts: dispatch={disp_bytes / 1e6:.2f} MB (BF16), "
|
|
f"combine={comb_bytes / 1e6:.2f} MB (BF16), selections={num_valid_selections}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
finally:
|
|
if dist.is_initialized():
|
|
try:
|
|
dist.barrier()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
dist.destroy_process_group()
|
|
except Exception:
|
|
pass
|