Add MTP Support for Gemma 4#1744
Conversation
|
Pure-CPU EPYC bench: 21.02 t/s vs 7.05 t/s baseline = 2.98× speedup, 84.3% per-token acceptance Tested PR #1744 on AMD EPYC 9655 (96 cores, 1.13 TB DDR5-5600, no GPU) with Gemma 4 31B-it Q4_K_M target + your First attempt segfaulted on first request because of a chicken-and-egg in static bool params_use_gemma4_external_mtp(const gpt_params & params_base) {
return params_base.has_mtp &&
- params_base.speculative.type == COMMON_SPECULATIVE_TYPE_MTP &&
model_has_arch(params_base.speculative.model_dft, "gemma4_mtp");
}The After the fix:
Notably higher speedup than your mixed-CPU/GPU bench (2.3×) — the small 500 MB drafter amortizes well against the slow dense 31B target on CPU; MTP is relatively more impactful when the target is BW-bound. Two minor nits remaining: the size-accounting iteration at Hardware: AMD EPYC 9655 (96C/192T Zen 5), 1.13 TB DDR5-5600 ECC, NPS4, ~460 GB/s aggregate BW. Settings: |
|
Cosmetic follow-up: silencing the two MTP-related "Oops: tensor with strange name" warnings After my pure-CPU bench comment above, one nit remained: the size-accounting iteration at The warnings are cosmetic — One-line patch to silence them, alongside diff --git a/src/llama.cpp b/src/llama.cpp
@@ -2504,6 +2504,12 @@
if (name == "output_norm.weight") {
continue;
}
+ // Top-level (non-blk.*) tensors introduced by Gemma4Assistant (gemma4_mtp arch).
+ // Loaded by create_gemma4_mtp_tensors; not per-layer, so they don't participate in this accounting.
+ if (name == "mtp_pre_proj.weight" || name == "mtp_post_proj.weight" ||
+ name == "mtp_centroids.weight" || name == "mtp_token_ordering.weight") {
+ continue;
+ }
auto pos = name.find("blk.");
if (pos != 0) {
LLAMA_LOG_WARN("Oops: tensor with strange name %s\n", name.c_str());Verified locally on top of Note: there is a separate pre-existing Bonus: in-house GGUF conversion for the 26B-A4B drafter + bench result While testing, I also exercised PR #1744's 26B-A4B + MTP bench on the same EPYC 9655 setup:
Much weaker than 31B Dense (2.98×, 84% accept) — confirms the MoE-batch=1 expert-routing-cancellation behavior that's been reported elsewhere. The smaller 16/8-head drafter struggles to predict MoE expert routing, AND the verifier loads up to K×8 distinct experts per K accepted tokens, eroding the bandwidth savings that make MTP win on dense. Empirical conclusion: on pure CPU, dense Gemma 4 + MTP is the universally favorable case at batch=1; MoE batch=1 is marginal regardless of MTP correctness. Worth flagging in any deployment guidance the PR ends up shipping with. |
|
Gemma 4 MTP is functional, however it does not currently run on a split graph due to the complexities inherent in this MTP architecture. Here are the tests conducted for E2B:
Currently, MTP does not support the embedding layer, which may be linked to the drop in the acceptance rate. Since these are small models, I don’t see why we should invest so much effort in them, given that many should be focusing on 31B and 26B. @pestopoppa Thank you for the tests, I’ve fixed the issues you pointed out. |
ikawrakow
left a comment
There was a problem hiding this comment.
From the testing I have done it seems to be working. Only tested with Gemma4-31B
I'm seeing nearly 3X TG performance improvement running CPU-only, ~2.4X running with full GPU offload (and having the MTP output tensor on the GPU as per one of the comments).
We again suffer a significant PP performance loss. 1.5X compared to split mode layer, 2X+ compared to split mode graph when running on the GPU. On the CPU it is less but still not negligible (10% for 4 draft tokens, but also seems dependent on the the number of drafted tokens).
But overall this is a game changer for using Gemma4. Hopefully when merged this will motivate me to add SWA KV cache compression.
| return; | ||
| } | ||
|
|
||
| if (params.split_mode == LLAMA_SPLIT_MODE_GRAPH || params.split_mode == LLAMA_SPLIT_MODE_ATTN) { |
There was a problem hiding this comment.
We cannot use split mode graph for the main model and split mode layer for the draft model? I guess I'll see why as I go on with the review.
There was a problem hiding this comment.
I believe I've fixed all the issues you pointed out, most of them were indeed errors.
The graph split was a bigger headache, so I was forcing it in layer mode, but now I've been able to spend more time building a K/V that MTP can use safely without triggering an illegal memory error.
| : (int) target_hparams.n_layer; | ||
|
|
||
| int target_il = -1; | ||
| for (int il = 0; il < target_n_kv_layer; ++il) { |
There was a problem hiding this comment.
We want to find the last layer that matches is_sliding, right?
Then why not
int target_il = target_n_kv_layer-1;
for (; target_il >= 0; --target_il) {
if ((target_hparams.swa_layers[target_il] != 0) == is_sliding) {
break;
}
}?
| cb(lctx.inp_tokens, "inp_tokens", -1); | ||
| ggml_set_input(lctx.inp_tokens); | ||
| } else { | ||
| return gf; |
There was a problem hiding this comment.
Do we have use cases where we want to return an empty graph? Because if not, we might as well throw or abort here. If we abort, we immediately know that we should never be building Gemma4 MTP graphs without input tokens. The way it is right now someone reading the code will need to go and check what happens when we get back an empty graph.
| token_embd = ggml_scale(ctx0, token_embd, std::sqrt(float(n_backbone))); | ||
| cb(token_embd, "inp_embd_scaled", -1); | ||
| } else { | ||
| token_embd = ggml_dup(ctx0, hidden_state); |
There was a problem hiding this comment.
Are there use cases where we want to run with the token embeddings being a copy of the hidden state? Because if not, we might as well abort here to reduce the mental burden for the reviewer and people reading this code later.
| cb(cur, "mtp_pre_proj", -1); | ||
|
|
||
| const llama_kv_cache * target_kv = has_target_ctx ? &lctx.mtp_target_ctx->kv_self : &kv_self; | ||
| const int32_t target_n_kv = has_target_ctx ? std::max<int32_t>(lctx.mtp_target_ctx->kv_self.n, n_tokens) : n_kv; |
There was a problem hiding this comment.
The fact that it runs without hitting an assert tells me that n_tokens is never greater than lctx.mtp_target_ctx->kv_self.n (or we never have a target ctx). Because if n_tokens ever became the value of target_n_kv, we will crash later as n_kv needs to be padded to a multiple of 256 (FA on) or 32 (FA off). I.e., also this is an unnecessary mental burden.
| struct llama_cparams cparams; | ||
| struct llama_sampling sampling; | ||
| struct llama_kv_cache kv_self; | ||
| struct llama_context * mtp_target_ctx = nullptr; |
There was a problem hiding this comment.
Do we anticipate to ever have mtp_target_ctx being not NULL and the model it holds being different from the model pointed to by mtp_target_model. Because if we don't, mtp_target_model should not be there as we can get the target model from the target context ptr.
| } | ||
|
|
||
| model.tok_embd = create_tensor(ctx_input, tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0); | ||
| model.output = model.tok_embd; |
There was a problem hiding this comment.
This means the MTP model output tensor is always on the CPU. Isn't this costly when running on the GPU? Normally, for models that use the token embedding tensor as the output tensor we use
model.output = create_tensor(out_ctx, tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab},
llama_model_loader::TENSOR_NOT_REQUIRED);
// if output is NULL, init from the input tok embed
if (model.output == NULL) {
model.output = create_tensor(out_ctx, tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab},
llama_model_loader::TENSOR_DUPLICATED);
}This will duplicate the tensor on the GPU holding the output layer.
OK, made the change and ran a quick test. TG increased from 97.7 t/s to 103.5 t/s.
| } | ||
| n_outputs_prev += lctx.n_outputs; | ||
| n_outputs_prev_embd += has_mtp ? n_tokens : lctx.n_outputs; | ||
| n_outputs_prev_embd += has_mtp && embd ? embd->ne[1] : lctx.n_outputs; |
There was a problem hiding this comment.
Brackets around has_mtp && embd
| ggml_backend_sched_set_only_active_experts(ctx->sched, true); | ||
| } | ||
| if (model->split_mode == LLAMA_SPLIT_MODE_GRAPH && (!model->has_tensor_overrides() || cparams.split_mode_graph_scheduling)) { | ||
| const bool force_layer_scheduling_for_mtp = |
There was a problem hiding this comment.
This change is not related to the Gemma4 MTP functionality. Why do we need it?
|
|
||
| void llama_set_mtp_target_context(struct llama_context * ctx, struct llama_context * target_ctx) { | ||
| ctx->mtp_target_ctx = target_ctx; | ||
| ctx->mtp_target_model = target_ctx ? llama_get_model(target_ctx) : nullptr; |
There was a problem hiding this comment.
OK, this proves that the mtp_target_model member is not required.
|
Not sure if this is obvious to others or not but using After removing those arguments, I'm seeing ~25% tg improvement on simple prompts and ~10% improvement in wall time on mtp-bench.py using |
Thank you for pointing this out. No, it is not immediately obvious. Yes, Hadamard transforms do not work at this point. The draft model uses the KV cache of the main model, so the keys and values have a Hadamard transform applied to them when using |
|
Is there any consents if gemma 4 KV Cache quantization is a good or bad idea anyway? Are there new benchmarks available, because AFAIK the perplexity benchmark is worthless with the IT models. Ministral-3 for example did not care at all, others are super sensitive - and with gemma 4 im currently not using KV Cache quant purely because im uncertain how bad it truly is on long contexts. |
|
@TomTheWise As you point out, one cannot deduce from PPL if quantizing the KV cache has a major impact for Gemma4-it. But if the base Gemma4 models are any indication, it should be OK to quantize down to |
|
Graph split is supported, so a new benchmark for 31B was run using the following arguments
In the current setup, the main model runs in graph-split mode while the MTP remains in layer mode. |
|
Tried this PR on a single-GPU setup (RTX 3090, Windows, CUDA, build 4472 / commit 57e949c). Crashes consistently at build_gemma4.cpp:395 with GGML_ASSERT(has_target_ctx && "Gemma4 MTP assistant requires a bound target context") failed, immediately after the warning ggml_backend_cuda_context: a context for device 0 already exists?. Tested with KV f16, KV q8_0, KV q4_0, with and without GGML_CUDA_NO_PINNED=1, with and without explicit -mtp flag. Target loads fine, draft loads fine, fails at MTP context binding step. Only difference vs your benchmark setup is single-GPU vs CUDA_VISIBLE_DEVICES=1,0 — looks like the context-binding logic might assume distinct contexts for target and draft. |
|
@danganbenpa You shouldn't have any MTP context issues without enabling it with |
|
This is a geamechanger! So in certain szenarios even for the MoE it is definitely worth it!! |
Google has released the MTP for its models as standalone files. The upside is that we don’t need to download the model again, the downside is all the work I had to do to get their architecture to work.
In any case, this PR enables the use of MTP for Gemma 4 models. As a prerequisite, download the MTP, you can download the ones I made for testing:
Or you can make your own, I’ve updated the GGUF conversion files to make it easier.
To use MTP, use these arguments:
If you want to test other draft-max and draft-p-min options, feel free to do so, or just use
--spec-autotune, an algorithm that tests each generated token to find the best fit for your scenario.Benchmark - 1 run with max-tokens 1000
Tested with the Bartowski model.
--draft-max 1--draft-max 2--draft-max 3--draft-max 4Arguments used:
GGML_CUDA_NO_PINNED=1 CUDA_VISIBLE_DEVICES=1,0 ./build_release/bin/llama-server \ --model '/media/samuel/llm_models/gemma-4/google_gemma-4-31B-it-Q8_0.gguf' \ --ctx-size 20480 --main-gpu 1 -ctk q8_0 -ctv q8_0 --n-gpu-layers 99 \ -b 128 -ub 128 --no-mmap --jinja --seed 42 \ --threads 24 --threads-batch 32 --host 127.0.0.1 --port 18080 \ --no-warmup --temp 0 --top-k 1 --top-p 1 \ --spec-type mtp \ -md '/media/samuel/llm_models/gemma-4/gemma-4-31B-it-assistant-Q8_0.gguf' \ -ngld 99 --draft-max N --draft-p-min 0.0Next steps:
Currently-sm graphis broken, since it’s getting late, I’ll leave that for tomorrow, but I want to open the PR now so others can test it.Models with embeddings are supported, but I still need to test them, so don’t set your expectations too high for now.