[PyTorch] Expert Parallelism: PyTorch wrapper + autograd ops with symm-mem zero-copy#3035
[PyTorch] Expert Parallelism: PyTorch wrapper + autograd ops with symm-mem zero-copy#3035phu0ngng wants to merge 18 commits into
Conversation
| @contextlib.contextmanager | ||
| def _zero_copy_scope(enabled: bool): | ||
| """Toggles whether per-step ops apply the symm-mem NCCL window annotation.""" | ||
| if enabled: | ||
| yield | ||
| return | ||
| tex.ep_set_zero_copy(False) | ||
| try: | ||
| yield | ||
| finally: | ||
| tex.ep_set_zero_copy(True) |
There was a problem hiding this comment.
_zero_copy_scope does not save/restore the previous flag value
When enabled=False, the manager unconditionally sets g_zero_copy_enabled=False on entry and g_zero_copy_enabled=True on exit. If two callers both use zero_copy=False concurrently (e.g., pipeline-parallel microbatches dispatched from separate Python threads) or if the context is nested, the inner scope's finally block prematurely re-enables zero-copy while the outer scope is still active. The outer scope's finally then sets True again, but between the inner finally and the outer finally the C++ layer sees True unexpectedly.
The fix is to capture the previous value before writing and restore it unconditionally: save old = tex.ep_get_zero_copy() (adding a corresponding getter), then tex.ep_set_zero_copy(old) in the finally block. At minimum, document the single-caller-at-a-time assumption prominently so pipeline-parallel users know to serialize.
540ef54 to
bacae5f
Compare
| device = expert_out.device | ||
| # Weight in payload dtype: single fused broadcast multiply into combine_in. | ||
| w = recv_topk_weights.unsqueeze(-1).to(expert_out.dtype) | ||
| torch.mul(expert_out, w, out=combine_in) |
There was a problem hiding this comment.
why we need this?🤔
At the training scenario, the weight gets multiplied onto the activation between fc1 and fc2 (we also dispatch the weight at the same time as dispatching the tokens), or am I misunderstanding something here?
My understanding is that this multiplication is unnecessary. Furthermore, if it is removed, another problem becomes more prominent: how do we add symm buffer support for the combine input? This would require changes on the grouped GEMM side.
There was a problem hiding this comment.
Second this. I saw unexpected kernel here and found this same problem. A potential solution is to provide a separate path when the weight is not provided. This means the weight multiplication is handled elsewhere, and in this case skip the multiplication here.
There was a problem hiding this comment.
Good to learn that we can fuse the weight x to the activation. I will make this optional.
We will need to change the GG to return the symmetric memory buf.
There was a problem hiding this comment.
Yes. we need change the grouped gemm I think
| ep_group: dist.ProcessGroup, | ||
| num_experts: int, | ||
| max_tokens_per_rank: int, | ||
| recv_capacity_per_rank: int, |
There was a problem hiding this comment.
When allocating the buffer, we need to allocate according to the worst case. There are two scenarios here:
- The first is rank-major, where the memory footprint is max_tokens_per_rank × num_of_ranks. This generally stays below 10 GB, which is the primary memory overhead of typical EP setups and is acceptable.
- The second is expert-major, where the memory footprint is max_tokens_per_rank × num_of_ranks × min(topk, num_of_experts). This could reach 40–50 GB, which is unacceptable.
If I understand this correctly, we must find a way to optimize the memory usage in the expert-major layout — or alternatively, we need to fall back to the rank-major layout + explicit permutation approach.
There was a problem hiding this comment.
With the rank-major, you still need to overallocate the output buffer of local permute as in expert-major. Right?
There was a problem hiding this comment.
There are two types of buffers:
The first is the EP buffer, which serves as the destination for communication (NCCL EP is a push-based design), so it requires a relatively costly registration process. These are reused globally as static buffers as much as possible, so they are allocated based on the worst-case size. In HEP, the rank-major output buffer is an EP buffer, so we only need a rank-major worst-case-size buffer. I haven't studied NCCL EP in detail, but my understanding is that if our output is a symmetric buffer, we don't need a built-in static comm buffer inside NCCL EP — meaning recv_capacity_per_rank is not needed when the output buffer is a symm buffer. I think this is worth discussing and clarifying.
The second type is regular GPU memory, which can be managed by the caching allocator. In HEP, the output of the permute operation falls into this category — it can be dynamically allocated each iteration based on the scan result, with just one additional sync required. Additionally, in sync-free mode, the size of this buffer is specified by the user.
To summarize, we may need to confirm whether recv_capacity_per_rank requires building an expert-major worst-case-size buffer inside NCCL EP. If the output is a symm buffer, we theoretically don't need such a buffer. However, if it is necessary, then we cannot accept an expert-major worst-case-size buffer. I also observed in my draft PR that NCCL EP uses more memory.
There was a problem hiding this comment.
Hi,
It's correct that if the output buffer is a symmem, then we should not need to register the gigantic IPC/MC buffer in ep_group with the size based on recv_capacity_per_rank. Let's request NCCL EP to add an option to skip this buffer allocation.
However, I think we should still ask users to specify this recv_capacity_per_rank so that we can handle overflow policy in the metadata_preprocessing rather than delaying it to dispatch phase.
There was a problem hiding this comment.
We need an option to skip this internal buffer.
Also, are you thinking of using recv_capacity_per_rank to support the sync-free mechanism? That is, tokens exceeding the threshold get dropped, and then trigger the flipping of the overflow flag? I think this is not correct — we should not set it at buffer initialization, but instead pass it as a parameter before the preprocess step of each dispatch, because the threshold changes every iteration.
cc @nanz-nv plz correct me if I made mistakes
There was a problem hiding this comment.
because the threshold changes every iteration.
I'm curious to learn about this possibility. From my understanding, the output buffers need to have a static size for CUDA Graph replay, and so does the recv_capacity.
There was a problem hiding this comment.
I think for each global batch, we recalculate a new output size, since each batch has its own CUDA graph — but I'm not 100% sure on this. You may want to confirm with @nanz-nv.
There was a problem hiding this comment.
I think it is something in between. With the current way of doing full-iteration cuda graph, ideally recv_capacity_per_rank should stay the same across training, but it can sometimes gets updated. So I'd treat it as something that may change but not frequently.
40d8011 to
2153492
Compare
9ec1aff to
7ce8d8b
Compare
b2ab069 to
c8c54fd
Compare
|
|
||
| const size_t H = static_cast<size_t>(tokens.size(-1)); | ||
| const size_t T_flat = tokens.numel() / H; | ||
| const size_t topk_n = static_cast<size_t>(topk_idx.size(-1)); | ||
| const size_t recv_pr = recv_tokens.numel() / H; | ||
|
|
||
| NVTE_CHECK(static_cast<size_t>(topk_weights.size(-1)) == topk_n, | ||
| "topk_weights last dim must equal topk_idx last dim"); | ||
| NVTE_CHECK(static_cast<size_t>(recv_topk_weights.numel()) == recv_pr, | ||
| "recv_topk_weights total size must equal recv_tokens recv_pr"); | ||
| NVTE_CHECK(recv_tokens.scalar_type() == tokens.scalar_type(), "recv_tokens dtype (", | ||
| c10::toString(recv_tokens.scalar_type()), ") must match tokens dtype (", | ||
| c10::toString(tokens.scalar_type()), ")"); | ||
|
|
||
| auto tok_dtype = GetTransformerEngineDType(tokens.scalar_type()); | ||
| auto handle_mem_te = makeTransformerEngineTensor( | ||
| handle_mem.data_ptr(), Shape{static_cast<size_t>(handle_mem.numel())}, DType::kByte); | ||
| auto topk_idx_te = | ||
| makeTransformerEngineTensor(topk_idx.data_ptr(), Shape{T_flat, topk_n}, DType::kInt64); | ||
| auto tokens_te = makeTransformerEngineTensor(tokens.data_ptr(), Shape{T_flat, H}, tok_dtype); | ||
| auto topk_w_te = | ||
| makeTransformerEngineTensor(topk_weights.data_ptr(), Shape{T_flat, topk_n}, DType::kFloat32); | ||
| auto recv_tokens_te = | ||
| makeTransformerEngineTensor(recv_tokens.data_ptr(), Shape{recv_pr, H}, tok_dtype); | ||
| auto recv_topk_w_te = |
There was a problem hiding this comment.
Token-count mismatch between
tokens, topk_idx, and topk_weights goes unchecked
In ep_dispatch, T_flat is derived from tokens.numel() / H, but the TE tensor descriptors for topk_idx and topk_weights are assembled using that same T_flat without verifying those tensors actually contain T_flat rows. If a caller inadvertently passes a topk_idx or topk_weights from a differently-sized batch (e.g. a leftover buffer from a previous micro-batch with a different sequence length), makeTransformerEngineTensor silently builds a descriptor that claims more rows than the tensor holds, and the subsequent NCCL EP kernel performs an OOB GPU memory read. The mismatch can also arise across the two-step call sequence: ep_prepare computes its own T_flat from topk_idx.numel(), so if topk_idx is later swapped for a different-sized one before calling ep_dispatch, the routing table and the dispatch tensor descriptor silently disagree.
Adding NVTE_CHECK(topk_idx.numel() == T_flat * topk_n, ...) and NVTE_CHECK(topk_weights.numel() == T_flat * topk_n, ...) before the descriptor construction would surface this class of error immediately.
df732a5 to
67917a3
Compare
|
/te-ci pytorch L1 |
|
Pipeline #54455868 TE EP tests passed in L1_pytorch_distributed_unittest--B200_8GPU and L1_pytorch_distributed_unittest--H100_4GPU. There are other failures that are unrelated to TE EP. |
52bbf88 to
d6c5745
Compare
| NVTEShape idx_shape = nvte_tensor_shape(topk_idx); | ||
| void* idx_data = nvte_tensor_data(topk_idx); | ||
| NVTE_CHECK(idx_data != nullptr, "topk_idx data must not be null"); | ||
|
|
There was a problem hiding this comment.
max_token_bytes hardcodes bfloat16 but float32 payloads are not blocked
cfg.max_token_bytes is set to hidden_dim * sizeof(nv_bfloat16) at group creation time. NCCL EP uses this to size internal staging buffers. If a caller constructs an EpBuffer with explicitly float32 recv_tokens and passes float32 tokens, the C++ dtype-match check in ep_dispatch passes (both tensors are float32), but max_token_bytes is half of what NCCL EP needs — leading to OOB writes inside ncclEpDispatch.
The default auto-alloc path is protected because recv_tokens defaults to handle.payload_dtype (bfloat16), making the C++ dtype-match check catch float32 tokens. However, a user who explicitly allocates float32 recv_tokens (a documented option in EpBuffer.__init__) and passes float32 tokens would silently bypass all guards and hit NCCL EP with undersized buffer configuration. The Python boundary only rejects FP8 (_reject_fp8), not float32.
Consider adding a validation in ep_bootstrap / EpHandle.__init__ that enforces bfloat16 at the payload boundary, and add an assertion in ep_dispatch (C++) that tok_dtype == kNVTEBFloat16 to fail fast instead of silently corrupting memory.
There was a problem hiding this comment.
Nit: A filename like expert_parallel.py would be a bit more obvious (#3034 (comment)).
There was a problem hiding this comment.
The ep_dispatch and ep_combine APIs are convenient and obvious, and it's especially nice how torch.compile support looks straightforward. If we ever want to fuse EP communication with grouped MLP compute, then we should also consider implementing a te.ops.BasicOperation for each, and then implementing a te.ops.FusedOperation for dispatch+FC1+act+FC2+combine (or some subset).
| ctx.handle_mem = handle_mem | ||
| ctx.handle_id = handle_id | ||
| ctx.grad_topk_weights_buf = grad_topk_weights_buf |
There was a problem hiding this comment.
It's recommended to store tensors with ctx.save_for_backward rather than assigning directly.
| ctx.handle_mem = handle_mem | |
| ctx.handle_id = handle_id | |
| ctx.grad_topk_weights_buf = grad_topk_weights_buf | |
| ctx.save_for_backward(handle_mem, grad_topk_weights_buf) | |
| ctx.handle_id = handle_id |
I don't think many of the concerns are relevant to us (these are persistent workspace buffers, so no grads or memory usage concerns), but there are some weird edge cases its supposed to avoid.
We'll also need to change the backward:
handle_mem, grad_topk_weights_buf = ctx.saved_tensorsWe should also do a similar change in _EpCombine.
One final bug we might encounter is with CPU offloading, which automatically offloads saved tensors (see https://github.com/NVIDIA/TransformerEngine/pull/3035/changes#r3407248167).
| ctx.handle_mem = handle_mem | ||
| ctx.handle_id = handle_id | ||
| ctx.recv_tokens_grad = recv_tokens_grad |
There was a problem hiding this comment.
Avoid storing tensors directly in ctx:
| ctx.handle_mem = handle_mem | |
| ctx.handle_id = handle_id | |
| ctx.recv_tokens_grad = recv_tokens_grad | |
| ctx.save_for_backward(handle_mem, recv_tokens_grad) | |
| ctx.handle_id = handle_id |
| self.grad_topk_weights = torch.empty( | ||
| (handle.max_tokens_per_rank, handle.top_k), dtype=torch.float32, device=device | ||
| ) | ||
|
|
There was a problem hiding this comment.
If we save these workspace buffers in autograd contexts, then they might get picked up by CPU offloading.
| # Prevent buffers from participating in activation CPU offloading | |
| mark_not_offload( | |
| self.recv_tokens, | |
| self.recv_tokens_grad, | |
| self.recv_topk_weights, | |
| self.token_counts, | |
| self.grad_topk_weights, | |
| ) | |
See:
…ia cfg.zero_copy Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
… a single buffer Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ents; drop unused ep_group kwargs Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
… _-prefixed stub args, autograd docstrings) Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…t_out to alias combine_in in zero-copy Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…rad_tokens/grad_topk_weights; alias-check bwd grads in zero-copy Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
for more information, see https://pre-commit.ci
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…eights fp32 dtype Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
… ep_dispatch Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…h/ep_combine accept caller-supplied output buffers with C++ symm-mem checks under zero-copy Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…normalize bwd grad layout in Python Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
d6c5745 to
3e9e1cf
Compare
| check_symm_mem_required(grad, "grad (dispatch_bwd input)"); | ||
| check_symm_mem_required(g_recv_topk_weights, "g_recv_topk_weights"); |
There was a problem hiding this comment.
Zero-copy backward always crashes — upstream grad tensors can never be symm-mem
ep_dispatch_bwd calls check_symm_mem_required on both grad (= g_recv_tokens) and g_recv_topk_weights, and ep_combine_bwd calls it on grad (= g_result). These tensors are allocated by PyTorch's autograd engine, not by the user via symm_mem_alloc, so they are never symm-mem-backed. When zero_copy=True, check_symm_mem_required is a hard error, meaning any training run that uses ep_dispatch or ep_combine with zero_copy=True will unconditionally throw in the backward pass: "ep zero-copy: grad (dispatch_bwd input) must be symm-mem-backed…". The existing autograd tests all set ZERO_COPY = False so this path is not exercised by the test suite. Either the backward path should fall back to staged-copy for non-symm-mem grad inputs (replacing check_symm_mem_required with maybe_make_window, matching the PR's stated "anything else falls back to staged-copy" contract), or EpBuffer needs symm-mem-allocated gradient slots and the autograd backward needs to route into them before calling these ops.
Summary
Second PR in the TE Expert Parallelism (EP) series. Adds the PyTorch binding on top of the common C API (#3034): exposes EP dispatch/combine as
torch.librarycustom ops with autograd, and plumbs NCCL symmetric-memory windows through for the zero-copy path.Payload tensors allocated via
te.pytorch.ep.symm_mem_alloctake the one-sided zero-copy path whenep_bootstrap(zero_copy=True); anything else falls back to staged-copy, so the API stays drop-in compatible with any allocator.Implementation
Public Python API (
transformer_engine/pytorch/ep.py)ep_bootstrap/ep_finalize- one-time per-process init/teardown. Borrows the NCCL comm fromep_groupviaProcessGroupNCCL._comm_ptr()(no separatencclUniqueIdbootstrap).ep_finalizeis optional - anatexithandler covers normal shutdown; call it explicitly beforedist.destroy_process_group(). Requiresep_group.size() >= 2.symm_mem_alloc(shape, dtype, ep_group)- per-rank tensor backed by NCCL symmetric memory, rendezvoused onep_group.EpBuffer- per-layer state: routing handle + persistent payload slots (recv_tokens,combine_in, grad buffers). One per concurrently-in-flight call (e.g. PP-1F1B microbatch). Symm-mem-backed whenzero_copy=True.ep_dispatch/ep_combine- autograd-aware per-step ops, registered astorch.library.custom_opwith correctmutates_args, so they compose withtorch.compilefullgraph and CUDA graphs.Current payload dtype is restricted to bfloat16; FP8 quantize/dequantize stays outside the EP boundary.
C++ bindings (
transformer_engine/pytorch/csrc/extensions/ep.cpp)pybind11::objectfor dtype) - no c10d ABI on the boundary. -maybe_make_window()resolves each payload tensor to anNVTECommWindowviac10d::symmetric_memory::rendezvous; non-symm-mem tensors returnkNoWindowand the backend picks staged-copy automatically.ep_initializeand forwarded intoNVTEEpGroupConfig.zero_copy.Build
build_tools/pytorch.pypropagates-DNVTE_WITH_NCCL_EP(gated onNVTE_BUILD_WITH_NCCL_EP=1, default on) and-DUSE_NCCLso PyTorch's symm-mem feature macros are visible. When NCCL EP is off,ep.cppno-ops behind the#ifdef.Testing
tests/pytorch/distributed/run_ep.py- 8-test suite: prepare correctness, raw dispatch/combine identity round-trip, dispatch fwd+bwd VJP, full fwd+bwd round-trip, multi-iter bit-stability, CUDA graph capture, PP-1F1B 3-buffer interleave, int64topk_idxvalidation. Launcherrun_test_ep.shauto-detects GPUs (skips with <4). Pytest driver:tests/pytorch/distributed/test_ep.py.examples/pytorch/ep/ep_moe.py- minimal end-to-end MoE fwd+bwd driver with--checkagainst an analytical reference.examples/pytorch/ep/bench/ep_bench.py- times raw + autograd dispatch/combine, optional--cuda-graphcapture and--kineto/--nsysprofiling.Type of change
Checklist: