Skip to content

mega-MoE prefill: cycle-chunked dispatch pull (ring < num_min)#4

Draft
ipiszy wants to merge 15 commits into
mxfp4-mxfp4-mega-moefrom
yingz/deepgemm-mem
Draft

mega-MoE prefill: cycle-chunked dispatch pull (ring < num_min)#4
ipiszy wants to merge 15 commits into
mxfp4-mxfp4-mega-moefrom
yingz/deepgemm-mem

Conversation

@ipiszy

@ipiszy ipiszy commented Jul 6, 2026

Copy link
Copy Markdown

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==1 paths 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_len minimum, trading symmetric-buffer memory for optional multi-round dispatch.

Host API: get_symm_buffer_for_mega_moe gains chunk_ratio (None = prior behavior, float = ceil(ratio × tokens) ring, 'auto' = ring sized to max_tokens × topk for no-wrap baseline-like latency with less memory). Chunked rings may be below num_min; sizing only clamps to the upper ring limit.

Kernel / runtime: When ring < num_min, the dispatch pull runs num_cycles = ceil(total_pool_blocks / ring_blocks) with pool-block bounds, cross-CTA grid_sync each cycle, cross-rank max num_cycles via a new workspace slot, and reload of per-rank expert counts via counts_loaded_expert (fixes hang when a CTA skipped loading counts). GEMM roles stay on the original single-cycle for_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 requires ring >= num_min.

Tests & benchmarks: Prefill scopes sweep chunk_ratio, capture kernel num_cycles printf, and assert correctness + cycle behavior; bench_packed_fp4 adds a multi-GPU prefill sweep (latency, sym-buffer size, peak memory). Design notes in doc/chunking.md.

Reviewed by Cursor Bugbot for commit e0b66df. Bugbot is set up for automated code reviews on this repo. Configure here.

ipiszy and others added 9 commits July 6, 2026 07:50
… 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ae9031d. Configure here.

ipiszy and others added 5 commits July 6, 2026 18:30
…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>
…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant