mega-MoE prefill: cycle-chunked dispatch pull (ring < num_min)#4
mega-MoE prefill: cycle-chunked dispatch pull (ring < num_min)#4ipiszy wants to merge 15 commits into
Conversation
… verified) Add dispatch cycle chunking to the packed-FP4 mega-MoE kernel so the token ring can be instead of , lifting the prefill seqlen cliff. Host: - get_symm_buffer_for_mega_moe: chunk_ratio param (default None=original); num_ring_tokens = align(ratio * tokens, 384) when set. - get_symm_buffer_size_for_mega_moe: relax the num_min <= num_ring_tokens assert (chunking permits ring < num_min); add a num_cycles all-reduce slot to the Workspace. Scheduler (mega_moe.cuh): - kNumThreads template param; constructor takes (workspace, sym_buffer, sm_idx, thread_idx). - cycle_pool_block_end + cap_blocks + cycle_ended fields; cycle_barrier() method (mbarrier + cluster_sync); for_each_block_impl with internal for-cycle loop; for_each_block (no-arg) uses chunking path when cap_blocks > 0. - fetch_next_l1/l2_block: cycle-pool-block stop + cycle_ended flag. - get_next_block: cycle-end break via cycle_ended (not stale m_block_idx). Kernel (sm100_mxfp4_mxfp4_mega_moe.cuh): - constexpr kChunking = (ring < num_ranks * max_tokens); set_cap_blocks + set_cycle_barrier when chunking. - num_cycles = ceil(total_pool_blocks / kNumRingBlocks) + cross-rank all-reduce (gather-based) + printf. - dispatch pull restructured into a for-cycle loop (pool-block-aligned bound); per-cycle cycle_barrier. - shared_storage.cycle_barrier (ClusterTransactionBarrier) + smem_barriers +1 in the launch config (the OOB-smem crash found by compute-sanitizer). Wave heuristic (mega_moe.hpp): gated relaxation — when ring < num_min, force num_experts_per_wave=1 (one expert per wave so the cycle boundary only splits a single expert's tokens). Verified: kChunking=false (baseline + 1-rank matrix, 16 cases, diffs unchanged: mxfp4 0.00075 / nvfp4 0.00058). kChunking=true runs (num_cycles computes + all-reduces correctly, no deadlock); the cycle_barrier sync (mbarrier + cluster_sync) is being finalized. Co-Authored-By: Claude <noreply@anthropic.com>
…tion Two crashes found via compute-sanitizer, both in the cycle-chunking path: 1. smem OOB: the cycle_barrier mbarrier (Barrier cycle_barrier in SharedStorage) was not counted in smem_barriers (sm100_mxfp4_mxfp4_mega_moe.hpp), so its init wrote beyond the shared memory allocation -> Invalid __shared__ write -> illegal instruction. Fix: +1 in the smem_barriers count. 2. non-leader MMA warp: the MMA issue warp's for_each_block was inside if (is_leader_cta), so the non-leader CTA's MMA warp never called for_each_block -> never called cycle_barrier -> the all-thread cycle sync (bar.sync 4) faulted. Fix: add an else branch for the non-leader MMA warp with a no-op for_each_block body (so it participates in cycle_barrier every cycle). cycle_barrier now uses sync_aligned(kNumThreads, 4) (bar.sync on a dedicated named barrier, avoiding the barrier-0 conflict with the dispatch setup) + cluster_sync (cross-CTA). The fault has moved to the epilogue (next to debug). kChunking=false still verified (baseline). Co-Authored-By: Claude <noreply@anthropic.com>
…ing hang The counted-arrival mbarrier cycle_barrier (with the smem + non-leader MMA fixes) syncs cycle 0 correctly (all 16 warps on SM 0 arrive at phase 0). Cycle 1 starts (some warps reach phase 1) then hangs — the L2 ring/TMEM pipeline stalls: warps processing cycle 1's blocks wait on L2 ring data (full/empty counts) that isn't coming. The remaining issue is the L2 ring wrap mechanism across cycles (the num_expected_blocks = ... * (pool_block_idx/kNumRingBlocks) counts at lines ~772 and ~1084 for wrap 1). Debug printf in cycle_barrier (remove after). Co-Authored-By: Claude <noreply@anthropic.com>
…e_cycle helper Add doc/chunking.md documenting the full cycle-chunking design, progress (3 prior commits), the current blocker (cycle 1+ TMEM/TMA pipeline hang), the fix direction (caller loops over cycles with fresh pipeline state), key files, probe scripts, resume steps, and lessons learned. Add for_each_block_single_cycle + get_num_cycles + get_total_pool_blocks to the scheduler (helpers for the 'caller loops over cycles' restructure — not yet wired into the call sites). kChunking=false still verified (baseline). Co-Authored-By: Claude <noreply@anthropic.com>
Enable prefill (large seqlen) for the packed-FP4 mega-MoE kernel by chunking the dispatch PULL into num_cycles rounds of kNumRingTokens pool tokens each, so the per-rank token ring can be ratio*max_seq_len instead of num_ranks*max_seq_len (the num_min that forced a seqlen cliff). Root cause of the prior cycle-1+ deadlock (found via printf isolation): the per-rank counts (stored_rank_count) were only reloaded when the expert changed within a for-loop iteration (old_expert_idx != current_expert_idx). CTAs that pulled 0 tokens in a cycle (broke at the cycle bound before loading) carried stale/zero counts into the next cycle, and with num_active_ranks=0 the round-robin rank-selection while-loop spun forever. Fixed by tracking counts_loaded_expert (the expert the cached counts are loaded for) and reloading whenever current_expert_idx != counts_loaded_expert. Design: the GEMM roles run the ORIGINAL single-cycle for_each_block (cap_blocks=0) and process all pool blocks continuously, gated by the ring full/empty counts (A-load waits for full_count per block; pull waits for empty_count per cycle). The pull's own cycle loop (cycle_end_pool bound) + a cross-CTA grid_sync at each cycle boundary (dispatch warps only) keeps the global ring counts consistent across CTAs. A per-role GEMM cycle loop + shared mbarrier does NOT work because the 5 GEMM roles are pipelined (A-load ahead of epilogue) and reach the cycle boundary at different cycles -- a single mbarrier mixes their arrivals and desyncs. Verified: 1-rank chunk_ratio=0.5 (tok=1024, ring=768<num_min=1024) -> num_cycles=4, diff=0.00079 (<0.05). Baseline + 1-rank matrix unchanged (num_cycles=1, mxfp4 0.00075 / nvfp4 0.00058). Co-Authored-By: Claude <noreply@anthropic.com>
Add PREFILL_1RANK (chunk_ratio=0.5 forces num_cycles=4 on small shapes; ratio=1.3 is a no-chunk sanity) and PREFILL_MULTIRANK (ratio=1.3, EP2 num_cycles=2, EP8 num_cycles=3) shape matrices + a 'prefill' --scope. All pass with diff<0.05 (mxfp4 0.00079 / nvfp4 0.00064). Co-Authored-By: Claude <noreply@anthropic.com>
bench_mega_prefill: EP8 prefill shape (32 experts, topk=4, h=4096, inter=1536) sweeps chunk_ratio in [1.3, 1.5, 2.0, baseline(=world)] and reports ring size, latency, and peak device memory. Shows the latency cost of cycle chunking (~15-20% slower at ratio 1.3 vs the un-chunked baseline) for the memory savings (ring = ratio*tok instead of num_ranks*tok). Co-Authored-By: Claude <noreply@anthropic.com>
…cause, verify results Co-Authored-By: Claude <noreply@anthropic.com>
…hinery The cycle loop now lives entirely in the dispatch PULL (the GEMM uses the original single-cycle for_each_block, cap_blocks=0). Remove the now-unused scheduler cycle API (cycle_barrier, set_cap_blocks, set_cycle_pool_block_end, cycle_pool_block_end, cap_blocks, cycle_ended, current_cycle/get_current_cycle, for_each_block_single_cycle, get_num_cycles, get_total_pool_blocks) and revert fetch_next_l1/l2_block, get_next_block, for_each_block_impl, for_each_block to the original single-cycle logic. Remove the unused cycle_barrier SharedStorage member + init, the smem_barriers +1, and the non-leader MMA warp's no-op for_each_block (which was only for the cycle_barrier). kNumThreads template param kept (constructor still takes thread_idx). Verified: baseline + 1-rank + prefill (1-rank/EP2/EP8) all pass unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
| : (total_pool_blocks + kNumRingBlocks - 1) / kNumRingBlocks; | ||
| if (sm_idx == 0 && warp_idx == 0 && lane_idx == 0) | ||
| printf("[mega_moe] rank=%u num_cycles=%u total_recv=%u cap=%u\n", | ||
| sym_buffer.rank_idx, num_cycles, total_recv, kNumRingTokens); |
There was a problem hiding this comment.
Debug printf left in kernel
Medium Severity
An unconditional printf on SM0 warp0 runs on every mega-MoE kernel launch to print rank, num_cycles, and ring capacity. That is leftover instrumentation, not gated by a debug flag, and adds avoidable overhead and device log noise in production runs.
Reviewed by Cursor Bugbot for commit ae9031d. Configure here.
…s on kChunking Kernel fix: num_cycles was ceil(total_pool_blocks/kNumRingBlocks) ALWAYS, so for ring >= num_min (kChunking=false) it reported num_cycles>1 (wasteful empty tail cycles + extra grid_syncs) when total_pool_blocks > kNumRingBlocks. Now num_cycles = (!kChunking || total_pool_blocks==0) ? 1 : ceil(...), so a big-enough ratio (ring >= num_min) runs exactly 1 cycle. Tests: expand the prefill scope to sweep chunk_ratio across [0.5, 1, 1.5, 2, 5] for 3 1-rank shapes + 3 multi-rank shapes (EP2/EP4/EP8), both formats. Capture the kernel's num_cycles printf (fd-1 redirect) and assert: - ring >= num_min (big enough) -> num_cycles == 1 - ring < num_min AND ratio <= 1 -> num_cycles >= 2 (chunking happens) - otherwise num_cycles >= 1 (correctness guarded by diff<DIFF_TOL) 60 prefill cases total, all pass (mxfp4 0.00079 / nvfp4 0.00064). Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…m-buffer size bench_mega_prefill now defaults to the large prefill shape (E=512, topk=16, M=16384/rank, h=inter=4096) and sweeps chunk_ratio=[1.3,1.5,2.0,4.0,world,None]. None = the ORIGINAL pre-change kernel (ring=786K); world = ring=num_min (un-chunked min ring). Reports ring, num_cycles (captured from the kernel printf), latency, sym-buffer size (computed without allocating), and peak device memory. Dedup ratios. Sample (EP4, 4 free GPUs of 8): baseline 9768us / sym 8.19GB; ratio 1.3 (nc=13) 9897us (+1.3%) / sym 2.35GB (71% reduction); ratio 4.0 (nc=1) 10048us / sym 2.69GB. Chunking overhead ~1-3% for a 5.84GB sym-buffer saving. Co-Authored-By: Claude <noreply@anthropic.com>
…king Investigated the num_cycles=1 perf gap (ratio 4.0 +2.4% vs the un-chunked baseline). Root cause: ring-wrap stall, NOT chunking overhead. total_recv per rank ~= M*topk (262K for this shape); ratio 4.0's ring (66K = num_min) < total_recv, so it wraps 4x and the pull stalls on empty_count at each wrap. ratio 16 (ring=264K >= total_recv, no wrap) matches the baseline (9894 vs 9898 us). The chunked runs (ratio 1.3, 13 cycles + 13 grid_syncs) ~= ratio 4.0 (1 cycle, 0 grid_syncs, 4 wraps) -> the grid_sync/cycle overhead is ~0%. The grid_sync is required on every cycle (incl. last): between cycles it prevents the cross-CTA desync deadlock (world>=4); on the last cycle it prevents the post-loop cleanup (zeroes ring counts) from racing with a slow CTA's still-running pull. Skipping the last-cycle grid_sync was tested and deadlocks EP4. bench: add ratio=topk to the sweep (ring >= M*topk = no-wrap point). Verified: baseline + 1rank + prefill EP2/EP4 pass (EP8 needs 8 GPUs, only 4 free). Co-Authored-By: Claude <noreply@anthropic.com>
…m_cycles=1 perf) The num_cycles=1 perf gap (ring < total_recv -> ring-wrap stall, ~2-3%) is inherent to a ring smaller than total_recv = num_max*num_topk (worst case). The grid_sync itself is free (confirmed: chunked runs with 13 grid_syncs ~= un-chunked small-ring runs with 0 grid_syncs). The fix: give users a way to size the ring to the no-wrap minimum automatically. - chunk_ratio='auto': sizes the ring to num_max*num_topk (no-wrap minimum), matching the un-chunked baseline's latency with minimal memory. Measured (EP4, E=512, topk=16, M=16384): auto (ring=264K) = 9924us ~= baseline (786K) = 9982us, sym-buffer 4.21GB vs 8.19GB (49% saved). - A manually-set float chunk_ratio that yields a ring < num_max*num_topk emits a UserWarning (the ring will wrap, ~2-3% stall; use chunk_ratio>=num_topk or 'auto'). Tests: add 'auto' to PREFILL_RATIOS; assert num_cycles==1 for auto. All prefill cases (1-rank + EP2/EP4) pass. Verified on 4 free GPUs (GPUs 4-7 occupied). Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 93ff23f. Configure here.
| for (uint32_t r = 0; r < kNumRanks; ++ r) | ||
| num_cycles_max = cute::max(num_cycles_max, | ||
| *sym_buffer.map(workspace.get_num_cycles_max_ptr(r), r)); | ||
| num_cycles = num_cycles_max; |
There was a problem hiding this comment.
Printf logs pre-allreduce cycles
Medium Severity
The kernel printf for num_cycles runs before the cross-rank max all-reduce updates num_cycles, so logged and test-captured values reflect per-rank local cycle counts, not the synchronized count the pull loop actually executes under EP.
Reviewed by Cursor Bugbot for commit 93ff23f. Configure here.
… chunk_ratio Setting chunk_ratio < num_topk intentionally wraps the ring (smaller ring = less memory), and the ~2-3% wrap stall is the accepted trade-off. Warning the user about it is counterproductive. Keep chunk_ratio='auto' for users who want no-wrap baseline perf; the wrap-stall case is silent. Co-Authored-By: Claude <noreply@anthropic.com>


Enable prefill (large seqlen) for the packed-FP4 mega-MoE kernel by chunking the dispatch pull into num_cycles rounds, so the per-rank token ring can be ratiomax_seq_len instead of num_ranksmax_seq_len (the num_min that forced a seqlen cliff ~98K/8 ranks, ~49K/16 ranks).
Design: the cycle loop lives in the pull only. The GEMM roles run the ORIGINAL single-cycle for_each_block, gated by the ring full/empty counts (A-load waits for full_count per block; pull waits for empty_count per cycle). The pull cycle loop (cycle_end_pool bound) + a cross-CTA comm::grid_sync at each cycle boundary keeps the global ring counts consistent across CTAs. For multi-rank, ring counts are per-rank so no cross-rank cycle barrier is needed (only the num_cycles all-reduce ensures all ranks run the same cycle count).
A per-role GEMM cycle loop + shared mbarrier does NOT work: the 5 GEMM roles are pipelined (A-load ahead of epilogue) and reach the cycle boundary at different cycles, so a single counted-arrival mbarrier mixes arrivals and desyncs. Abandoned after printf isolation.
Root cause of the prior cycle-1+ deadlock: stored_rank_count was only reloaded when the expert changed within a for-loop iteration. CTAs that pulled 0 tokens in a cycle (broke at the cycle bound before loading) carried stale/zero counts into the next cycle -> num_active_ranks=0 -> the round-robin rank-selection while(true) spun forever. Fixed by tracking counts_loaded_expert and reloading whenever current_expert_idx != counts_loaded_expert.
Verification: baseline num_cycles=1 mxfp4 0.00075 / nvfp4 0.00058 (unchanged); 1rank all pass; prefill 1-rank ratio=0.5 num_cycles=4 diff=0.00079, EP2 ratio=1.3 num_cycles=2 diff=0.00079, EP8 ratio=1.3 num_cycles=3 diff=0.00064; bench EP8 ratio 1.3 227us vs baseline 193us (~18% slower). Full design + debugging notes in doc/chunking.md.
Remaining (follow-up): mirror to sm100_fp8_fp4_mega_moe.cuh; right-size the SF ring; combine_token_buffer chunking for very large seqlen (out of scope, kept as-is).
Generated with Claude Code
Note
High Risk
Changes distributed mega-MoE dispatch synchronization, ring sizing, and CUDA kernel control flow; regressions would show as deadlocks or wrong MoE outputs on multi-GPU prefill, though baseline
num_cycles==1paths are intended to be preserved.Overview
Enables large prefill for packed-FP4 mega-MoE by allowing a per-rank token ring smaller than the old
num_ranks × max_seq_lenminimum, trading symmetric-buffer memory for optional multi-round dispatch.Host API:
get_symm_buffer_for_mega_moegainschunk_ratio(None= prior behavior, float =ceil(ratio × tokens)ring,'auto'= ring sized tomax_tokens × topkfor no-wrap baseline-like latency with less memory). Chunked rings may be belownum_min; sizing only clamps to the upper ring limit.Kernel / runtime: When
ring < num_min, the dispatch pull runsnum_cycles = ceil(total_pool_blocks / ring_blocks)with pool-block bounds, cross-CTAgrid_synceach cycle, cross-rank maxnum_cyclesvia a new workspace slot, and reload of per-rank expert counts viacounts_loaded_expert(fixes hang when a CTA skipped loading counts). GEMM roles stay on the original single-cyclefor_each_block, synchronized by ring full/empty counts—not a per-role cycle barrier. Wave heuristics force one expert per wave when chunking; buffer assert no longer requiresring >= num_min.Tests & benchmarks: Prefill scopes sweep
chunk_ratio, capture kernelnum_cyclesprintf, and assert correctness + cycle behavior;bench_packed_fp4adds a multi-GPU prefill sweep (latency, sym-buffer size, peak memory). Design notes indoc/chunking.md.Reviewed by Cursor Bugbot for commit e0b66df. Bugbot is set up for automated code reviews on this repo. Configure here.