CUDA: Fuse MMVQ post-scale for NVFP4#24481
Conversation
TODO: 1. Add tests to test-backend-ops (did verify correctness manually for one model) 2. Reorder bias/scale once PRs for NVFP4 are merged/landed
Perf numbers on B4500. Note qwen35 is FP8->Q8 + ./scripts/compare-llama-bench.py -b master -c osimons/nvfp4_fuse_mmvq --tool llama-bench -i llama-bench.sqlite | Model | Test | t/s master | t/s osimons/nvfp4_fuse_mmvq | Speedup | |:-------------------------|:-------------|-------------:|------------------------------:|----------:| | qwen35moe 35B.A3B NVFP4 | tg128@d32768 | 150.15 | 156.29 | 1.04 | | qwen35moe 35B.A3B Q4_K_M | tg128@d32768 | 157.91 | 157.64 | 1.00 | Perf numbers on DGX Spark + ./scripts/compare-llama-bench.py -b master -c osimons/nvfp4_fuse_mmvq --tool llama-bench -i llama-bench.sqlite | Model | Test | t/s master | t/s osimons/nvfp4_fuse_mmvq | Speedup | |:-------------------------|:-------------|-------------:|------------------------------:|----------:| | qwen35moe 35B.A3B NVFP4 | tg128@d32768 | 58.31 | 59.69 | 1.02 | | qwen35moe 35B.A3B Q4_K_M | tg128@d32768 | 54.94 | 54.79 | 1.00 |
1. Unrestrict post-scale fusion 2. Rename names accordingly 3. Remove env variable to disable fusion
This is necessary, as the prolog is quite heavy in GEMV for some quants/model configs, leading to net perf regression. We should really be looking to refactor this such that ratio of prologue/hot-loop/epilogue is better on the hot-loop front: + ./scripts/compare-llama-bench.py -b master -c c1b9381 --tool llama-bench -i llama-bench.sqlite | CPU | Model | Test | t/s master | t/s c1b9381 | Speedup | |:----------------------------|:-------------------------|:-------------|-------------:|----------------:|----------:| | INTEL(R) XEON(R) GOLD 6542Y | gemma4 26B.A4B NVFP4 | tg128@d32768 | 151.70 | 154.32 | 1.02 | | INTEL(R) XEON(R) GOLD 6542Y | gemma4 26B.A4B Q4_K_M | tg128@d32768 | 187.95 | 185.73 | 0.99 | | INTEL(R) XEON(R) GOLD 6542Y | gpt-oss 20B MXFP4 MoE | tg128@d32768 | 304.62 | 300.69 | 0.99 | | INTEL(R) XEON(R) GOLD 6542Y | qwen35moe 35B.A3B NVFP4 | tg128@d32768 | 193.72 | 211.99 | 1.09 | | INTEL(R) XEON(R) GOLD 6542Y | qwen35moe 35B.A3B Q4_K_M | tg128@d32768 | 217.76 | 218.15 | 1.00
Don't need to test unfused combinations
Latest perf numbers: B6000 build: 5b7d9f272 (9578) + ./scripts/compare-llama-bench.py -b master -c osimons/nvfp4_fuse_mmvq --tool llama-bench -i llama-bench.sqlite | CPU | Model | Test | t/s master | t/s osimons/nvfp4_fuse_mmvq | Speedup | |:----------------------------|:-------------------------|:-------------|-------------:|------------------------------:|----------:| | INTEL(R) XEON(R) GOLD 6542Y | gemma4 26B.A4B NVFP4 | tg128@d32768 | 151.79 | 154.10 | 1.02 | | INTEL(R) XEON(R) GOLD 6542Y | gemma4 26B.A4B Q4_K_M | tg128@d32768 | 187.90 | 187.27 | 1.00 | | INTEL(R) XEON(R) GOLD 6542Y | gpt-oss 20B MXFP4 MoE | tg128@d32768 | 303.77 | 306.56 | 1.01 | | INTEL(R) XEON(R) GOLD 6542Y | qwen35moe 35B.A3B NVFP4 | tg128@d32768 | 193.41 | 207.99 | 1.08 | | INTEL(R) XEON(R) GOLD 6542Y | qwen35moe 35B.A3B Q4_K_M | tg128@d32768 | 217.60 | 218.58 | 1.00 | DGX Spark build: 5b7d9f272 (9578) + ./scripts/compare-llama-bench.py -b master -c osimons/nvfp4_fuse_mmvq --tool llama-bench -i llama-bench.sqlite | CPU | Model | Test | t/s master | t/s osimons/nvfp4_fuse_mmvq | Speedup | |:------|:-------------------------|:-------------|-------------:|------------------------------:|----------:| | CPU | gemma4 26B.A4B NVFP4 | tg128@d32768 | 34.61 | 34.84 | 1.01 | | CPU | gemma4 26B.A4B Q4_K_M | tg128@d32768 | 46.95 | 46.90 | 1.00 | | CPU | gpt-oss 20B MXFP4 MoE | tg128@d32768 | 64.84 | 64.62 | 1.00 | | CPU | qwen35moe 35B.A3B NVFP4 | tg128@d32768 | 59.63 | 60.72 | 1.02 | | CPU | qwen35moe 35B.A3B Q4_K_M | tg128@d32768 | 56.53 | 56.55 | 1.00 | PPL values for 5 chunks: this PR model mode ppl uncertainty log /mnt/share/gguf/unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf fusion_enabled 5.2892 0.35389 ppl-value-checks/Qwen3.6-35B-A3B-UD-Q4_K_M.fusion_enabled.log /mnt/share/gguf/unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf fusion_disabled 5.2742 0.35215 ppl-value-checks/Qwen3.6-35B-A3B-UD-Q4_K_M.fusion_disabled.log /mnt/share/gguf/nvidia/Qwen3.6-35B-A3B-2.06GB-per-token-CT/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.gguf fusion_enabled 5.4487 0.36866 ppl-value-checks/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.fusion_enabled.log /mnt/share/gguf/nvidia/Qwen3.6-35B-A3B-2.06GB-per-token-CT/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.gguf fusion_disabled 5.4403 0.36782 ppl-value-checks/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.fusion_disabled.log /mnt/share/gguf/nvidia/Gemma-4-26B-A4B-NVFP4/Gemma-4-26B-A4B-NVFP4_fp8_q8.gguf fusion_enabled 17342.4348 3703.13932 ppl-value-checks/Gemma-4-26B-A4B-NVFP4_fp8_q8.fusion_enabled.log /mnt/share/gguf/nvidia/Gemma-4-26B-A4B-NVFP4/Gemma-4-26B-A4B-NVFP4_fp8_q8.gguf fusion_disabled 18627.0624 3998.42475 ppl-value-checks/Gemma-4-26B-A4B-NVFP4_fp8_q8.fusion_disabled.log /mnt/share/gguf/ggml-org/gpt-oss-20b-GGUF/gpt-oss-20b-mxfp4.gguf fusion_enabled 363.8913 33.14007 ppl-value-checks/gpt-oss-20b-mxfp4.fusion_enabled.log /mnt/share/gguf/ggml-org/gpt-oss-20b-GGUF/gpt-oss-20b-mxfp4.gguf fusion_disabled 363.8913 33.14007 ppl-value-checks/gpt-oss-20b-mxfp4.fusion_disabled.log /mnt/share/gguf/unsloth/gemma-4-26B-A4B-it-GGUF/gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf fusion_enabled 17330.3926 3716.70472 ppl-value-checks/gemma-4-26B-A4B-it-UD-Q4_K_XL.fusion_enabled.log /mnt/share/gguf/unsloth/gemma-4-26B-A4B-it-GGUF/gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf fusion_disabled 17933.9524 3883.17066 ppl-value-checks/gemma-4-26B-A4B-it-UD-Q4_K_XL.fusion_disabled.log master: summary: ppl-value-checks/summary.tsv model mode ppl uncertainty log /mnt/share/gguf/unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf fusion_enabled 5.2892 0.35389 ppl-value-checks/Qwen3.6-35B-A3B-UD-Q4_K_M.fusion_enabled.log /mnt/share/gguf/unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf fusion_disabled 5.2742 0.35215 ppl-value-checks/Qwen3.6-35B-A3B-UD-Q4_K_M.fusion_disabled.log /mnt/share/gguf/nvidia/Qwen3.6-35B-A3B-2.06GB-per-token-CT/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.gguf fusion_enabled 5.4487 0.36866 ppl-value-checks/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.fusion_enabled.log /mnt/share/gguf/nvidia/Qwen3.6-35B-A3B-2.06GB-per-token-CT/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.gguf fusion_disabled 5.4403 0.36782 ppl-value-checks/Qwen3.6-35B-A3B-2.06GB-per-token-CT_fp8_q8.fusion_disabled.log /mnt/share/gguf/nvidia/Gemma-4-26B-A4B-NVFP4/Gemma-4-26B-A4B-NVFP4_fp8_q8.gguf fusion_enabled 17342.4348 3703.13932 ppl-value-checks/Gemma-4-26B-A4B-NVFP4_fp8_q8.fusion_enabled.log /mnt/share/gguf/nvidia/Gemma-4-26B-A4B-NVFP4/Gemma-4-26B-A4B-NVFP4_fp8_q8.gguf fusion_disabled 18627.0624 3998.42475 ppl-value-checks/Gemma-4-26B-A4B-NVFP4_fp8_q8.fusion_disabled.log /mnt/share/gguf/ggml-org/gpt-oss-20b-GGUF/gpt-oss-20b-mxfp4.gguf fusion_enabled 363.8913 33.14007 ppl-value-checks/gpt-oss-20b-mxfp4.fusion_enabled.log /mnt/share/gguf/ggml-org/gpt-oss-20b-GGUF/gpt-oss-20b-mxfp4.gguf fusion_disabled 363.8913 33.14007 ppl-value-checks/gpt-oss-20b-mxfp4.fusion_disabled.log /mnt/share/gguf/unsloth/gemma-4-26B-A4B-it-GGUF/gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf fusion_enabled 17330.3926 3716.70472 ppl-value-checks/gemma-4-26B-A4B-it-UD-Q4_K_XL.fusion_enabled.log /mnt/share/gguf/unsloth/gemma-4-26B-A4B-it-GGUF/gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf fusion_disabled 17933.9524 3883.17066 ppl-value-checks/gemma-4-26B-A4B-it-UD-Q4_K_XL.fusion_disabled.log
|
I think we should add this extension in |
Care to elaborate on what you mean by "simplify the fusion logic"? With this PR the CUDA backend:
I agree we should be generalizing the second point to ggml side. To me, lane-based graph-parsing fit my mental model better as it represents the ML "layers" we are mapping to ggml-ops. I personally preferred this over expanding hard-coded patterns ala to the possible permutations of add & scale we can now support in the CUDA backend. Happy to hear your thoughts on this. |
|
Adding 2. to the ggml side is what I meant by simplifying the fusion. One more hard-coded pattern should be fine for this fusion rather than adding a new type of fusion detection. I will look at refactoring it in case it becomes too cumbersome. |
I lessened the restriction to allow vies to tensors marked as
It's actually three:
in addition to the existing
I generally would recommend the version of master...89fcfc2 that still contains the refactoring as it unifies mmvq fusion paths. We currently have dispersed pattern-validation where
This seemed convoluted to me, hence the refactoring (which comes out at 472 additions and 364 deletions as opposed to 500 additions and 78 deletions when adding 3 more patterns into Unrelated question from my side. Why are other backends not affected by the memory-overlap issue we guard for in |
Co-authored-by: Georgi Gerganov <ggerganov@gmail.com>
This is required so we correctly test fusion of NVFP4.
|
For clarity, the matcher with fixed patterns does it like this we match in the following node-pattern order:
I already gave my take on this verbosity here, recommending to go with the refactored version. If you want I can try to squeeze the bias-only path in the scale + optional bias path, I initially did not to preserve legibility |
There was a problem hiding this comment.
I think it's fine for now. I don't like how intricate the fusion code w.r.t the gated activations has become. It maybe be easier to execute the whole operation as a ggml-op. Trying to make it easier to do that by introducing #24646
JohannesGaessler
left a comment
There was a problem hiding this comment.
I don't like the way ggml_backend_buffer_usage is being integrated into test-backend-ops. My opinion is that we should stick to "normal" usage of the ggml API when possible to minimize discrepancies between the tests and production use. I would suggest the following pattern:
- Instead of just one
ggml_context, instead allocate two of them, one for weights and one for compute. - Allocate both
ggml_contexts withggml_backend_alloc_ctx_tensorsbut setGGML_BACKEND_BUFFER_USAGE_WEIGHTSfor only one of them. - Add a method
build_graphthat accepts twoggml_contextsand implement it for the test case for the new NVFP4 fusion code. To avoid having to change every single test when they don't care aboutGGML_BACKEND_BUFFER_USAGE_WEIGHTS, add a default implementation that simply calls the pre-existingbuild_graphwith only one context. - Also add sentinels to the newly added weight context to enable the detection of out-of-bounds writes.
JohannesGaessler
left a comment
There was a problem hiding this comment.
The MMVQ changes are OK. The changes to the fusion logic in ggml-cuda.cu seem fragile to me; if we are fusing 13 consecutive ggml ops the likelihood of something breaking the fusion is I think non-negligible. This should be fine in terms of at least not causing incorrect outputs but the fallback would be slower. Long-term it may make sense to think about how this could be implemented in a more robust way.
Co-authored-by: Johannes Gäßler <johannesg@5d6.de>
This reflects more natural use of ggml compared to artifically pre-allocating weights into the same context
I'm unsure of the current state, but naively every fusion pattern
should require its own backpropagation implementation. I don't see these
implemented for the CUDA backend, so we can disable tests to avoid
triggering GGML_ASSERT for
ggml_tensor * build_graph(ggml_context * ctx) override {
GGML_ASSERT(!use_weight_context());
return build_graph(ctx, nullptr);
}
|
@JohannesGaessler I implemented the 2nd context as you suggested. Regarding backprop changes:
Happy to hear your thoughts on this. I may be missing something, as my assumptions are rooted in how PyTorch implements SGD.
Agreed, pattern-matching of raw |
|
@ORippler I'm not surprised that the backpropagation tests are broken because they are (to my knowledge) not part of our CI and the implementation is nowhere near an actually usable state. The way backpropagation works in ggml is that the forward and backward pass are part of a single compute graph with gradient updates being a ggml op. So you can in fact not assume that weights will remain constant during the graph execution if they have the corresponding flag. But in any case, as of right now training is broken anyways so it doesn't really matter. |
Co-authored-by: Johannes Gäßler <johannesg@5d6.de>
@JohannesGaessler I see, thanks for the explanation. That would still mean we have to add backward passes for the fusion patterns though right? In this light, I'd just keep them excluded via 3b991ef for now. |
|
HIP CI failure seem unrelated, filed #25361 to track |

Overview
This PR fuses post-GEMV-mul of
w_sfor NVFP4 in the CUDA backend. This gives 1-10% E2E speed-up during decode depending on the platform and model checkpoint. Implements the logic of #24331 w.r.t bias-scale ordering.Additional information
perf evaluation
quality evaluation
computed over 5 chunks on wikitextPR
Master
Additional comments/explanations:
mul_mat_vec_qas we seemingly spend a lot of time in pro/epilogue side of things, especially for small feature dims.ggml_cuda_can_fuse_parsed_subgraphsince ggml does not offer lane-based, flexible node-fusion validation. It mimicks the logic ofggml_can_fuse_subgraphwhile allowing view nodes to external inputs be allowed. This was required to support fusion ofMUL_MAT_ID [RESHAPE -> REPEAT -> GET_ROWS -> MUL expert_scale] [ADD_ID], where the view-src of RESHAPE sits outside the fused pattern (it is a constant weight so I consider this safe to do). Once other backends look to fuse this, we may wish to generally lessen the restriction for view-srcs.Requirements