Gemma 4 拆解 | 多模态怎么塞进端侧,MTP drafter 又是怎么自带投机采样的
Gemma 4 拆解 | 多模态怎么塞进端侧,MTP drafter 又是怎么自带投机采样的
1. 前言
我看 LLM 技术报告有个习惯:跳过刷榜表格,直接翻到架构章节里那些”为了省内存/省算力我们做了什么妥协”的段落。因为真正决定一个模型能不能跑在你手机上的,从来不是它 MMLU 高了几个点,而是这些抠 overhead 的工程细节。
Gemma 4 这份报告在这方面料很足。这一代最值得聊的两块,恰好都踩在”端侧效率”这个点上:
- 多模态:怎么把图像、音频塞进一个本来只吃文本的 decoder?还要支持任意宽高比,还要在最小的 2B 模型上跑得动?更狠的是它的 12B 版本干脆把 vision / audio encoder 整个砍掉,走”encoder-free”路线。
- MTP drafter:模型自带了一个用于投机采样(speculative decoding)的多 token 预测 drafter,而且设计上巧妙绕开了”drafter 需要单独 prefill”这个老大难。
这篇就重点扒这两块,配简化源码帮你把数据流理顺。刷榜数据放最后,想看的自己翻。
2. 先认识一下这个模型家族
Gemma 4 一共 5 个尺寸,跨度从手机端一路铺到单卡服务器:

对着这张表说几句人话:
- E2B / E4B:
E是 effective。沿用了 Gemma 3n 那套 per-layer embedding 的把戏——总参数 5B / 8B,但每步真正激活的只有 2.3B / 4.5B。这俩也是唯一自带 audio + vision encoder 的(各 305M + 150M),主打端侧全模态。 - 12B:注意它 audio / vision encoder 两列都是
-。因为它走的是 encoder-free 统一架构(第 3.3 节细讲),这是这代最激进的一处改动。 - 26B-A4B:带星号的是 MoE,24,500M 总参数但只激活 2,800M(约 3.8B active),典型的”稀疏激活”路线。
- 31B:最大的 dense 模型。
最右边那列 Drafter——76M 到 500M 不等——就是本文要重点扒的 MTP drafter,每个主模型都单独配了一个。词表 262k。
顺带提一句跟本文主线相关的省内存操作:global attention 层用 p-RoPE(p=0.25),并且在 global 层直接让 value 复用 key,把 global KV cache 砍掉 37.5%。这些都是端侧模型抠内存的常规手段——OOM 是这类模型永远的敌人。
3. 多模态:怎么把图像和音频喂进一个文本模型
3.1 核心矛盾:变长宽比 vs 固定 token 预算
传统 ViT 的做法是把图 resize 成固定的 224×224 或 336×336,简单粗暴。但问题是:一张 16:9 的截图和一张竖屏文档,硬拉成正方形,信息全糊了;可你要是保留原始分辨率,token 数又会爆炸,端侧根本扛不住。
这就是多模态输入绕不过去的核心矛盾:既要保住宽高比,又要卡住 token 预算。
Gemma 4 的 vision encoder 是标准 ViT(patch size = 16),支持变长宽比,靠的是三件套:
- axial 2D-RoPE——把 RoPE 拆成横、竖两个轴分别做旋转位置编码;
- non-causal attention——图像 patch 之间当然要双向看;
- 2D 绝对位置编码。
关键在于它怎么在这对矛盾之间做权衡。报告给了 Algorithm 1,逻辑其实非常朴素——先算一个理想缩放系数 f,让缩放后的总 patch 数刚好卡在预算 N_max 附近,再向下取整到 patch 的整数倍:

N_max 只有 5 个离散档位:{70, 140, 280, 560, 1120}。翻译成代码就一目了然了:
import math
def aspect_preserving_resize(H, W, p=16, k=3, n_max=280):
"""
H, W : 原图高、宽
p : patch size (16)
k : pooling kernel size(相邻 k×k 个 patch 池化成 1 个 soft token)
n_max: 目标 soft token 数上限
"""
m = k * p # 一个 pooled patch 覆盖的像素边长 = 48
T = n_max * (m ** 2) # 面积预算:缩放后总像素数的目标值
f = math.sqrt(T / (H * W)) # 理想缩放系数:让缩放后面积 ≈ T
H_ideal, W_ideal = f * H, f * W
# 向下取整到 m 的整数倍,保证能被 patch + pooling 整除
H_target = (H_ideal // m) * m
W_target = (W_ideal // m) * m
return bicubic_resize(image, H_target, W_target)
报告里的例子很直观(下图):一张 572×1024(约 1:1.79)的图,在 k=3, N_max=10 的设定下被缩成 2×4 个 pooled patch(每个 48px),也就是 96×192;这对应 72 个 16px 的 patch,过完 vision encoder 后再做 3×3 pooling,最终只剩 8 个 soft token 喂给 LLM backbone。

注意这条链路的巧劲:72 个 patch 进 encoder,出来 pool 成 8 个 token 进 LLM。也就是说 vision encoder 内部照常用较多 patch 保住空间分辨率,但真正占用 LLM 上下文的只有池化后那 8 个 token——算力花在 encoder 里,上下文预算省在 LLM 里。这个 trade-off 拿捏得很到位。
vision encoder 本身有两个尺寸(Table 10):150M 版是 d_model=768 / d_MLP=3072 / 12 heads / 16 层,550M 版是 1152 / 4304 / 16 heads / 27 层。E2B/E4B 用小的,大模型用大的。
3.2 音频:直接吃连续表征,不做 VQ
音频这块,E2B/E4B 用的是 305M 的 USM-based conformer encoder:40ms 一个 chunk,Mel filterbank 输入,2 层下采样卷积 + 12 层 Conformer。相比 Gemma 3n 的 680M 砍了 55%。
有个设计选择值得单独拎出来说:它不做 vector quantization(VQ),LLM 直接吃 encoder 输出的连续表征。这跟很多”把音频离散化成 token”的方案是反着来的——VQ 会引入量化损失,直接喂连续 embedding 保真度更高,代价是接口上得多挂一个 projection。encoder 在预训练阶段是冻结的。
3.3 重头戏:Encoder-free 的 12B —— 把 encoder 直接砍了
前面 E2B/E4B 还是”外挂 encoder”的经典范式。但 12B 这版是从头训练的、统一的 encoder-free 架构,把独立的 vision / audio encoder 换成了轻量 projection。这是我觉得整份报告最激进、也最能说明趋势的一处。
视觉侧:12B 直接吃 48×48×3 的 RGB patch,然后——注意——把原来 550M 的 vision encoder 换成一个大矩阵乘法(35M 参数)。空间信息靠给 patch 表征加 2D 坐标位置编码、再过一层 LayerNorm 来维持。
import torch
import torch.nn as nn
class EncoderFreeVision(nn.Module):
"""12B 的 encoder-free 视觉输入:不要 ViT,一个大 matmul 搞定"""
def __init__(self, d_model, patch=48):
super().__init__()
# 原来一整个 550M ViT → 现在就一个 (48*48*3 → d_model) 的线性层(~35M)
self.proj = nn.Linear(patch * patch * 3, d_model)
self.ln = nn.LayerNorm(d_model)
def forward(self, patches, coords):
# patches: [N, 48*48*3] 已拍平的 RGB patch
# coords : [N, d_model] 每个 patch 的 2D 坐标位置编码
x = self.proj(patches) # 一次大矩阵乘 = 全部"视觉编码"
x = x + coords # 注入 2D 空间感知
return self.ln(x) # 直接进 LLM backbone
音频侧更狠:那个 305M 的 USM conformer encoder 被整个丢掉。原始音频按 40ms、16kHz 切 chunk,每个 chunk 就是个 640 维向量,直接 projection 进 LLM 的 embedding 空间。因为音频本身是时序的,连额外的位置编码都省了。
class EncoderFreeAudio(nn.Module):
"""原始波形 → 640 维/chunk → 直接投影进 LLM,没有 conformer"""
def __init__(self, d_model, chunk_dim=640):
super().__init__()
self.proj = nn.Linear(chunk_dim, d_model)
def forward(self, audio_chunks):
# audio_chunks: [T, 640] 每个 40ms chunk 一个向量
return self.proj(audio_chunks) # 就这一步,时序性天然保留,无需 PE
这背后的思路,本质上还是:当 LLM backbone 足够强,”模态编码”这件事可以从一个厚重的独立网络,退化成一个 projection 层,让 backbone 自己去学怎么理解 pixel 和 waveform。省下来的是参数、是显存、是端侧部署的复杂度。代价当然也有——得从头训、要更多数据和算力去 train 这个统一模型。说白了就是把复杂度从推理侧搬到了训练侧,而对端侧场景来说,这笔账是划算的。
4. MTP drafter:给模型自带一个投机采样的”打草稿”小模型
4.1 先说清楚投机采样在解决什么
自回归解码天生是串行的:一次前向只出一个 token,GPU 在 decode 阶段大部分算力其实是闲着的(memory-bound)。投机采样(speculative decoding)的思路是:用一个便宜的小模型(drafter)先一口气猜出未来 k 个 token,再让大模型一次前向并行地”验收”这 k 个。猜对的部分直接白嫖,猜错的从第一个错的地方截断重来。
只要 drafter 猜得够准(acceptance rate 高)、又足够便宜,整体 decode 速度就能翻倍。EAGLE(Li et al., 2024)这一系的做法是让 drafter 复用大模型的 feature / 隐层激活来提高命中率——Gemma 4 的 MTP drafter 走的就是这条线。
4.2 Gemma 4 的 MTP 结构:cross-attend 主模型的 KV,免 prefill
先看图,理解它跟主模型的耦合方式:

左边灰色是主模型(Layer 1 … Layer N),右边蓝色是 MTP drafter。核心机制报告说得很干脆(2.6 节),我提炼成三点:
- 输入:主模型上一步的 last-layer 激活 + token embedding,一起喂进 MTP head。
- 结构:drafter 是一个独立的 embedder + 4 层 Transformer block,它cross-attend 到主模型的 KV cache(图里红色 attention 箭头指向
Last Prefilled Local/Global Layer)。 - 最关键的收益:正因为它去 cross-attend 主模型现成的 KV,它自己不需要单独 prefill,而且支持任意 draft 长度。
第 3 点是我觉得这个设计最漂亮的地方。很多 drafter 方案要么本身是个独立小模型(得自己把 prompt prefill 一遍,长 prompt 下这笔开销不小),要么像 Medusa 那样加多个预测头、但受限于固定结构。Gemma 4 让 drafter 直接寄生在主模型的 KV 上——prompt 的信息在主模型 prefill 时已经算好并缓存了,drafter 白嫖就行。
结构规格:Transformer block 的 model dim,E2B/E4B 是 256,26B-A4B/31B 是 1024;每个都是 3 层 local + 1 层 global attention。
用简化代码把这个 forward 串一遍(省了 rope、norm 等细节,只保留数据流):
class MTPDrafter(nn.Module):
"""自带投机采样的 MTP drafter:cross-attend 主模型 KV,免 prefill"""
def __init__(self, d_main, d_draft=256, n_layers=4):
super().__init__()
self.embed = nn.Embedding(VOCAB, d_draft) # 独立 embedder
# 把 [主模型激活, token embedding] 拼起来再降维到 drafter 维度
self.down_proj = nn.Linear(d_main + d_draft, d_draft)
self.layers = nn.ModuleList([
DraftBlock(d_draft, cross_kv_dim=d_main) # 每层都能 cross-attend 主模型 KV
for _ in range(n_layers) # 3 local + 1 global
])
self.up_proj = nn.Linear(d_draft, d_main) # 投回主模型空间
def draft_one_step(self, prev_hidden, prev_token_id, main_kv):
# prev_hidden : [B, d_main] 主模型上一步的 last-layer 激活
# prev_token_id: [B] 上一步接受的 token
# main_kv : 主模型 prefill 好的 KV cache,直接复用,无需重算
tok = self.embed(prev_token_id) # [B, d_draft]
x = self.down_proj(torch.cat([prev_hidden, tok], dim=-1))
for layer in self.layers:
x = layer(x, cross_kv=main_kv) # ← 寄生在主模型 KV 上
h = self.up_proj(x) # [B, d_main]
return h # 再过主模型 unembed 出下一个草稿 token
@torch.no_grad()
def draft(self, hidden, token_id, main_kv, k=4):
"""自回归地连猜 k 个 token 作为草稿,交给主模型一次性验收"""
drafts = []
for _ in range(k): # 支持任意 draft 长度
hidden = self.draft_one_step(hidden, token_id, main_kv)
token_id = unembed_argmax(hidden) # 复用主模型的 unembed
drafts.append(token_id)
return drafts
一个完整的投机步长这样(drafter 猜 → 主模型并行验收 → 接受最长匹配前缀):
def speculative_step(main_model, drafter, hidden, last_token, main_kv, k=4):
# 1) drafter 便宜地猜 k 个
draft_tokens = drafter.draft(hidden, last_token, main_kv, k=k)
# 2) 主模型一次 forward 并行验收这 k 个
# (省时间的关键:k 个 token 只花大模型 1 次前向)
logits, new_hidden = main_model.verify(draft_tokens, main_kv)
# 3) 从第一个不匹配处截断,前面的全部接受
accepted = accept_longest_prefix(draft_tokens, logits)
return accepted, new_hidden
4.3 Efficient MTP Decoding:别让最后那个大 unembed 拖后腿
这里有个很实在的工程细节。drafter 每猜一个 token,最后都要过一次 unembed,也就是投影到 262,000 的词表上做 softmax——d × 262000 这个矩阵乘,在小 drafter 上反而成了 overhead 大头(drafter 本体才 256 维,结果尾巴上挂了个 26 万维的大矩阵乘,头轻脚重)。
Gemma 4 对 E2B/E4B 的 drafter 做了优化:把”投影到整个词表”换成”在 token 聚类上做 top-k”,最终那个矩阵乘从 d × 262,000 降到 d × 4096,而 acceptance rate 基本不掉。
def cheap_unembed(hidden, cluster_centroids, cluster_tokens, top_k=4096):
# 不再算 d × 262000 的完整 logits
# 1) 先在 token 聚类(centroid)层面选出最可能的若干簇
cluster_logits = hidden @ cluster_centroids.T # 便宜:簇数远小于 262k
top_clusters = cluster_logits.topk(...).indices
# 2) 只在这些簇覆盖的 ~4096 个候选 token 上算真正的 logits
cand = gather_tokens(cluster_tokens, top_clusters) # ~4096 个候选
logits = hidden @ embed_weight[cand].T # d × 4096,省了一个数量级
return logits, cand
这个思路跟稀疏 MoE 的 routing 其实同源——都是”别在全量上算,只在最相关的一小撮上算”。对 drafter 这种要被反复调用 k 次的模块,把尾部大矩阵乘从 26 万降到 4 千,收益是实打实的。
5. 效果:这套组合拳换来了什么
架构讲完,看一眼刷榜(Table 5,跟 Gemma 3 27B 对比):

挑几个我关注的:
- 31B 在 AIME 2026 上 89.2,LiveCodeBench v6 拿到 80.0、Codeforces Elo 2150,比上一代 27B(29.1 / 110)是断层式提升。当然这里 Gemma 4 开了 thinking mode、Gemma 3 是 non-thinking,不完全公平,但代差很明显。
- 26B-A4B 这个 MoE 只激活 3.8B,却能在多数 benchmark 上贴着 31B dense 打(AIME 88.3 vs 89.2),再次说明稀疏激活这条路线在效果/成本比上的价值——这也正是我一直关注的 MoE 方向的核心卖点。
- 就算是最小的 E2B(激活才 2.3B),AIME 也有 37.5,比上一代 27B 的 20.8 还高。端侧模型的天花板在肉眼可见地抬高。
内存这块(Table 3),配合 QAT 量化,E2B 量化后权重只要 0.8GB、E4B 2.3GB——是真能塞进手机的量级。
6. 一点个人 take
扒完这篇,我自己几个感受:
-
多模态正在”去 encoder 化”。12B 那套 encoder-free、用一个 35M 大 matmul 替掉 550M ViT 的做法,方向上跟”让强 backbone 自己吃原始信号”是一致的。虽然代价是训练侧更重,但对端侧部署(少一堆模块、少一份显存)太友好了。这个趋势值得盯。
-
MTP drafter 那个”cross-attend 主模型 KV、免 prefill”的设计是真的巧。它把 drafter 从”一个需要独立维护上下文的小模型”变成”寄生在主模型 KV 上的一个 4 层小尾巴”,长 prompt 场景下省掉的 prefill 开销很关键;再加上 top-k 聚类砍掉尾部大 unembed,整套东西透着一股”每一处 overhead 都不放过”的抠劲儿——这正是做推理效率的人该有的样子。
-
从 per-layer embedding、value 复用 key 砍 global cache、p-RoPE,到 MTP + QAT,Gemma 4 基本把端侧 LLM 的效率工具箱摊开来挨个用了一遍。单看每一项都不算全新,但把它们整合进一个能真正跑在手机上的模型里,本身就是很硬核的系统工程。
代码都是我按报告描述简化重写的,只为帮理解数据流,跟官方实现肯定有出入,看走眼的地方欢迎评论区拍砖交流。
如果这篇文章涉及的 LLM 推理效率、模型压缩优化你想系统深入,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里有专门讲 LLM 推理效率和参数高效优化的章节,和本文这些抠 overhead 的工程思路算是一脉相承。
