元素级图解 · 2026-11-09
别只背 shape。
看一个数怎样走过 Transformer。
每张图都给出轴的含义、实际小矩阵、变换前后的坐标,以及那一步究竟有没有做计算。从一个格子出发,再回到真实模型。
先做「分头与转置」→「attention 两次乘法」→「合头」。每次只追踪一个格子;能说出它的新坐标,再看整张量。页面较长,可以按下面目录分次完成。
0 · 一个维度就是一个需要选择的坐标
X[b,t,h] 的意思是:先选第 b 条请求,再选第 t 个 token,最后选它向量里的第 h 个数。[B,T,H]=[1,3,4] 表示有1条请求、每条3个 token、每个 token 4个数,总共1×3×4=12个数。方括号记录每个轴的长度,不是元素本身。
整页默认小例:B=1、T=3、H=4、Hq=2、Hkv=1、d=2、F=6。分头交互单独用B=2,以便你切换batch;真实模型例子另列。B、T、Hq、d表示轴长;b、t、hq、j表示下标。下标从0开始。各节用独立的数值小例解释一个操作,并非在运行同一个完整模型;投影和attention的静态图与交互使用相同数值。
| 操作 | 哪些东西改变 | 先问自己 |
|---|---|---|
| reshape / view | 轴的分组方式,元素总数不变 | 哪几个相邻逻辑轴被拆开或合并? |
| transpose / permute | 轴的顺序与索引关系,值不变 | 新坐标怎样对应旧坐标? |
| Linear / matmul | 沿一个轴乘加,计算新值 | 哪一轴被求和?输出保留哪些轴? |
| split / slice / gather | 取一段或选若干元素 | 按哪个轴取?是连续区间还是索引列表? |
| softmax / RoPE / norm | 通常保持shape,但数值改变 | 在哪个轴计算,哪些位置相互作用? |
图中每次只展开二维切片,其他轴明确写成“固定 b=0 / h=1”。四维张量不是必须画成四维立方体;选定两根轴后,可以像普通表格一样读。
1 · Embedding:用整数选行,增加特征轴
- 输入 IDs 是 [B,T],每格是词表中的一个整数索引。
- 查表 E 的形状是 [Vocab,H]。输入 token_id=2,就取 E 的第2行,一次拿到H个数。
- 每个原来的格子变成一个长度H的向量,所以结果是 [B,T,H];这不是把 ID reshape 成浮点向量。
小例 [1,3] → 查 [Vocab,4] 的3行 → [1,3,4]
源码:Embedding / LM head。取行的索引关系由embedding定义;投影是下一步。
2 · QKV:先计算新特征,再切成三段
固定 b=0,输入 X 是3×4矩阵。数学权重 Wqkv 是4×8。每个输出格子都来自一行 X 和一列 W 的4次乘加,因此输出是3×8。这里 Q宽4、K宽2、V宽2,三段不等宽。
Y[t,c] = Σₖ X[t,k]W[k,c]
Q=Y[:,0:4];K=Y[:,4:6];V=Y[:,6:8]
数学W和PyTorch weight方向相反:数学用 X @ W,W=[in,out];Linear.weight=[out,in],执行的是 X @ weight.T。矩阵图采用前者。
真实 Qwen3-0.6B:H=1024,Hq=16,Hkv=8,d=128;投影为1024→4096,再split为2048、1024、1024。Hq·d=2048,不等于H。小例恰好相等,不能推广。
证据:Qwen3Attention.forward、固定的官方config副本。
启用 JavaScript 后可点击矩阵与切换步骤;静态图、索引等式和练习在离线时也可阅读。
3 · 分头与转置:先拆一根轴,再交换两根轴
[B,T,Hq·d] → [B,T,Hq,d]:把每个token的一长行切成Hq小段,每段d个数。原末维 c=hq×d+j。[B,T,Hq,d] → [B,Hq,T,d]:把token轴和head轴交换,便于按每个head做矩阵乘法。值不变,新坐标为 q_heads[b,hq,t,j]=q_split[b,t,hq,j]。- 前一步是拆轴;后一步是换轴。直接reshape到最终shape,可能把不同token和不同head混在同一行。
启用 JavaScript 后可点击矩阵与切换步骤;静态图、索引等式和练习在离线时也可阅读。
进一步:shape相同,stride也可能不同
以交互里的连续输入 [2,3,4] 为例,stride=[12,4,1]。拆末维后 [2,3,2,2] 的stride=[12,4,2,1];transpose后shape=[2,2,3,2],stride=[12,2,4,1]。stride表示索引在该轴加1,需要跨过多少个元素的存储位置。
所以 transpose 通常只是改视图,不是把数据物理搬走。contiguous() 在需要时物理重排;reshape 可能返回view或copy。不能教成“非连续一律不能view”:split后的Q虽然行间仍跨着K/V的数据,也可能合法地view拆分末维;要看具体stride条件。
4 · QKᵀ 和 AV:两次求和,消掉的轴不同
固定一个 batch、一个query head。Q的行是query位置,K的行是key位置;先转置K,把每个key变成一列。Q的一行和Kᵀ的一列内积,产出一个“这对位置有多匹配”的分数。
A [Tq,Tk] × V [Tk,d] → O [Tq,d]:消掉 Tk
softmax在每行的Tk轴上归一化,不把行压成一个数,因此 [Tq,Tk] 形状保持不变。AV才沿key位置求和,把内容汇总回每个query的d维向量。
带缓存时Tq和Tk通常不同。例如历史p=3、新chunk c=2:Q=[2,d]、K/V=[5,d],scores和mask都是[2,5],输出=[2,d]。mask允许 j≤p+i;第一行可见4列,第二行可见5列。
启用 JavaScript 后可点击矩阵与切换步骤;静态图、索引等式和练习在离线时也可阅读。
5 · GQA:不同Q头共享K/V头,不减少Q头数
设g=Hq/Hkv,配对规则是 kv_head=⌊q_head/g⌋。本页Hq=2、Hkv=1,两组Q都读取KV head0;这是GQA里的MQA特例。一般GQA例如Hq=4、Hkv=2时,Q0/Q1→KV0,Q2/Q3→KV1。各组Q不同,算出的attention权重仍可不同。输出保留Hq个head。
Q:Hq个头 → 输出O:Hq个头;缓存K/V:各Hkv个头
公式为展示乘法会把K/V写成“按每个Q头配对”的形状。优化kernel可以直接读取共享head,不要求真的repeat成Hq份。repeat_interleave是便于验证的显式实现,不能拿重复后的张量大小计算真实KV cache。
本地调用:Attention.forward;官方算子约定:PyTorch SDPA。
6 · 合头:同一个token的各head,要放回同一行
- 如果O是[B,Hq,T,d],先transpose成[B,T,Hq,d]。
- 再合并最后两根轴,得到[B,T,Hq·d]。每行收集同一token的所有head。
- 经过Wo投影,Hq·d→H,回到[B,T,H],这时才能与残差输入逐元素相加。
Y[b,t,k] = Σ𝚌 concat[b,t,c] Wo[c,k]
reshape不会替你理解“同一个token”。直接把head-first的O reshape成[B,T,Hq·d],会把一个head里相邻token拼在一起。上面交互可切到“合头”对照。
回到nano源码要换布局:prefill的FlashAttention直接收发packed [N,Hq,d],这里没有先出现[B,Hq,T,d],所以输出可以直接flatten head与d。decode的backend还可能保留长度1轴,flatten仍得到[N,Hq·d]。不要给源码凭空补一个transpose。
证据:qwen3.py 的view / flatten / o_proj、prefill与decode的backend调用。
7 · SwiGLU:扩宽、切片、逐元素相乘、投回
- X=[B,T,H] 经过fused gate_up投影得到[B,T,2F];小例4→12。
- 沿最后一轴split成gate和up,各[B,T,F];小例每段6列。
- SiLU(gate)与up在同一坐标逐元素相乘,还是[B,T,F]。这里的⊙不是矩阵乘法,token轴和特征轴都没有被求和。
- down投影用[F,H],把最后一轴6→4,输出[B,T,H]。残差x+output要求形状一致。
out[b,t,h] = Σ𝒻 z[b,t,f] Wdown[f,h]
真实Qwen3-0.6B:1024→6144→两份3072→逐元素相乘3072→1024。RMSNorm通常保持形状,只沿指定特征轴计算统计量;Q/K norm沿d,decoder norm沿H。
证据:Qwen3MLP、SiluAndMul。
8 · RoPE:维度没变,也要看清哪两个分量配对
本地nano的布局是把最后一轴平分成两半,不是直接把相邻两个数配对。用d=4的单独小例,x=[a,b,c,d]拆为x1=[a,b]、x2=[c,d],配对是(a,c)、(b,d)。
| 输入坐标 | 配对 | 输出仍占原来的4个坐标 |
|---|---|---|
| x[0]=a,x[2]=c | 第0个旋转角θ₀ | y[0]=a cosθ₀−c sinθ₀;y[2]=a sinθ₀+c cosθ₀ |
| x[1]=b,x[3]=d | 第1个旋转角θ₁ | y[1]=b cosθ₁−d sinθ₁;y[3]=b sinθ₁+d cosθ₁ |
例如令两角都为90°方便手算,[1,2,3,4]→[−3,−4,1,2]。真实各对子频率通常不同,这里不模拟频率。Q和K各按自己的head数旋转,V保持原值;形状不变。
证据:apply_rotary_emb;继续读第19课。
9 · 变长组批:N是有效token总数,不是B×Tmax
A长3、B长2:补齐表示会有[2,3,H],其中一行是PAD;packed表示只有[5,H]。需要先选择有效token并串接,不能只靠reshape删除PAD。cu_seqlens=[0,3,5]记录两条请求的边界,positions=[0,1,2,0,1]记录各自的位置。
Qpacked [N,Hq,d];K/Vpacked [N,Hkv,d]
每段独立attention,不能把5个token当成同一条长度5的请求
这个小例是完整prefill;prefix命中、chunked prefill和decode里,新query长度与可读取key长度会不同。源码的cu_seqlens_q与cu_seqlens_k必须分别读。
证据:prepare_prefill,varlen attention调用。
启用 JavaScript 后可点击矩阵与切换步骤;静态图、索引等式和练习在离线时也可阅读。
10 · KV:增长的是历史位置轴,分页改变寻址
单请求、单层的逻辑K为[Tcache,Hkv,d],V相同。新chunk的Knew为[c,Hkv,d],写入后可读长度为Tcache+c;decode时c=1。缓存保存每个历史位置的K/V,不保存一张不断扩大的attention分数矩阵。
同时Qnew [2,2,2] → 每个Q头的scores [2,5]
上面的“+”表示沿token轴追加,不是逐元素相加;真实分页缓存通常直接写预分配槽位,不必每次分配新tensor并cat整个历史。
nano的单个K池可理解为[num_blocks,block_size,Hkv,d],V另有同形状池;还要在外层区分layer与K/V。给定token位置t,先查逻辑块t//block_size,再取物理块号,最后找块内偏移。不同backend可能有不同物理布局,不能把这张nano图推广到所有vLLM或DeepInfer内核。
证据:缓存分配与store_kvcache / slot_mapping。
11 · 张量并行:拼接与求和发生在不同位置
本图只演示普通Linear的两路分片,不表示单张4070 Ti正在跑双卡,也不把Hkv=1的小例当成nano的TP=2合法配置。
- 按数学W的输出列切分:每卡计算一部分输出特征。若需要完整输出,沿特征轴cat;某些路径把分片继续传给下一层。
- 按数学W的输入行切分:每卡计算同形状的部分和。两份partial输出要all-reduce相加,不能cat。
- 转成PyTorch weight=[out,in]后,图中的列/行方向会反过来;先确认约定,再看代码里的dim。
nano对Q头与KV头都有整除检查;单卡本课的模型几何不涉及跨卡通信。
继续对照第05课;这张图给你判断cat还是sum的数值依据。
12 · 选最后有效位置,再投影到词表
仍用packed长度[3,2]。最终hidden是[N,H]=[5,4];最后有效行索引是cu_seqlens[1:]−1=[2,4]。先gather这两行,得到[B,H]=[2,4],再乘词表投影。
| packed行号 | 属于谁 | 用于下一token采样 |
|---|---|---|
| 0 / 1 / 2 | A的三个位置 | 选第2行hidden,预测A的下一token |
| 3 / 4 | B的两个位置 | 选第4行hidden,预测B的下一token |
[B,H] × Wlm [H,Vocab] → logits [B,Vocab]
按词表轴softmax/采样 → next_ids [B]
gather从5行里选2行;Linear把每行的4个特征投到Vocab个分数;采样最后才把每行分数变成一个token ID。三步改变维度的原因各不相同。
这是nano的prefill优化路径,见ParallelLMHead.forward。通用教学图[B,T,Vocab]表示所有位置的logits,实际生成不必物化每个位置的大词表输出。
闭卷检查:只留下形状,也能说明理由吗?
- q_flat[0,1,2]分头并转置后,坐标是什么?
- 历史长度7、新chunk长度2、每头d=4:每个head的scores和输出分别是什么形状?
- 为什么[2,3,4]不能靠reshape变成[5,4]?
先回答,再展开核对
①Hq=2,d=2时对应q_heads[0,1,1,0];因为2=1×2+0。②scores=[2,9]、输出=[2,4];d先被收缩,key位置轴再被收缩。③24个元素不能仅靠reshape变成20个,需先选择有效token去掉PAD。
本地打印每一步
cd ~/vllm-learn
python3 labs/dimension_lab.py不依赖PyTorch或NumPy,打印索引变换并验证正确/错误反例。这里验证的是维度和数值逻辑,不测GPU性能。