cuda: device-routed MoE prefill (on-device expert counting-sort) - #322
cuda: device-routed MoE prefill (on-device expert counting-sort)#322jasstrong wants to merge 2 commits into
Conversation
The large-batch MUL_MAT_ID path copied the expert ids to the host and sorted token-slots by expert in a CPU loop bracketed by two stream synchronizes, leaving the GPU idle. For a 256-expert model that loop is O(experts * tokens * top-k); its cost grows linearly with the ubatch size while the GEMM gets more efficient, so it can take a sizeable slice of prefill (measured ~10% at ubatch 512, ~25% at ubatch 2048). Replace it with an on-device counting sort (histogram -> exclusive scan -> scatter) in moe-devsort.cu. Only tokens_per_expert (one int per expert) is copied back to the host, for per-expert GEMM sizing. The routing is bit-identical to the host loop -- it only changes the order of rows within an expert, which cannot change any per-token GEMM result -- and it is weight-type agnostic (it sorts ids, never touches weights). On by default; set GGML_MOE_HOST_ROUTE=1 to force the original host loop. Prefill (llama-bench, no draft model, Qwen3-MoE 35B-A3B, ubatch 2048): RX 7900 XTX / gfx1100 TQ3 pp512 837->987 (+18%) pp2048 1384->1925 (+39%) Radeon PRO V620 / gfx1030 TQ4 pp512 415->438 (+5.5%) pp2048 711->781 (+9.9%) Generated tokens verified bit-identical (host loop vs device sort) on both.
PR #322 Review — device-routed MoE prefill (on-device expert counting-sort)Repo/PR: #322 VerdictSolid, well-targeted, correct. Recommend merge, with two minor asks for the author (below). What it doesReplaces the CPU-side MoE expert-routing loop in the large-batch Scope — does this only affect TQ3_1S/TQ4_1S models?Not hardcoded to those types, but in practice, yes, on this fork. The changed function
Confirmed empirically: a stock Q4_K_XL MoE model on the test RTX 5090 never reaches this code at Static review
Empirical verificationBuilt the PR in an isolated git worktree against the actual project GPU (RTX 5090, 32GB). No TQ Bit-exactness (device-sort route vs.
Performance (
This reproduces the PR's own claimed pattern — larger gains at larger ubatch — on different Requested changes (minor, not blocking)
The author's own note about missing |
Per the TheTom#322 review: - Guard the expert index e in k_moe_hist (histogram atomicAdd) and k_moe_scatter (offsets[e]/fill[e] and the derived scatter write) against an out-of-range value, threading ne02 (n_experts) into both kernels. A device-side OOB is worse than the host path's release-stripped assert; well-formed ids never trip it. - Note that k_moe_excl_scan is deliberately single-threaded over the (small) expert count, to be revisited if a much-higher-expert-count model appears.
Per the TheTom#322 review: - Guard the expert index e in k_moe_hist (histogram atomicAdd) and k_moe_scatter (offsets[e]/fill[e] and the derived scatter write) against an out-of-range value, threading ne02 (n_experts) into both kernels. A device-side OOB is worse than the host path's release-stripped assert; well-formed ids never trip it. - Note that k_moe_excl_scan is deliberately single-threaded over the (small) expert count, to be revisited if a much-higher-expert-count model appears.
|
Thanks for the thorough review and the independent 5090 bit-exact/perf confirmation — much appreciated. Both addressed:
On test-backend-ops coverage: agreed a |
Summary
The large-batch
MUL_MAT_ID(MoE) path currently routes experts on the host: it copies theidstensor device→host, then runs a CPU loop overexperts × tokens × top-kto group token-slots by expert, bracketed by twocudaStreamSynchronizes. The GPU sits idle through all of it.For a 256-expert model that loop is
O(ne02 × ne12 × n_expert_used)and grows linearly with the ubatch size, while the GEMM it feeds gets more efficient per token as the batch grows. So the fixed-per-token CPU routing becomes a larger and larger slice of prefill as ubatch increases.Measured stall (
std::chronoaround the loop + syncs, Qwen3-MoE 35B-A3B on gfx1100):What this changes
Replaces the host loop with an on-device counting sort (
ggml/src/ggml-cuda/moe-devsort.cu): histogram of tokens per expert → exclusive scan → scatter. It writesids_to_sorted/ids_from_sortedstraight into the existing device buffer; onlytokens_per_expert(oneint32per expert) is copied back to the host, which is still needed to size the per-expert GEMMs.llama-cligeneration is token-for-token identical to the host path on TQ3 (gfx1100) and TQ4_1S (gfx1030).ids; it never touches weights. Helps every MoE model that reaches this path, not just the turbo types.atomicAdd(int)and a small serial scan — no warp/wave-size assumptions — so it also carries to CDNA/MFMA parts unchanged.GGML_MOE_HOST_ROUTE=1to force the original host loop (kept intact as theelsebranch,GGML_ASSERTand all).Benchmarks
llama-bench, no draft model, Qwen3-MoE 35B-A3B, ubatch 2048:(RDNA2's smaller gain is expected — no matrix cores, so its GEMM is ~2× slower and the fixed routing loop is a smaller fraction of the longer prefill.)
Notes / scope
test-backend-ops, because itsMUL_MAT_IDcases are absorbed by the mmq/mmf paths before reaching this host-routed fallthrough, so they don't exercise it. Happy to add a targeted test if you'd like one.GGML_ASSERTenforces this); the device path counts each(token, slot)once, which agrees for all valid top-k routing.