Skip to content

第 3 章 前向与生成(代码走读) ​

概念篇介绍了从 token IDs 到 logits,再到自回归生成的过程。本篇沿着 mini-sglang 的 Qwen3 实现走读这条路径,把 Embedding、Attention、MLP 和采样对应到具体函数。

本文固定使用参考提交 9a91cfa。代码块为源码节选或明确标注的教学简化,需要结合原文件阅读;类初始化、权重加载和 GPU 环境不在片段中。先理解一次前向与一次生成的关系,再到第 4 章学习 KV Cache。

1 本章学习目标 ​

完成本章学习后,你将能够:

  1. 定位 Qwen3 的 Embedding、Decoder Layer、Attention 和 LM Head。
  2. 沿张量形状解释 Q/K/V、因果掩码与残差连接。
  3. 说明生成循环为什么只读取每条请求最后一个位置的 logits,以及何时停止。

2 定位源码 ​

以下路径均相对于 mini-sglang 的 python/minisgl/:

文件与入口阅读重点
models/qwen3.py:Qwen3Model、Qwen3DecoderLayer模型整体前向与每层的计算顺序
models/utils.py:RopeAttn、GatedMLPQKV 投影、Attention 与门控 MLP
layers/attention.py:AttentionLayer.forwardQ/K 归一化、RoPE 与 Attention 后端
layers/norm.py:RMSNormFused.forward归一化与残差相加的融合
layers/embedding.py:ParallelLMHead.forward选取预测位置并计算词表分数
engine/engine.py、engine/sample.py执行前向与选择下一个 token

先读模型计算,再读采样。调度器如何组织多个请求留到第 6 章。

3 沿一次前向走读 ​

3.1 从 token IDs 进入模型 ​

Qwen3Model.forward 先查 Embedding 表,再依次经过 Decoder Layer,最后归一化:

python
# models/qwen3.py: Qwen3Model.forward
x = self.embed_tokens.forward(input_ids)
residual: torch.Tensor | None = None
for layer in self.layers.op_list:
    x, residual = layer.forward(x, residual)
return self.norm.forward(x, residual)[0]

这里的 input_ids 是整数索引,Embedding 把每个索引换成长度为 hidden_size 的向量。mini-sglang 将本轮需要计算的 token 打包为一维输入,隐藏状态为 [N,C];N 是本轮 token 总数,C 是隐藏维度。概念篇中的 [B,T,C] 是便于解释的规则 batch 布局,两种表示不要混用。

3.2 一个 Decoder Layer 的计算顺序 ​

Qwen3DecoderLayer.forward 的主体只有五行:

python
# models/qwen3.py: Qwen3DecoderLayer.forward
x, residual = self.input_layernorm.forward(x, residual)
x = self.self_attn.forward(x)
x, residual = self.post_attention_layernorm.forward(x, residual)
x = self.mlp.forward(x)
return x, residual

源码为什么没有显式的 x + attention(x)?相加被放进了 RMSNormFused。首次调用时,它保存原始 x 为 residual,并返回归一化结果;后续调用把当前计算结果加到 residual,再归一化。MLP 的残差相加延后到下一层的输入归一化,最后一层则由模型末尾的 self.norm 完成。

展开后,数学顺序仍然是 h = x + Attention(RMSNorm(x)),然后 out = h + MLP(RMSNorm(h))。源码同时传递 x 和 residual,是为了融合这些操作。

3.3 QKV、RoPE 与因果 Attention ​

在 models/utils.py 中,Qwen3 使用 RopeAttn,并启用 has_qk_norm=True:

python
# models/utils.py: RopeAttn.forward (memory-release statements omitted)
qkv = self.qkv_proj.forward(x)
o = self.attn.forward(qkv)
return self.o_proj.forward(o)

qkv_proj 合并三个线性投影。进入 AttentionLayer.forward 后,结果按 Q、K、V 拆开;Q/K 沿每个头的最后一维做 RMSNorm,再按 token 位置应用 RoPE。V 不做 RoPE。最后交给 Attention 后端,输出投影把所有 Q 头的结果映射回 C。

用规则布局展开同样的注意力数学,核心如下。这是教学简化,不是上述 GPU 后端的源码:假定 q、k 已完成 Q/K 归一化和 RoPE,形状分别为 [B,Hq,T,D]、[B,Hkv,T,D],v 为 [B,Hkv,T,D],没有 padding 或缓存。

python
import torch

groups = q.shape[1] // k.shape[1]
k = k.repeat_interleave(groups, dim=1)
v = v.repeat_interleave(groups, dim=1)
scores = q @ k.transpose(-1, -2) / q.shape[-1] ** 0.5
T = q.shape[-2]
allowed = torch.ones(T, T, dtype=torch.bool, device=q.device).tril()
scores = scores.masked_fill(~allowed, float("-inf"))
weights = scores.float().softmax(dim=-1).to(q.dtype)
attention = weights @ v

GQA 要求 Hq 能被 Hkv 整除,让多个 Q 头共享一组 K/V;这里显式复制 K/V 只是为了看清矩阵运算。下三角掩码使第 i 个位置只能访问 0..i,不能读取未来 token。head_dim 是独立配置,不能一概假设 C = Hq * D。

3.4 门控 MLP ​

GatedMLP.forward 的主体为:

python
# models/utils.py: GatedMLP.forward (memory-release statements omitted)
gate_up = self.gate_up_proj.forward(x)
y = self.act_fn(gate_up)
return self.down_proj.forward(y)

在使用 SiLU 的 Qwen3 配置中,合并投影得到 gate 与 up 两部分,act_fn 计算 silu(gate) * up,再通过 down 投影回到隐藏维度。MLP 逐 token 变换特征,token 之间的信息交换由 Attention 完成。

4 从 logits 到生成循环 ​

4.1 只保留每条请求的最后一个位置 ​

Qwen3ForCausalLM.forward 将模型输出交给 LM Head:

python
# models/qwen3.py: Qwen3ForCausalLM.forward
output = self.model.forward(get_global_ctx().batch.input_ids)
logits = self.lm_head.forward(output)
return logits

继续打开 ParallelLMHead.forward:prefill 时,它先用 get_last_indices 选出每条请求最后一个位置的隐藏状态,再做词表投影。因此,供采样使用的是每条请求一行的 logits,而不是概念示例中的全部 [B,T,V] 行。

例如输入 [1,2,3],位置 2 的 logits 预测“读完 3 后的下一个 token”。前面的位置对应更早的预测,不能拿来继续当前前缀。

4.2 把一步计算放进循环 ​

下面是单请求、无 KV Cache 的教学简化,用于连接概念篇与真实引擎。forward_all(tokens) 是已初始化模型的接口,输入为非空 [1,T] 整数张量,返回所有位置的 [1,T,V] logits;它不是 mini-sglang 的同名 API。模型应已设为推理模式,max_new_tokens 为非负整数。

python
import torch

@torch.inference_mode()
def generate(forward_all, tokens, max_new_tokens, eos_id=None):
    output = []
    for _ in range(max_new_tokens):
        logits = forward_all(tokens)[0, -1]
        token = int(logits.argmax())
        output.append(token)
        tokens = torch.cat((tokens, tokens.new_tensor([[token]])), dim=1)
        if token == eos_id:
            break
    return output

每轮只做“前向 → 选 token → 追加到前缀”。函数返回新增 token,EOS 本身计入结果;长度上限为 0 时不调用模型。这个版本会反复计算整个前缀,正好引出下一章 KV Cache 要消除的重复工作。

4.3 对应到引擎中的采样 ​

真实实现把循环分散在调度器与引擎中。Engine.forward_batch 计算 logits 后,调用 Sampler.sample。以下节选保留它的两条选择路径:

python
# engine/sample.py: Sampler.sample (profiling context omitted)
if args.temperatures is None:
    return torch.argmax(logits, dim=-1)
return sample_impl(logits.float(), args.temperatures, args.top_k, args.top_p)

全贪心 batch 直接取 argmax;其他情况进入温度与 Top-k/Top-p 采样路径。采样只负责选 token,输出长度与 EOS 由调度器处理。eval() 控制模型层的推理行为,inference_mode() 关闭梯度相关记录,两者作用不同。

5 用形状与因果性检查理解 ​

先在纸上推演一个规则布局的例子:B=1,T=3,C=32,Hq=4,Hkv=2,D=8,V=64。这里的表对应第 3.3 节教学片段,而非引擎的打包布局。

位置张量形状
输入 / Embedding[1,3] / [1,3,32]
Q / 原始 K、V[1,4,3,8] / [1,2,3,8]
Attention 分数[1,4,3,3]
全位置 logits / 下一步分数[1,3,64] / [1,64]

检查三个关系:修改未来输入不应改变较早位置的输出;每一步只追加一个新 token;达到长度上限或已确认 EOS 后停止。对照源码时,再确认哪些全位置计算被 LM Head 的最后位置选择省去了。

6 总结与测试题 ​

6.1 课程总结 ​

一次前向沿 Embedding、Decoder Layer 和 LM Head 得到预测分数;生成循环从最后一个位置选择 token,再更新前缀。mini-sglang 用打包输入、融合残差和最后位置选择组织同一套计算,阅读时需要把数学步骤与张量布局对应起来。

6.2 测试题 ​

  1. 为什么 mini-sglang 的 prefill LM Head 不必对每个输入位置都做词表投影?
  2. Qwen3DecoderLayer.forward 没有显式残差加法,它在哪里完成?
  3. 若 Hkv 从 2 改为 1,第 5 节表中哪些形状会变化?
  4. 去掉因果掩码后,修改未来 token 会影响哪些输出位置?
  5. 无缓存生成循环重复计算了什么?下一步可以保存哪些结果?

参考资料 ​