mirror of
https://github.com/deepseek-ai/DeepEP.git
synced 2026-11-05 00:47:32 +00:00
[GH-ISSUE #756] [HybridEP] Blocking-mode permute/unpermute kernels read a per-call pinned host tensor that the caller's allocator can recycle before the kernels run (intermittent illegal memory access) #415
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
deepseek-ai/DeepEP#415
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @yuzhongw-nvidia on GitHub (Sep 21, 2026).
Original GitHub issue: https://github.com/deepseek-ai/DeepEP/issues/756
Summary
In blocking mode (
non_blocking=False, i.e. nonum_permuted_tokenssupplied),metadata_preprocessingstores the dispatched-token count in a 4-byte CPU pinned tensor that is allocated per call, and the permute / unpermute kernels then dereference that host pointer on the device — in the forward pass and again in both backward passes (combine_with_unpermutein the dispatch backward,dispatch_with_permutein the combine backward). HybridEP never ties that tensor's lifetime to the stream (record_stream/CachingHostAllocator_recordEventare not used anywhere undercsrc/hybrid_ep). PyTorch's pinned-memory allocator is not stream-ordered, so as soon as the caller drops the handle — which autograd does right after the backward kernels are enqueued — the block goes back to the host free list and is handed to the next small pinned allocation. A kernel still queued behind the host then reads a count written by an unrelated tensor and indexes far outsiderow_id_map/ the shared NVLink buffer. The result is an intermittentCUDA error: an illegal memory access was encountered, reported at the next host synchronization, i.e. at the start of the following training iteration.The non-blocking path is not affected: there the count lives in a device tensor (
executor.cu:103-105).Reproduced with both
1b8f467andd28bd67(identical code for the files involved; the diff between them only touches includes ininternode_doca.cuh).Environment
hybrid-epbranch atd28bd67and1b8f467(deep_ep 1.2.1+…), single NVLink domain per EP group,use_shared_bufferdefaultmoe_flex_dispatcher_backend=hybridep, blocking mode (nomoe_expert_rank_capacity_factor, sonum_permuted_tokens=None),moe_hybridep_num_sms=32,NUM_OF_TOKENS_PER_CHUNK_{DISPATCH_API,COMBINE_API,PREPROCESSING_API}=128Symptom
Iterations 1 and 2 complete normally. At the very beginning of iteration 3 (sometimes 4-6), one or two processes fail with
CUDA error: an illegal memory access was encountered, surfacing at the first host sync of the new iteration (a.item()in the first layer); the NCCL watchdog reports the same sticky error. The failing processes are always the node-local rank 0 of their node — in our setup exactly the processes that run the data loader, i.e. the ones that allocate pinned staging buffers at the start of every iteration. The other 62-63 processes never fault.Bisect (all on the same 512K configuration)
CUDA_LAUNCH_BLOCKING=1PYTORCH_CUDA_ALLOC_CONF=expandable_segments:Falsemoe_hybridep_pad_variable_tokens=TrueSo: not a deterministic index/overflow problem, not the device allocator, not the optimizer; a host-side lifetime race on the HybridEP path that disappears when the host cannot run ahead of the GPU.
Root cause (code at
d28bd67)csrc/hybrid_ep/executor/executor.cu:103-109):padded_tokens_per_expertgets the same treatment (executor.cu:168-175). The scan kernel writes the count into it (executor.cu:140), and the host reads it after an uncheckedcudaStreamSynchronize(executor.cu:182-191) — that part is fine.hybrid_ep.cu:196/303/383copyhandle.num_dispatched_tokens_tensorinto the launch args of dispatch, combine and the permute helpers;csrc/hybrid_ep/extension/permute.cuhandsdata_ptr<int>()to the kernels (permute.cu:288,:429,:548) and the kernels dereference it:Reading pinned host memory from a kernel is legal, but only while the block is alive.
Nothing extends the block's lifetime to the kernels. Every HybridEP launch runs on
at::cuda::getCurrentCUDAStream()(hybrid_ep.cu:199/257/312/389,executor.cu:45/86) and there is norecord_stream,CachingHostAllocator_recordEvent, event or side stream anywhere undercsrc/hybrid_ep. PyTorch'sCachingHostAllocatorfrees a block straight into its free list unless a stream was recorded for it (PyTorch's own copy kernels do record); the freed 4-byte block is the first candidate for the next small pinned request.The handle (and with it the pinned tensor) is released by the caller as soon as the backward is enqueued: in Megatron's
HybridEPDispatch.backward/HybridEPCombine.backwardthe handle lives in the autograd context, which is dropped right aftercombine_with_unpermute/dispatch_with_permutereturn — but those calls only enqueue the unpermute / permute kernels. With the host running ahead of the GPU, the block is recycled and rewritten (on our ranks by the data loader pinning the next batch) before the kernels execute. The kernels then compute row indices from a foreign value, hence the fault.Why it depends on sequence length: it is a race, not a size limit. Longer sequences make each iteration's attention much longer, so the GPU lags further behind the host at the iteration boundary and the window between "handle dropped" and "backward kernel actually runs" widens. Why only node-local rank 0: those processes are the only ones that immediately reuse the freed pinned block for something else (data-loader staging); on the other ranks the block is recycled but still holds the old, and in this force-balanced run still correct, value.
Suggested fix
Any of these closes the hole; (1) is the cleanest and matches what the non-blocking path already does:
cudaStreamSynchronizeinmetadata_preprocessingcopy the count into a device int32 (a plain device tensor is stream-ordered by the caching allocator) and pass that topermute_preprocessing_kernel,permute_kernelandunpermute_kernel. Equivalently: always allocate the device tensor for the kernels and add the pinned copy only for the host read.at::cuda::CachingHostAllocator_recordEvent(ptr, ctx, stream)on the pinned tensor after every kernel launch that reads it (forward and both backward paths), so the allocator does not recycle the block until those kernels complete.cudaStreamSynchronize(executor.cu:183) and ofcudaFuncSetAttribute/cudaLaunchCooperativeKernelinpermute.cu(:296-299); today a failure there is silent and only shows up later as a corrupted count.padded_tokens_per_expert(pinned int64) is only read on the host after the sync, so it is not affected, but the same "device copy for kernels, pinned copy for the host" pattern would keep both consistent.Caller-side mitigation we validated
While the fix lands in HybridEP we keep the handle's pinned tensors alive on the Megatron side (module-level
collections.deque(maxlen=1024), appended right afterdispatch_with_permutereturns in the dispatch autograd forward):Every later blocking dispatch synchronizes the stream, so by the time an entry is evicted the kernels that could read it have completed. With only this change (HybridEP untouched) the 512K configuration runs 8/8 and 20/20 iterations at the same throughput as before the crash (about 73 s per iteration, 64 GPUs). It is a mitigation, not a fix: the ownership problem stays in HybridEP.
Reproduction notes
Any Megatron-LM MoE run that uses the flex dispatcher with
moe_flex_dispatcher_backend=hybridepin blocking mode, long enough sequences to keep the GPU well behind the host (we needed 32768 tokens per rank per call; 16384 did not trigger it), and pinned-memory churn on the host (a data loader with pinned staging buffers is enough). The failure shows up within the first few iterations. A quick sanity check of the diagnosis is to poison the pinned block (write a large value into it right after the handle is dropped) — the fault then becomes deterministic. Happy to open a PR for option (1) if the approach is acceptable.@yuzhongw-nvidia commented on GitHub (Sep 21, 2026):
Hi @Autumn1998 , could you please help take a look at this? Thanks!
@yuzhongw-nvidia commented on GitHub (Sep 21, 2026):
Update after retesting with the current
hybrid-ephead (10d4dd7, 2026-09-11):The mechanism described above is gone since
17cfb81(#625, "Optimization of the standalone permute path"). Starting with that commit the permute/unpermute kernels no longer take a pointer tohandle.num_dispatched_tokens_tensor: in blocking mode the count is read on the host after thecudaStreamSynchronizeinmetadata_preprocessingand passed to the kernels by value (PermuteArgs::num_permuted_token), and the kernels only read device tensors (dense_chunk_layout,dense_to_expert_map,tokens_per_expert);UnpermuteArgscarries no count pointer at all.grep num_dispatched_tokens_ptr csrc/hybrid_ep/extension/permute.cureturns nothing on10d4dd7. Our container hadd28bd67, i.e. the commit right before that change.Empirical confirmation on the same setup as the report (GB300 NVL72, 64 ranks, 32768 tokens per rank per call, blocking mode, no caller-side mitigation), same day:
d28bd67illegal memory accessat the start of iteration 3 on a node-local-rank-0 process, as reported10d4dd7(rebuilt in the same container with the same build flags)What remains is only the robustness note: in blocking mode
num_dispatched_tokens_tensor(andpadded_tokens_per_expert) are still per-call pinned host tensors with no stream tracking. Their only device access is now the scan kernel's write, which completes before the synchronize, so I do not see a way for the race to recur with the current kernels. From our side this can be closed, or kept open only as a note about the untracked pinned allocations if you prefer to move the count to a device tensor as in the non-blocking path.