Gemma 4 拆解 | 多模态怎么塞进端侧,MTP drafter 又是怎么自带投机采样的

Gemma 4 拆解 | 多模态怎么塞进端侧,MTP drafter 又是怎么自带投机采样的

原文:Gemma 4 Technical Report


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 个尺寸,跨度从手机端一路铺到单卡服务器:

Gemma 4 各模型参数拆解

对着这张表说几句人话:

  • E2B / E4BE 是 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),支持变长宽比,靠的是三件套:

  1. axial 2D-RoPE——把 RoPE 拆成横、竖两个轴分别做旋转位置编码;
  2. non-causal attention——图像 patch 之间当然要双向看;
  3. 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。

图像缩放示意:从整图到 soft token

注意这条链路的巧劲: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

先看图,理解它跟主模型的耦合方式:

自回归 MTP drafter 结构

左边灰色是主模型(Layer 1 … Layer N),右边蓝色是 MTP drafter。核心机制报告说得很干脆(2.6 节),我提炼成三点:

  1. 输入:主模型上一步的 last-layer 激活 + token embedding,一起喂进 MTP head。
  2. 结构:drafter 是一个独立的 embedder + 4 层 Transformer block,它cross-attend 到主模型的 KV cache(图里红色 attention 箭头指向 Last Prefilled Local/Global Layer)。
  3. 最关键的收益:正因为它去 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 对比):

Gemma 4 与 Gemma 3 27B 各 benchmark 对比

挑几个我关注的:

  • 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

扒完这篇,我自己几个感受:

  1. 多模态正在”去 encoder 化”。12B 那套 encoder-free、用一个 35M 大 matmul 替掉 550M ViT 的做法,方向上跟”让强 backbone 自己吃原始信号”是一致的。虽然代价是训练侧更重,但对端侧部署(少一堆模块、少一份显存)太友好了。这个趋势值得盯。

  2. MTP drafter 那个”cross-attend 主模型 KV、免 prefill”的设计是真的巧。它把 drafter 从”一个需要独立维护上下文的小模型”变成”寄生在主模型 KV 上的一个 4 层小尾巴”,长 prompt 场景下省掉的 prefill 开销很关键;再加上 top-k 聚类砍掉尾部大 unembed,整套东西透着一股”每一处 overhead 都不放过”的抠劲儿——这正是做推理效率的人该有的样子。

  3. 从 per-layer embedding、value 复用 key 砍 global cache、p-RoPE,到 MTP + QAT,Gemma 4 基本把端侧 LLM 的效率工具箱摊开来挨个用了一遍。单看每一项都不算全新,但把它们整合进一个能真正跑在手机上的模型里,本身就是很硬核的系统工程。

代码都是我按报告描述简化重写的,只为帮理解数据流,跟官方实现肯定有出入,看走眼的地方欢迎评论区拍砖交流。


如果这篇文章涉及的 LLM 推理效率、模型压缩优化你想系统深入,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里有专门讲 LLM 推理效率和参数高效优化的章节,和本文这些抠 overhead 的工程思路算是一脉相承。

动手学AutoML书籍封面

Flag Counter