cuda: fuse MoE weighted expert reduction - #25952
Conversation
|
Hi @anujj, thanks for your contribution! Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:
Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below. |
|
@gaugarg-nv @ORippler @JohannesGaessler for review |
ORippler
left a comment
There was a problem hiding this comment.
It seems to me the PR description of previous state doesn't 100% match the current state of the CUDA backend:
- We already fuse
Multi-MUL|ADDin the CUDA backend, launching 2k_bin_b_castinstead of N kernels
- Taking nsight compute to those kernels, they seem to be not running at speed-of-light yet.
- Please comment on what this PR does differently than #17100, where we revert this exact fusion due to PPL issues (was it memory-overlap of input/outputs?)
How I think we should proceed:
- We should fold the ops that do not require GEMM-completion (i.e. everything except the reduction over the experts) into the GEMM-epilogue, similar to GEMV kernel. This does not have to happen all at once. Alternatively, one can look to optimize k_bin_bcast_kernel for the above configurations.
- We should look to bring remaining contraction kernel to speed-of-light (i.e. fully coalesced loads and stores, ideally only once)
@am17an @JohannesGaessler thoughts on above?
| float4 first = experts[first_row * n_embd4 + col4]; | ||
| if constexpr (has_expert_scale) { | ||
| const float scale = expert_scale[first_row]; | ||
| first.x = __fmul_rn(first.x, scale); |
There was a problem hiding this comment.
Why use explicit __fmul_rn? If it is to prevent compiler optimizations: Given we already enable --use_fast_math for the cuda backend, we don't care about bit-wise numerical exactness.
There was a problem hiding this comment.
addressed and revalidated the model correctness though the PPL after allowing normal compiler optimization
| // __fmul_rn/__fadd_rn ordering are identical to the templated kernels, so the result is | ||
| // bit-identical. The hot small-k cases keep their fully-unrolled kernels. |
|
|
||
| #include <climits> | ||
|
|
||
| template <int n_expert_used, bool has_expert_scale> |
There was a problem hiding this comment.
have we validated templating is necessary? A ternary expression where one multiplies with 1 should be fine (unless we actually run into math pipe throttles). May need to massage compiler as in 8a6ca53
There was a problem hiding this comment.
No meaningful gain was observed with the template, so I removed it and handled the scaling based on the suggestions in the review comments.
My original number came from the focused unit test, where that graph allocation didn't trigger the generic MUL/ADD fusion, so the baseline showed 8 launches. On the real Qwen model the generic fusion does kick in, and the baseline is only 2 kernels (a fused two-MUL + a fused seven-ADD) — which my Nsight Systems trace confirms. I've updated the PR description to describe it as 2 → 1 for the production workload (not 9 → 1) and to frame the main benefit as the eliminated intermediate-tensor traffic rather than the launch-count drop. |
am17an
left a comment
There was a problem hiding this comment.
IMO this code needs to be deslopped. Please keep only relevant comments + code minimal. I think fusing the NVFP4 scale should be separate and follow in a subsequent PR.
Please also check why #16857 was reverted, there was a PPL blowup using --n-cpu-moe or multi-GPU IIRC.
| ggml_tensor * dst = cgraph->nodes[output_idx]; | ||
| const uintptr_t experts_begin = (uintptr_t) experts->data; | ||
| const uintptr_t experts_end = experts_begin + ggml_nbytes(experts); | ||
| const uintptr_t dst_begin = (uintptr_t) dst->data; | ||
| const uintptr_t dst_end = dst_begin + ggml_nbytes(dst); | ||
| // The fused op preserves overlapping weights, but cannot safely overwrite expert rows while reading them. | ||
| if (experts_begin < dst_end && dst_begin < experts_end) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
this should be check via ggml_cuda_check_fusion_memory_ranges
| // Largest router top-k we fuse. The bound comes from ggml_can_fuse_subgraph(), which matches at | ||
| // most 31 nodes (fixed int idxs[32] with GGML_ASSERT(count < 32)). Our fused subgraph spans | ||
| // node_count = 2*k + n_muls - 1 nodes (n_muls is 1 or 2), so 2*k + 1 <= 31 gives k <= 15. Real MoE | ||
| // routing uses a small top-k, so this covers every current model; larger k simply falls back to | ||
| // the per-op path. Dispatch: k <= 8 -> per-k fully-unrolled kernels, 9..15 -> one runtime-k kernel | ||
| // (see ggml_cuda_op_moe_weighted_reduction in moe-weighted-reduction.cu). |
There was a problem hiding this comment.
is this true? we're fusing 32 nodes here when max_experts=15?
There was a problem hiding this comment.
scaled form: 2 MUL + 15 VIEW + 14 ADD = 31 nodes
unscaled form: 1 MUL + 15 VIEW + 14 ADD = 30 nodes
I have reworked the implementation substantially to simplify the PR. Regarding the NVFP4 scale fusion, I just wanted to confirm that I understood your suggestion correctly. Are you proposing that (experts * expert_scale) * router_weight should be implemented in a follow-up PR? After the rework, I think the scaled path is much simpler to review as well. However, if you would still prefer to keep it separate, I'm happy to split it into a follow-up PR.
I checked #16857 and ran full WikiText-2 with fusion ON/OFF for single GPU, --n-cpu-moe 10, multi-GPU, and both combined on current PR; all paired PPL blowup is not observed. Also the new implementation validates overlap with ggml_cuda_check_fusion_memory_ranges, preserves aliased weights/scales in scratch, and rejects expert/output overlap Qwen3.6-35B-A3B NVFP4
Qwen3.6-35B-A3B Q4_K_M
Qwen3-30B-A3B Q4_0
Based on these results, enabling the fusion does not introduce a meaningful PPL regression for any of the measured configurations. |
|
The fusion code to me now is starting to look quite bad and it was the only solution before we had support for arbitary fused ops without maintainence burden. So my recommendation would be add this as MOE_REDUCE as well remove everything for the top-k and call it TOPK_MOE (in a separate PR), the ops can be handled quite simply using the |
It's not clear - do you ask if we should introduce |
|
I'm asking if we fuse operations like this using |
|
The I'll need to look more to understand better what is the problem here and why the fusion logic is so complicated. |
|
These MoE ops will be used much more than say dsv4 ops, so it makes sense for them to have dedicated ops. If you take topk-moe for example the fusion detection is way more than the actual kernel, which doesn't let other backends implement the op properly. It also does not play well with the graph allocator as it is not fusion aware. |
|
Thanks for the discussion, @am17an and @ggerganov . Before I rework the PR further, could we align on the preferred direction: should the MoE expert weighting and reduction become a dedicated GGML operation, such as GGML_OP_MOE_REDUCE, or should we continue with the current backend fusion-matching approach? I’m happy to implement either direction, but it would be helpful to agree on the architecture first to avoid unnecessary rework. |
|
I support having new ops for these MoE operations:
|
|
From what I understand, one of the fusions requires 16 source tensors (2x MUL + 14x ADD). This would require to bump It's not great that we had to introduce DSv4-specific ops. The experience is that such ops get obsolete over time (e.g. the RWKV ops). I would rather look for ways to express them with backend fusion logic and deprecate them. So I am not convinced that it's better to introduce dedicated ggml ops in this case. The main purpose of the fusion mechanism in the backends is to avoid the operator explosion that otherwise would occur if we add new ops for each new thing that we want to fuse. IMO it's better to think along the direction to refactor the backend fusion logic, make it easy to maintain and test. |
It's only that way because llama-graph does this way, the actual operations would operate on the source tensor.
Yes, though now with |
|
Which part of the graph does the topk-moe replace? |
Regarding this point specifically, we don't have a clear idea as how to fuse ops without telling the allocator that we wish to fuse these ops. This leads to various races/clobbering issues e.g. #21566. One possible implementation I proposed was #21897 (comment) which may be sufficient for fusion.
in the same function it replaces from probs->logits |
The
|
|
Yes it is not perfect, we need to a way to consolidate that check from O(graph * fused_ops) to O(graph) at some point. The second point can be solved via how we solved the FA stuff on the CPU using the |
|
The moe reduction op is now clear, but do you know how the top-k op signature would look like? |
|
|
|
Note that I'm not advocating for top-k to refactored immediately, but say for adding it to Metal after the refactor would be much easier than adding it now. |
|
I think the topk-moe dedicated op would be tricky to define because there is a lot of branching, so whatever API we come up with, it would likely break really quickly. Regarding the expert reduction - do I understand correctly that the CUDA backend currently does not even fuse the ADDs? That would be surprising - it's quite simple to fuse those ops. |
|
Aman, did you get chance to review this PR ? |
| const float * weights_data = (const float *) weights->data; | ||
| const float * expert_scale_data = expert_scale ? (const float *) expert_scale->data : nullptr; | ||
| const uintptr_t weights_begin = (uintptr_t) weights->data; | ||
| const uintptr_t weights_end = weights_begin + ggml_nbytes(weights); | ||
| const uintptr_t dst_begin = (uintptr_t) dst->data; | ||
| const uintptr_t dst_end = dst_begin + ggml_nbytes(dst); | ||
| ggml_cuda_pool_alloc<float> weights_copy(ctx.pool()); | ||
| if (weights_begin < dst_end && dst_begin < weights_end) { | ||
| // The graph allocator may reuse weights for dst after the original MUL. Fusion reads both at once. | ||
| weights_data = weights_copy.alloc(ggml_nelements(weights)); | ||
| CUDA_CHECK(cudaMemcpyAsync((void *) weights_data, weights->data, ggml_nbytes(weights), | ||
| cudaMemcpyDeviceToDevice, stream)); | ||
| } | ||
|
|
||
| ggml_cuda_pool_alloc<float> expert_scale_copy(ctx.pool()); | ||
| if (expert_scale != nullptr) { | ||
| const uintptr_t scale_begin = (uintptr_t) expert_scale->data; | ||
| const uintptr_t scale_end = scale_begin + ggml_nbytes(expert_scale); | ||
| if (scale_begin < dst_end && dst_begin < scale_end) { | ||
| expert_scale_data = expert_scale_copy.alloc(ggml_nelements(expert_scale)); | ||
| CUDA_CHECK(cudaMemcpyAsync((void *) expert_scale_data, expert_scale->data, ggml_nbytes(expert_scale), | ||
| cudaMemcpyDeviceToDevice, stream)); | ||
| } | ||
| } |
There was a problem hiding this comment.
This is the bad pattern I was talking about earlier. To enable fusion in all cases we basically have to act like a graph allocator ourselves. So for now let's just not enable the fusion if there are overlapping ranges. If after this change basically no fusions happen then we should come back to this once we have a better idea to how to do fusion
There was a problem hiding this comment.
Aman, I tested the strict overlap behavior you suggested on Qwen3.6-35B-A3B NVFP4, Q4_K_M, and and one other model. Without preserving overlapping inputs in scratch, all reduction-fusion sites were rejected for for all 3 models. Therefore, the current end-of-chain fusion is not practically useful without the scratch-copy behavior you objected to.
Would a cleaner direction be to fuse the immediate router-weight MUL into the preceding MUL_MAT_ID GEMM epilogue, then use the CUDA backend’s existing multi-input ADD fusion for the expert reduction?
We could initially support the simple MUL_MAT_ID -> router MUL form and consider scale_2/NVFP4 separately afterward. This would provide:
- A small, fixed two-node matcher instead of matching the complete VIEW/ADD chain.
- Reuse of the existing ADD-reduction fusion.
- No new reduction kernel.
- No fusion-specific scratch allocation or input copying.
- No need for the CUDA backend to compensate for graph-allocation decisions.
- Strict overlap rejection with fallback to the existing path.
- A smaller and less brittle implementation that is easier to test and maintain.
Would this direction address your concerns with the current design?
There was a problem hiding this comment.
Would a cleaner direction be to fuse the immediate router-weight MUL into the preceding MUL_MAT_ID GEMM epilogue, then use the CUDA backend’s existing multi-input ADD fusion for the expert reduction?
Yes - at least in the meaning that it would not require transient memory-allocations
There was a problem hiding this comment.
This is the bad pattern I was talking about earlier.
I don't share this sentiment personally, so long as transient workspace memory-allocation is contained within a single "op" in the CUDA backend. ggml_cuda_pool_alloc exists for this exact reason (i.e. to allocate transient/workspace memory), and having workspace memory is common practice for GPU programming.
There was a problem hiding this comment.
The problem is that it doesn't play well with --fit, also vmm is broken on HIP, we want to move away from pool allocations. That sentiment is shared by both me and @JohannesGaessler. I personally want fusion to be allocator aware so that it works across all batch sizes
There was a problem hiding this comment.
The problem is that it doesn't play well with --fit, also vmm is broken on HIP, we want to move away from pool allocations. That sentiment is shared by both me and @JohannesGaessler.
Hmm. I think having some pre-sized/constant workspace allocation that can then work with --fit would be suitable middle ground (similar to how the memory claimed by CudaRT has to be considered) - We want to keep CudaX libraries (or their cross-IHV components) in there for performance, and as said having workspace memory is a common thing to do in GPU programming (see CUB/CUBLAS).
Agree that we should look to formalize this long term. Combined with more static graphs on llama-side, this would enable graph-compilers to go beyond static-pattern-matching we currently do. |
|
Thanks for putting this up @am17an , i will try and update |
|
Great, we can wait for that to be merged and then rebase this on top |
286a7a8 to
2e14285
Compare
|
i am working on it, i will update the final patch tomorrow |
The MoE combine tail currently writes weighted expert outputs to global memory before reducing them. That intermediate global-memory traffic is the main cost. The production baseline generally runs two physical fused kernels; this path runs one. This change matches the full expert-weighting plus ordered-reduction subgraph and replaces it with one weighted-reduction kernel. Supported graphs: - unscaled: experts * router_weights - scaled: (experts * expert_scale) * router_weights k = 2..15 is handled by one runtime-k kernel. Matching is structural: op sequence, shapes, strides, expert views, and the left-to-right ADD chain. The fused kernel keeps that same reduction order. Results are not claimed bit-identical; CUDA FP32 contraction can change rounding slightly. Allocator integration uses add_alloc_dep from the graph-optimizer API so experts, router weights, and optional expert scales stay live until the fused destination is written. Memory ranges are rechecked before the fused kernel runs. Unrecognized or unsafe graphs are left alone and keep the existing per-op path. Set GGML_CUDA_MOE_WEIGHTED_REDUCTION=0 to disable the fusion. test-backend-ops covers scaled/unscaled, aligned/unaligned, and representative values across k=2..15, plus a k=16 case that must stay on the per-op path.
2e14285 to
eb24eb8
Compare
|
Rebased onto #27301. Experts, router weights, and the optional expert_scale are now registered as allocation dependencies, and the scratch buffer allocation is gone. Final Spark numbers with Qwen3.6-35B-A3B NVFP4:
@am17an : could you help to revie it , please |
| } | ||
|
|
||
| // returns whether the write (out) nodes overwrite the read nodes in operation | ||
| // Returns whether the write (out) nodes overwrite the read nodes in operation. |
| params->add_alloc_dep(params->user_data, const_cast<ggml_tensor *>(match.experts), match.dst); | ||
| params->add_alloc_dep(params->user_data, const_cast<ggml_tensor *>(match.weights), match.dst); | ||
| if (match.expert_scale != nullptr) { | ||
| params->add_alloc_dep( | ||
| params->user_data, const_cast<ggml_tensor *>(match.expert_scale), match.dst); | ||
| } |
There was a problem hiding this comment.
This path is not tested for at the moment. Beyond the scope of this PR, but I feel we should have test-backend-ops reflect the graph_optimize -> graph_compute flow
| return; | ||
| } | ||
|
|
||
| const int64_t token = index / n_embd; |
There was a problem hiding this comment.
if n_embed and index fit in uint_32t, fastdiv should be used (didn't check this myself)
| // The long form spans 2*k + 1 nodes. ggml_can_fuse_subgraph() accepts at most | ||
| // 31 nodes, so k <= 15; larger values use the per-operation path. | ||
| static constexpr int MOE_WEIGHTED_REDUCTION_MAX_EXPERTS = 15; |
There was a problem hiding this comment.
Feels like we should relax that artificial constraint to 32 nodes on ggml side in a follow-up PR
| auto is_weights = [](const ggml_tensor * tensor, const ggml_tensor * full) { | ||
| return tensor && tensor->type == GGML_TYPE_F32 && ggml_is_contiguous(tensor) && | ||
| tensor->ne[0] == 1 && tensor->ne[1] == full->ne[1] && | ||
| tensor->ne[2] == full->ne[2] && tensor->ne[3] == full->ne[3]; | ||
| }; | ||
| auto is_experts = [](const ggml_tensor * tensor, const ggml_tensor * full) { | ||
| return tensor && tensor->type == GGML_TYPE_F32 && ggml_is_contiguous(tensor) && | ||
| ggml_are_same_shape(tensor, full); | ||
| }; |
There was a problem hiding this comment.
Given they are used only in split_mul lambda, we can define them in there
| static bool ggml_cuda_fusion_disabled() { | ||
| static const bool disabled = [] { | ||
| const char * env = getenv("GGML_CUDA_DISABLE_FUSION"); | ||
| return env != nullptr && atoi(env) != 0; | ||
| }(); | ||
| return disabled; | ||
| } |
There was a problem hiding this comment.
Unrelated refactor that could be reverted
| static bool ggml_cuda_use_moe_weighted_reduction() { | ||
| static const bool enabled = [] { | ||
| // Enabled by default. Unrecognized graphs use the per-operation path. | ||
| // Set GGML_CUDA_MOE_WEIGHTED_REDUCTION=0 to disable the fusion. | ||
| const char * env = getenv("GGML_CUDA_MOE_WEIGHTED_REDUCTION"); | ||
| return env == nullptr || atoi(env) != 0; | ||
| }(); | ||
| return enabled; | ||
| } |
There was a problem hiding this comment.
Please remove, there is no need for a separate toggle
| int node_count = 0; | ||
| }; | ||
|
|
||
| static bool ggml_cuda_match_moe_weighted_reduction( |
There was a problem hiding this comment.
Please move the run-time memory-check out of this function (i.e. ggml_cuda_match_moe_weighted_reduction is used by both graph_optimize and try_fuse to match the fusion pattern, but try_fuse afterwards does the check via ggml_cuda_check_fusion_memory_ranges
| for (int64_t expert = 0; expert < n_expert_used; ++expert) { | ||
| views[expert] = ggml_view_2d( | ||
| ctx, weighted, n_embd, n_tokens, weighted->nb[2], expert * weighted->nb[1]); | ||
| } | ||
|
|
||
| ggml_tensor * out = views[0]; | ||
| for (int64_t expert = 1; expert < n_expert_used; ++expert) { | ||
| out = ggml_add(ctx, out, views[expert]); | ||
| } |
There was a problem hiding this comment.
If we support interleaving view and adds in the cuda backend, we should also test for it
am17an
left a comment
There was a problem hiding this comment.
pass gridDim.x as the token and gridDim.y as the ceil_div(n_embd / n_threads). Also we should clean-up before merging this
| int64_t n_embd, | ||
| int64_t n_tokens, | ||
| int n_expert_used) { |
There was a problem hiding this comment.
@am17an @ggerganov I feel we should add nvm we don't use the realloaction beahvior in test-backend-opsggml_backend_graph_optimize(backend, gf) to test_case::eval and test_case::eval_perf in test-backend-ops to ensure we run the fusion code. Can be done in a follow-up PR though, if @anujj checked this PR for correctness (PPL value-diff)
|
|
||
| ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) backend->context; | ||
|
|
||
| static const bool disable_fusion = getenv("GGML_CUDA_DISABLE_FUSION") != nullptr && std::atoi(getenv("GGML_CUDA_DISABLE_FUSION")); |
There was a problem hiding this comment.
Let's extract this to a shared helper in a follow-up pr (used here and in ggml_cuda_try_fuse)
test-suite passes locally, merging |
|
For everyone that arrived at this exact PR / merged commit because of bisecting the significant performance regression in your MI50 / gfx906 or other AMD hardware: Make sure to set the environment variable GGML_CUDA_DISABLE_FUSION=1 to regain performance. The variable is no longer called GGML_CUDA_MOE_WEIGHTED_REDUCTION as stated in the PR description. Has anyone in this ticket actually tested on AMD hardware? unsloth/GLM-4.7-GGUF:UD-IQ2_M without fusion: ~325/9 (pp8192/tg1024) |
* cuda : fuse MoE weighted reduction (mul + view + add) The MoE combine tail currently writes weighted expert outputs to global memory before reducing them. That intermediate global-memory traffic is the main cost. The production baseline generally runs two physical fused kernels; this path runs one. This change matches the full expert-weighting plus ordered-reduction subgraph and replaces it with one weighted-reduction kernel. Supported graphs: - unscaled: experts * router_weights - scaled: (experts * expert_scale) * router_weights k = 2..15 is handled by one runtime-k kernel. Matching is structural: op sequence, shapes, strides, expert views, and the left-to-right ADD chain. The fused kernel keeps that same reduction order. Results are not claimed bit-identical; CUDA FP32 contraction can change rounding slightly. Allocator integration uses add_alloc_dep from the graph-optimizer API so experts, router weights, and optional expert scales stay live until the fused destination is written. Memory ranges are rechecked before the fused kernel runs. Unrecognized or unsafe graphs are left alone and keep the existing per-op path. Set GGML_CUDA_MOE_WEIGHTED_REDUCTION=0 to disable the fusion. test-backend-ops covers scaled/unscaled, aligned/unaligned, and representative values across k=2..15, plus a k=16 case that must stay on the per-op path. * Pruned the test matrix from 15 to 6 * Addressed the aman and olivers review comments


Overview
cuda: fuse MoE weighted expert reduction
The MoE combine tail currently writes weighted expert outputs to
global memory before reducing them. The existing top-k=8 CUDA path
generally uses two physical fused kernels. This path does weighting
and ordered expert reduction in one CUDA kernel. The main benefit is
removing that intermediate global-memory traffic; launch count goes
from two to one.
Supported graphs:
k = 2..15 is handled by one runtime-k kernel. Matching uses ops
shapes, strides, views, add-chain topology, and memory safety. It
does not depend on the model or quantization type.
Allocator integration uses add_alloc_dep from merged PR #27301 so
experts, router weights, and optional expert scales stay live until
the fused destination completes. Memory ranges are rechecked before
the fused kernel runs. There is no scratch allocation and no
device-to-device preservation copy.
Unsupported or unsafe graphs keep the existing per-op path.
Set GGML_CUDA_MOE_WEIGHTED_REDUCTION=0 to disable the fusion.
k = 16 deliberately exercises that fallback.
Additional information
Performance — prefill
t/s, default-ON vs=0, same binary, mean-vs-mean:Qwen3.6-35B-A3B NVFP4 tested with Single GPU, multi-GPU, CPU MoE 10, multi-GPU + CPU MoE 10 and the largest PPL diff ON/OFF was 0.0843%
Testing (both modes, same binary):
test-backend-ops13,178/13,178 pass; CTest 42/42 pass; CPU-vs-CUDA compared; WikiText-2 PPL identical 5.8857 ± 0.03661;llama-clioutput unchanged. CUDA backend only; no changes to weights or numerics.Requirements