nano-vllm 用千行代码拆解 vLLM 核心,是读懂大模型推理最快的捷径。
1. 介绍
上一篇拆解了残差流的首尾两站(embed_tokens 与 lm_head)。回到中间的 decoder 层:L13 展示的骨架是 input_norm → self_attn → post_norm → mlp,L14 拆解了两处 RMSNorm、L17 拆解了 self_attn——层里只剩最后一块 MLP。
Qwen3 的 MLP 做三件事:升维 → 门控 → 降维。gate_up_proj 一次 matmul 把 hidden 升到两份宽中间层 gate 和 up,SiluAndMul 把它拆回两半做 silu(gate) * up 的门控,down_proj 再投回 hidden。
本篇的核心概念是 SwiGLU:普通 MLP 中间层每个通道都固定地过一次激活,SwiGLU 多引一条投影 gate 当「软闸门」,每个通道放多少根据输入自适应。
2. 总览
MLP 站在 decoder 层的后半段:post_attention_layernorm 把残差流末端归一化后输入 MLP,MLP 把它升维、门控、再降维,输出一个加回残差流的「增量」。

| 配置项 | 值 | 含义 |
|---|---|---|
hidden_size | 1024 | 残差流宽度 / MLP 的进出维度 |
intermediate_size | 3072 | MLP 中间层宽度(gate、up 各一份) |
hidden_act | “silu” | 激活函数,Qwen3 固定 silu(SwiGLU 的 S) |
图中三段对应后续三节:3.1 升维(gate_up_proj)、3.2 门控(SiluAndMul / SwiGLU)、3.3 降维(down_proj)。
3. 打开 Qwen3MLP:升维 → 门控 → 降维
Qwen3MLP 的 forward 只有三行,正好对应三个子模块:
gate_up = self.gate_up_proj(x) # 升维:1024 → 6144
x = self.act_fn(gate_up) # 门控:6144 → 3072(SiluAndMul)
x = self.down_proj(x) # 降维:3072 → 1024
一个 token 的 hidden 是 1024 个数,相当于它「当前理解」的一份摘要。三步各做一件事:
- 升维(
gate_up_proj,1024→6144):把紧凑的 hidden 摊开到更宽的中间层,一次算出 gate、up 两栏,各 3072(合计 6144)。为什么要先摊宽:在原来 1024 个数里直接算,能拼出的花样有限;摊到三倍宽,给后面门控更多通道去施展,模型能表达的变化更丰富,算完再压回 1024。宽度由intermediate_size定。 - 门控(
SiluAndMul,6144→3072):用 gate 当软闸门逐元素缩放 up——重要的留下、次要的压小,得 3072。为什么要门控:不加 gate 的话,每个通道都一视同仁地过激活、放多少跟当前输入无关;加上这道闸门,同一个通道面对不同输入能开大或关小,模型才会按输入挑重点。闸门怎么开(SwiGLU)是本篇核心,见 3.2。 - 降维(
down_proj,3072→1024):门控挑出的是 3072 个挑过重点的数,可开头那份摘要只有 1024 个数,长短不一没法相加。down_proj 把这 3072 个数重新组合、浓缩回 1024 个,凑成和摘要一样长——这 1024 个数不是一份新摘要,而是「该在原摘要上改多少」的修改量,逐位加到摘要上,这个 token 的理解就更新一点。
整体是「窄→宽→窄」:中间放宽(intermediate 3072)给足容量,进出两端都对齐残差流的 1024 宽度。
# Qwen3MLP(qwen3.py 真实源码 + 注释):升维 → 门控 → 降维
from torch import nn
from nanovllm.layers.activation import SiluAndMul
from nanovllm.layers.linear import MergedColumnParallelLinear, RowParallelLinear
class Qwen3MLP(nn.Module):
def __init__(self, hidden_size, intermediate_size, hidden_act):
super().__init__()
# 升维:gate、up 两个投影合并成一次 matmul(合并投影,省一次 kernel launch)
# 输出 = [intermediate]*2 = gate(3072) ⊕ up(3072) = 6144
self.gate_up_proj = MergedColumnParallelLinear(
hidden_size, [intermediate_size] * 2, bias=False)
# 降维:把门控后的中间层投回 hidden
self.down_proj = RowParallelLinear(
intermediate_size, hidden_size, bias=False)
assert hidden_act == "silu" # Qwen3 固定 silu(SwiGLU 的 S)
self.act_fn = SiluAndMul() # 门控:silu(gate) * up
def forward(self, x):
gate_up = self.gate_up_proj(x) # [N,1024] → [N,6144]
x = self.act_fn(gate_up) # [N,6144] → [N,3072]
x = self.down_proj(x) # [N,3072] → [N,1024]
return x
3.1 gate_up_proj
升维这一步把 hidden(1024)投到更宽的中间层。Qwen3 的 MLP 需要 gate、up 两份中间层(各 intermediate_size=3072),但没有建两个投影,而是用一个 MergedColumnParallelLinear(1024, [3072, 3072])——把两个本该各自 [3072,1024] 的权重拼成一个 [6144,1024],一次 matmul 算出,输出 6144 = gate(3072) ⊕ up(3072) 头尾相接。
gate、up 用的是同一个输入 x,合成一次大 matmul 比分两次少一次 kernel launch。
3.2 SiluAndMul:拆回两半做门控
升维得到的 gate_up(6144)马上被 SiluAndMul 切回两半做门控。这是本篇的核心——SwiGLU。
SiluAndMul 做 silu(gate) * up。先把 6144 的宽向量从中间 chunk(2,-1) 切成 gate、up 两半(各 3072),对 gate 过一个 silu,再和 up 逐元素相乘,得 3072 维。silu(x) = x·σ(x)(σ 是 sigmoid):σ(gate) 落在 0 到 1 之间,像每个通道的「开度」;silu 在开度上又乘了 gate 自身的大小。这套「gate 当门、控 up 通过多少」加 silu 激活的组合,就叫 SwiGLU(Swish-Gated Linear Unit,Swish 即 silu)。
打个比方:up 是一股股要往下流的水,gate 经 silu 后是一排阀门——每个阀门按自己的开度,决定对应那股水放多少过去。
σ(x) 本身能表示开度,为什么还要乘 x:开度 σ 本就落在 0~1,作系数只能把 up 关小、最多原样放行;而且会饱和——gate=3 和 30 的 σ 都≈1,顶到满开、分不出轻重。silu 在开度上再乘 gate 自己,整段乘子 silu(gate) 就突破了 0~1:gate 大时能超过 1(放大)、gate 略小于 0 时还能微微为负,gate 的强弱也被留了下来。代价是这个「阀门」能开过头、不再是干净的 0~1;但实测就是 silu 当门(SwiGLU)比纯 σ 当门(经典 GLU)更好用。
解决了什么:让同一套 MLP 权重按 token 自适应。比如一个 MLP 同时要处理代码里的 { 和散文里的「快乐」——{ 进来时 gate 开大一批通道、关小另一批,「快乐」进来时换一组开关,等于在同一套权重里被门控临时「裁」出两个不同的子网络。普通 MLP 没这道闸门,所有 token 都按同一条激活曲线过,token 间的差异只能靠权重。简而言之,既起到闸门的选择作用,又保留了 gate 的强弱。实测的收益很直接:在差不多的模型规模下,把普通 FFN 换成 SwiGLU,预训练 loss 更低、下游任务也更好。

# SiluAndMul(activation.py 真实源码 + 注释):SwiGLU 的门控
import torch
import torch.nn.functional as F
class SiluAndMul(nn.Module):
# @torch.compile 把下面两步融成一个 kernel
@torch.compile
def forward(self, x):
x, y = x.chunk(2, -1) # 6144 切回两半:x=gate(3072)、y=up(3072)
return F.silu(x) * y # silu(gate) 当开度,逐元素乘 up
import torch
import torch.nn.functional as F
# 造一条宽中间层(N=2、宽=8 便于打印),手算 SwiGLU
gate_up = torch.randn(2, 8)
gate, up = gate_up.chunk(2, -1) # 切成两半:gate、up 各 [2,4]
out = F.silu(gate) * up # SwiGLU:silu(gate) * up
print("宽中间层减半 :", tuple(gate_up.shape), "->", tuple(out.shape)) # (2,8)->(2,4)
# 验证 silu 定义:silu(x) = x * sigmoid(x)
silu = gate * torch.sigmoid(gate)
print("silu = x·σ(x) :", torch.allclose(out, silu * up)) # True
# 开度 σ(gate) 落在 0~1:门开多少由 gate 自己定
print("开度 σ(gate) :", torch.sigmoid(gate)[0].tolist())
宽中间层减半 : (2, 8) -> (2, 4)
silu = x·σ(x) : True
开度 σ(gate) : [0.9031739234924316, 0.7533642053604126, 0.2760199010372162, 0.6877664923667908]
3.3 down_proj:投回 hidden
门控后的中间层是 3072 维,down_proj(RowParallelLinear(3072, 1024))把它投回 hidden 的 1024 维,结束 MLP。单卡下就是一次普通 F.linear。
要注意 MLP 的输出是残差流的「增量」,不是新的残差流本身。把它加回残差流的那个 +,由下一层的 input_layernorm(add_rms)完成。
回看这一节:gate_up_proj 合并 gate、up 为一次 matmul(合一为快),SiluAndMul 拆回两半做门控(拆开为门控),down_proj 投回 hidden。升维、门控、降维三步走完,一层 decoder 的模块就全齐了。
4. 集成验证
加载真实 Qwen3-0.6B,取第 0 层的 mlp,喂一条 [N,1024] 的 hidden,手动走升维 → 门控 → 降维三步,看 shape 流 1024 → 6144 → 3072 → 1024,并验证手动三步与 mlp(x) 完全一致。
import torch
import torch.distributed as dist
from modelscope import snapshot_download
from nanovllm.config import Config
from nanovllm.engine.model_runner import ModelRunner
torch.cuda.set_device(0)
model_path = snapshot_download("Qwen/Qwen3-0.6B")
config = Config(model_path, enforce_eager=True, max_model_len=4096)
runner = ModelRunner(config, 0, [])
mlp = runner.model.model.layers[0].mlp # 第 0 层的 Qwen3MLP
print("gate_up_proj.weight :", tuple(mlp.gate_up_proj.weight.shape)) # (6144, 1024) 升维
print("down_proj.weight :", tuple(mlp.down_proj.weight.shape)) # (1024, 3072) 降维
/opt/conda/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
Downloading Model from https://www.modelscope.cn to directory: /root/.cache/modelscope/hub/models/Qwen/Qwen3-0.6B
2026-06-17 16:25:13,706 - modelscope - INFO - Target directory already exists, skipping creation.
gate_up_proj.weight : (6144, 1024)
down_proj.weight : (1024, 3072)
# 喂一条 [N,1024] 的假 hidden, 查看 shape
x = torch.randn(4, 1024, device="cuda", dtype=mlp.gate_up_proj.weight.dtype)
with torch.inference_mode():
gate_up = mlp.gate_up_proj(x) # 升维
mid = mlp.act_fn(gate_up) # 门控(SiluAndMul)
out = mlp.down_proj(mid) # 降维
full = mlp(x) # 一次 forward 对照
print("升维 :", tuple(x.shape), "->", tuple(gate_up.shape)) # (4,1024)->(4,6144)
print("门控 :", tuple(gate_up.shape), "->", tuple(mid.shape)) # (4,6144)->(4,3072)
print("降维 :", tuple(mid.shape), "->", tuple(out.shape)) # (4,3072)->(4,1024)
print("手动三步 == forward :", torch.allclose(full, out)) # True
升维 : (4, 1024) -> (4, 6144)
门控 : (4, 6144) -> (4, 3072)
降维 : (4, 3072) -> (4, 1024)
手动三步 == forward : True
5. 小结
Qwen3 的 MLP 分三步走:gate_up_proj 升维到两份宽的中间层(6144 = gate ⊕ up),SiluAndMul 拆回两半做 silu(gate) * up ,维度降到 3072,down_proj 投影回 hidden 1024。门控就是 SwiGLU——gate 当软闸门,让每个通道放多少依输入自适应,比普通 MLP 固定过激活更灵活。
一条主线贯穿:gate_up_proj 把 gate、up 合成一次 matmul(合一为快),SiluAndMul 紧接着 chunk 拆回两半(拆开为门控)。合并与拆分的对称,是这一层值得记住的设计。
拆解完 MLP,一层 decoder 的模块就齐了。下一篇讲解权重加载:packed_modules_mapping 怎么把 HF 分开的 gate_proj、up_proj 装进合并的 gate_up_proj。
1124

被折叠的 条评论
为什么被折叠?



