[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 39 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
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
…ights loss term in dispatch autograd test Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
ff06156 to
53a3834
Compare
|
/te-ci pytorch L1 |
…_NCCL_EP Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…der zero-copy, allocate in-flight otherwise Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ad and rename dispatch autograd tests Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…include and combine_bwd grad_eo locals to grad_expert_out Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
… and run 1f1b interleave both eager and CUDA-graph-captured Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…s allocated internally Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…opy; ep_dispatch falls back to them Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ute instead of save_for_backward Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ler_provides_combine_grad_buffer; recv_topk_weights is always buffer-owned Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ovides_combine_grad_buffer CLI flags to ep_moe example and ep_bench (default False) Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…buffer, dispatch/combine, tests and examples Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
for more information, see https://pre-commit.ci
| # to opt in; the C++ backend then operates the EP group in zero-copy mode. | ||
|
|
||
|
|
||
| def symm_mem_alloc( |
There was a problem hiding this comment.
nit: This function is pretty generic to allocate symm mem, maybe consider to move it to general utils?
| self.token_counts = torch.empty(self.num_local_experts, dtype=torch.int32, device=device) | ||
| # Persistent tensor; keep resident if activation CPU offloading is on. | ||
| mark_not_offload(self.handle_mem) | ||
| self._alloc_symm_buffers() |
There was a problem hiding this comment.
Just note this buffer allocation logic might be pending for future change.
There was a problem hiding this comment.
There is an existing warning that the API related to zero-copy is subject to change.
| ctx.mark_non_differentiable(token_counts) | ||
| # Detach so the long-lived buffers aren't tracked as differentiable outputs; | ||
| # autograd re-attaches grad_fn pointing back at this Function. | ||
| return recv_tokens.detach(), recv_topk_weights.detach(), token_counts |
There was a problem hiding this comment.
Not super clear to me why we do detach here. Autograd function is running in no_grad context anyway. If these tensors are long-lived buffers, user should allocate them as requires_grad=False?
There was a problem hiding this comment.
I think when recv_tokens are symmem, we need to do detach to avoid the grad_fn from sticking with this tensor, while when it is a non-symmem, we have an in-place modification which requires dirty-mark, which detachs can trigger a similar effect. I'm new to PyTorch so let me know if this is incorrect.
There was a problem hiding this comment.
If we allocate recv_tokens as requires_grad=False, it should not have a grad_fn. I think we should let user manage the responsibility if this tensor requires grad, i.e. if user explicitly want the output of dispatch carries grad_fn for some reason, we should not forbid it. Otherwise they can just make the tensor not require grad.
| torch.ops.transformer_engine_ep.combine(handle_mem, expert_out, result) | ||
| ctx.save_for_backward(handle_mem) | ||
| ctx.grad_symm_buf = grad_symm_buf | ||
| if grad_symm_buf is None: |
There was a problem hiding this comment.
If grad_symm_buf is not None, we should use ctx.save_for_backward to save it?
There was a problem hiding this comment.
I think save_for_backward should be used only when you need to read the value of the tensor in the backward path.
Here, we only want to pass the reference to the buffer so that we can write to it.
There was a problem hiding this comment.
All tensors that will be used by backward should be passed by ctx.save_for_backward, this is to prevent memory leak, check https://docs.pytorch.org/docs/2.12/generated/torch.autograd.function.FunctionCtx.save_for_backward.html. I think it is needed for torch to manage its autograd graph lifecycle. If the tensor does not require grad, it is probably okay to assign it with ctx directly, but it is always safe to use ctx.save_for_backward
Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…/combine_grad_expert_out buffers on EpBuffer Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
…ard docstring Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
|
/te-ci pytorch L1 |
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Phuong Nguyen <phuonguyen@nvidia.com>
|
/te-ci pytorch L1 |
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: