本文按「完全没接触过」的顺序写:先补最基础的概念,再讲 FlashDecoding 干什么、怎么做、怎么合并。 所有术语第一次出现都解释;过程中问过的每一个问题、弄错的每一处、当时没懂的点,全部收在第 9 / 10 / 11 章。 记号约定:不用字母
O(那是大写字母 O、取自 Output,不是数字 0),统一写「输出向量」;Q / K / V保留。 文中共 9 张图解,清单见文末附录。
第 0 章 · 先补最基础的:大模型是怎么生成一句话的
0.1 模型里没有"字",只有一串数字
一个字进模型时是一串数字(比如 128 个数),每过一层就被改写一次,但始终还是同样长度的一串数字。模型内部流动的从来不是文字,而是"这个字当前的理解状态"。直到最后一步,才把这串数字拿去和词表比分数、选出下一个字。
比方:读到「他把这个东西放下」,字典义是固定的,但结合前文你脑子里对它的理解变了 —— 这个"被上下文刷新后的理解",在模型里就是那串数字变了。
0.2 token、token id、输入向量、embedding 表
- token:模型处理文本的最小单位。中文常一字一 token,但不总是(可能是词片段)。
- token id:每个 token 在词表里的整数编号,比如 3842。
- embedding 表:一张
[词表大小 × 向量宽度]的大矩阵,一行对应一个 token,存着它的一串数字。简化模型50000 × 128;Qwen3-0.6B 是151936 × 1024。 - 输入向量:token id 当行号,直接取那一行得到的一串数字。这是纯查表,没有矩阵乘、没有算术运算。
为什么非得查表:整数编号本身没有语义(3842 和 3843 挨着不代表意思相近),换成 embedding 后那串数字带语义,意思相近的 token 向量也相近,模型算相似度才有意义。表里的数字是训练时学出来的(初始随机、随梯度更新),不是人工编的词典。
同一个东西三种表示,一一对应:
| 形式 | 例子 | 谁在用 |
|---|---|---|
| 字(文本) | 「猫」 | 人 |
| token id(整数) | 3842 | 分词器、采样输出 |
| 输入向量(128 个数) | [0.12, −0.45, …] | 模型内部 |
位置信息不在这张表里(RoPE 另加);KV cache 里存的也不是它。Qwen3 的 tie_word_embeddings=true,这张表还和末尾的 LM head 共用同一份权重。
0.3 prefill 与 decode
- Prefill:把你提的问题一次性全部读进去。一次大矩阵乘批量算出所有 token 的 Q/K/V,把 K/V 写进 KV cache,产出第一个生成的字。这一步 Q 有几百上千行,切得开,算力能打满(计算密集)。
- Decode:之后一个字一个字往外吐。每吐第 N 个字之前,都要把前面 N−1 个字重新看一遍才知道该接什么。所以每轮只有 1 个新的 q,但要对全部历史 K/V 算一次 attention。
闭环(下一轮的输入不是上一轮最后那个向量!):
采样得到 token id
├─ 查 embedding 表 → 输入向量(带位置编号)→ 新一轮 decode
└─ 解码成文本 → 输出给用户看
「查向量」和「显示文字」是并行的两条支路,不存在"先有字、再拿字去查表"。
0.4 一层里有什么、多少层
一个 Transformer 层里有两个子层,每个子层各有一条残差直通车:
x0 = 进这一层的原始向量
x1 = x0 + Attention( RMSNorm(x0) ) ← 残差 A
x2 = x1 + FFN( RMSNorm(x1) ) ← 残差 B
本层输出 = x2
- 不是"把同一个向量加两遍":
x1 ≠ x0,第二次加的是已被 attention 改过的x1。 - 归一化在子层之前(Pre-Norm),残差加的是未归一化的原始 x。
- 每层都有两次残差、两次归一化;4 层 = 8 次残差;层与层之间没有额外残差。
- 每层用自己的权重重新投影 q/k/v,把新 k/v 追加进自己那份 KV cache,并重读一遍全部历史。所以生成 1 个字 = 读 4 次历史(Qwen3 28 层 = 28 次)。
FFN:128 → 512 → 128(Qwen3 1024 → 3072 → 1024),撑宽、过 SiLU 非线性、压回;attention 负责"跨 token 搬信息",FFN 负责"逐位置加工内容、存知识"。输入输出宽度相同,所以能直接接残差。
最后过一次 RMSNorm 得到最终状态向量 → LM head([词表大小 × 向量宽度] 矩阵,每行是某个 token 的模板)→ 跟 5 万个候选各算一次内积 → 50000 个 logits → softmax(logits/T) → top-k/top-p → 采样 → 1 个 token id。
logits ≠ 概率 ≠ 字:logits 可正可负、加起来不为 1;softmax 之后才是概率;采样之后才是字。
第 1 章 · FlashDecoding 是干什么的
1.1 一句话
FlashDecoding 是一个让"模型写长文章时别越写越慢"的提速技巧。它不改模型、不改答案,只改芯片上怎么分配活儿。
它不是新模型、不是业务功能、不是训练方法;是推理框架能力清单里的一项算子级优化(和 PagedAttention、FlashAttention 并列)。所谓「特性适配」,就是把这项 GPU 上已有的能力搬到新硬件(昇腾 NPU)上跑通 —— 是搬家,不是发明新算法。
1.2 慢在哪
芯片里有几十上百个计算核心,本该一起分担。老办法(FlashAttention)按"要处理的字数"分活:
- Prefill:一次几百上千个 q,切得开,大家一起干。
- Decode:一次只写 1 个字,没法分。结果少数计算单元在忙、其余大量空转,而且这活儿只是"读历史 KV",纯访存瓶颈,人多本来能快很多。
大白话:原来 1 个人从第 1 页翻到第 1000 页,其余 3 个干等;改成 4 个人各翻 250 页,各交一份草稿,再合成 1 个答案。
1.3 核心思路:Q 切不动,就切 KV
- Q = 当前要问的那个问题(decode 时只有 1 个),没法拆。
- KV = 那本历史账本(越长越厚),可以拆。
于是把 K 和 V 一起按 token 位置切成 N 段(注意:是 K 和 V 一起,不能只切 K —— 每段还要用对应的 V 算加权和),分给 N 个计算单元并行,各自算一份局部结果,最后合并成 1 个答案。
因为合并时把各段统一到同一把尺子,合出来的结果和"1 个人读完全部历史"完全相同,不是近似、不是猜的。所以这是纯赚速度的买卖:长文章、同时只服务一两个人时收益最大;文章短就别切,因为切和合本身也要花时间。
1.4 边界:它只动一小段
FlashDecoding 只优化每层 attention 内部「读历史 K/V」那一步。后面的 输出投影 / FFN / LM head / 采样 一概不动。这也解释了为什么短文本不切:短序列时瓶颈在权重矩阵乘(矩阵 × 向量,也是访存瓶颈)上,切 KV 帮不上忙。
第 2 章 · 先把 attention 内部彻底讲清楚
2.1 Q / K / V 分别是什么
每层用自己的权重,把输入向量投影出三份东西:
- q:当前这个字拿去"提问"的向量;
- k:历史每个字留下的"打分用的钥匙";
- v:历史每个字留下的"被加权的内容"。
K 和 V 存在 KV cache(分页 block)里。decode 每轮把新字的 k/v 追加进去,所以 cache 越来越长。
2.2 打分:点积 + 除以 √d
q(128 个数)和每个 k(128 个数)逐位相乘再全部加起来 = 点积,得到一个数字(标量)。有 N 个历史 k 就做 N 次点积,得到 N 个数字。
sⱼ = (q · kⱼ) / √d
d = head_dim = 128,√d ≈ 11.3,做的是点积 / 11.3—— 是除以维度的平方根,不是把点积开根号,也不是除以 128。- 除
√d是为了防止分数太大、softmax 退化成"几乎只选一个"。 - 这一步叫「打分」,不属于 softmax。
2.3 softmax 的完整四步
① 每个分数减去最大值 [1,2] − 2 = [−1, 0]
② 各自取指数 exp [exp(−1), exp(0)] = [0.368, 1]
③ 把指数结果全部加起来 0.368 + 1 = 1.368 ← 分母
④ 每个数除以这个和 [0.269, 0.731] ← 才是 softmax 之后的"权重"
exp 是什么:exp(x) = e^x(e ≈ 2.718),对每一个分数单独算一次,几个输入就几个输出。exp(−2)≈0.135、exp(−1)≈0.368、exp(0)=1、exp(1)≈2.718、exp(10)≈22026。作用是把可正可负、差距可能很小的打分,变成全为正数、差距被拉开的票数。是指数 exp,不是对数 log(两者是反函数)。
为什么减最大值:exp(s−m) = exp(s)/exp(m),等于分子分母同除一个数,softmax 结果分毫不变;减完最大项变成 exp(0)=1,其余都 ≤1,exp(大正数) 溢出的问题就没了。这是纯数值稳定技巧。
归一化(第 ④ 步)不能漏:减最大值、取 exp 之后只是"未归一化权重",必须每个再除以总和,得到的才是和为 1 的权重。
2.4 加权求和 → 输出向量
输出向量 = Σⱼ 权重ⱼ × vⱼ
N 个历史 v 按权重加起来,结果只有 1 个向量(和原来一样长,比如 128 个数)。因为 attention 的本质是"汇总":999 份意见按权重平均成 1 份综合意见,不是把 999 份列出来。
这个输出向量是什么:「当前这个字,读完历史之后,被改写成的那一串数字」。它不是字、不是下一个 token、不是分数、不是概率、不是 logits,只是中间状态,后面还要被 FFN 和剩下各层继续改写很多次。
2.5 三段边界(重要)
① 打分 q·k 点积 → ÷ √d → 得到分数 s ← 这一步不是 softmax
② softmax 减最大值 → 取指数 exp → 求和 → 各自除以和
③ 加权求和 权重 × 对应的 v,全部相加 → 输出向量
定稿版(可直接引用):
q 和每个 k 点积、除以 √d,得到一串分数 s;softmax 做的是:s 里每个数减去最大值 → 各自取指数 → 把指数结果全部加起来(这个和就是本段权重和,也就是分母)→ 每个数除以这个和,得到权重;权重乘对应的 v、全部相加 → 输出向量。
2.6 一个小措辞:attention 输出的是「权重」,不是「概率」
attention 内部 softmax 输出的那串数准确叫权重(每个历史位置占多大比例)。它确实和为 1,但别和模型最后 LM head 之后「每个候选字被选中的概率」混了 —— 那是另一处 softmax,在链路末尾。
| ① attention 内部 | ② LM head 之后 | |
|---|---|---|
| 对什么 | 历史各位置的 q·k/√d 分数 | 词表各 token 的 logits |
| 输出含义 | 每个历史位置占多少权重 | 每个候选 token 的概率 |
| 拿去干嘛 | 加权求和 v → 1 个输出向量 | 采样 / argmax → 1 个 token id |
| 产生字吗 | 不会 | 会 |
FlashDecoding 动的是第 ① 次,跟第 ② 次毫无关系。
2.7 完整数字例子(严格自洽)
段 1 分数 [1, 2],v₁=[1,0],v₂=[0,1]
本段最大 = 2
exp: exp(1−2)=0.368, exp(2−2)=1 → 本段权重和 = 1.368
权重: [0.269, 0.731]
局部输出向量 = 0.269·[1,0] + 0.731·[0,1] = [0.269, 0.731]
段 2 分数 [0, 3],v₃=[1,1],v₄=[2,0]
本段最大 = 3
exp: exp(0−3)=0.050, exp(3−3)=1 → 本段权重和 = 1.050
权重: [0.0476, 0.9524]
局部输出向量 = 0.0476·[1,1] + 0.9524·[2,0] = [1.952, 0.0476]
第 3 章 · 分段计算:每段交三样东西
每个计算单元只看自己那段,完整跑一遍第 2 章那套打分 → softmax → 加权求和,然后交三样东西:
| 交什么 | 是什么 | 白话 |
|---|---|---|
| 局部输出向量 | 一个完整向量(和最终答案一样大,如 128 个数) | 「如果世界上只有我这段历史,答案应该是这个向量」——一份完整的答案草稿 |
| 本段最大分数 | 1 个数,softmax 之前这段里 q·k/√d 原始分数中的最大值 | 这段的"尺子原点":防 exp 溢出;合并时当锚点 |
| 本段权重和 | 1 个数,这段所有 exp(分数 − 本段最大分数) 加起来的和,即局部 softmax 归一化之前的分母 | 这份草稿"背后有多少票" |
⚠️ 最要紧的一句:每份局部输出向量都是和最终答案一样大的完整向量,不是"答案的四分之一"。四个核不是各写四分之一份答案,而是各写一份完整的、只是依据不全的答案草稿。会觉得该"拼接",根子就在把它想成"答案被切成四块"—— 其实切的是依据(K/V),不是答案。
本段最大分数不是 softmax 之后的数字(softmax 之后的权重都在 0~1、和为 1,不可能是 2、3 这种数)。本段权重和也不是"softmax 后权重相加"(那个恒等于 1、没信息量),它是归一化之前的分母。
因为局部输出向量已经被自己的分母除过、丢掉了票数和尺子信息,所以三样必须一起交。
补一句范围:本段最大分数取的是「本段内」的最大值,各段最大分数再取最大,才是合并用的全场最大分数。
第 4 章 · 什么时候合并
4.1 kernel 是什么
kernel = CPU 一次下发到 NPU/GPU 上跑的底层计算程序,可以理解成"派给芯片的一趟活儿":
- CPU 发一条启动指令,附带"输入在显存哪儿、输出写哪儿、开多少个计算单元";
- 芯片上几十上百个计算单元并行各干一小块;
- 全部干完、结果写回显存,才返回。
它不是 Python 函数,是已编译成这块芯片机器指令的程序。华为把常用 kernel 打包在 CANN / torch_npu 里,调用 torch.mm 或 npu_fused_infer_attention_score_v2 背后就是它在跑。
三个特性:
- kernel 结束边界 = 天然同步点(第一个没结束,第二个不会启动);
- 两个 kernel 之间不共享中间状态,只能靠显存传数据;
- 启动一次有固定开销(下发、参数、调度、返回),所以工程上倾向于减少 kernel 数量、把几步融成一个 kernel。
别把 kernel(程序)、head(模型结构里的观察角度)、**计算核心(硬件单元)**混成一回事。
4.2 合并时机
不是"算完顺手拼一下",而是明确分成两个 kernel:
- 第一个 kernel:N 段并行,各交一份局部输出向量 + 本段最大分数 + 本段权重和,写进显存临时缓冲。
- 同步点:必须等所有段都交齐 —— 因为要取全场最大分数当统一尺子,少一段就定不了尺子、算不准票重。
- 第二个 kernel(规约):做第 5 章那四步,产出 1 个形状不变的输出向量。这个 kernel 很轻,通常只要少量计算单元。
- 合并完才进入残差 → 归一化 → FFN → 下一层(下一层又重新分一次段、再合一次)。
每生成 1 个 token、每一层都要合一次:简化 4 层模型每轮 4 次分段 + 4 次合并;Qwen3 28 层每轮 28 次。
第 5 章 · 怎么合并(最详细)
5.1 一句话
合并不是拼接,是把几份"答案草稿"按票重加权平均成一份,产出 1 个形状不变的输出向量。
为什么不能是拼接:
- 拼起来维度变 N 倍,后面所有层的矩阵乘都接不上;
- attention 的本质是把历史压成一个向量(加权求和),天然是求和/平均,不是连接。
5.2 四步(严格自洽的数字例子)
段 1:草稿 [0.269, 0.731],本段最大 2,本段权重和 1.368
段 2:草稿 [1.952, 0.0476],本段最大 3,本段权重和 1.050
① 全场最大分数 = max(2, 3) = 3
② 打折系数:段1 exp(2−3) = 0.368;段2 exp(3−3) = 1
③ 票重 = 打折系数 × 本段权重和
段1 = 0.368 × 1.368 = 0.503
段2 = 1 × 1.050 = 1.050
④ 分子 = 0.503×[0.269,0.731] + 1.050×[1.952,0.0476]
= [0.135,0.368] + [2.050,0.050] = [2.185, 0.418]
分母 = 0.503 + 1.050 = 1.553
输出向量 = [2.185, 0.418] / 1.553 = [1.407, 0.269]
每一步在干什么:
- ① 找全场最大分数:全部历史里所有
q·k/√d分数的最大值,等价于max(各段本段最大分数)。这是统一尺子的基准。 - ② 打折:谁的最高分没到全场最高,谁那份草稿按比例缩小。因为各段做局部 softmax 时只统计了自己那段,等于"在自己班里排名",两把尺子不一样;直接平均等于让低分班和高分班平起平坐,结果就错了。
- ③ 算票重:
票重 = 打折系数 × 本段权重和,含义是"如果按全局分母算,这一段该占多大分量"。段 1 分数普遍低,只摊到 0.503;段 2 有个高分 token,摊到 1.050。 - ④ 加权平均:分子是
Σ 票重ᵢ × 局部输出向量ᵢ(票重只是系数,不是分子本身);分母 = 所有票重加起来 =1.553。
5.3 分母到底是什么(这轮专门问过)
分母 = 所有票重之和,而它正好等于不切分时全局 softmax 的分母。
验证:不切分时按全场最大 3 做全局 softmax,四个 token 的全局权重是:
段1 各乘 exp(2−3)=0.368 → 0.368×0.368=0.135, 0.368×1=0.368
段2 各乘 1 → 0.050, 1
分母 = 0.135 + 0.368 + 0.050 + 1 = 1.553 ← 与票重之和完全相同
分子 = 0.135·v₁ + 0.368·v₂ + 0.050·v₃ + 1·v₄ = [2.185, 0.418]
输出向量 = [1.407, 0.269] ← 逐位相同
代数上必然如此:票重 × 局部输出向量 展开正好等于那一段里「各 token 全局权重 × v」的和;所有段加起来就是全局分子,所有票重加起来就是全局分母。所以不是近似,是逐位等价。
5.4 两种 exp 千万别混
| softmax 内部 | 合并时的打折系数 | |
|---|---|---|
| 算式 | exp(每个分数 − 本段最大分数) | exp(本段最大分数 − 全场最大分数) |
| 相减的是什么 | 分数 − 最大值 | 两个"最大值"相减 |
| 数量 | 每个 token 各 1 个 | 每段只有 1 个标量 |
| 用途 | 算本段权重,加总得本段权重和 | 统一尺子,乘本段权重和得票重 |
5.5 早期用过的简化版例子(结构相同,数字取整便于手算)
段 1:草稿 [0.25, 0.75],本段最大 2,本段权重和 4
段 2:草稿 [1, 0], 本段最大 5,本段权重和 2
① 全场最大 = 5
② 打折:段1 exp(2−5) ≈ 0.05;段2 exp(5−5) = 1
③ 票重:段1 0.05×4 = 0.2;段2 1×2 = 2
④ 分子 = 0.2×[0.25,0.75] + 2×[1,0] = [2.05, 0.15]
分母 = 0.2 + 2 = 2.2
输出向量 = [0.932, 0.068]
这版数字是早期为了讲清流程取整简化过的(本段权重和 4 / 2 并非按 softmax 真实算出的值);精确计算请以 5.2 的严格版为准。两版结构完全一致。
5.6 完整公式
全场最大分数 = max(各段本段最大分数)
打折系数ᵢ = exp(本段最大分数ᵢ − 全场最大分数)
票重ᵢ = 打折系数ᵢ × 本段权重和ᵢ
输出向量 = Σ(票重ᵢ × 局部输出向量ᵢ) / Σ(票重ᵢ)
5.7 为什么短文本不切
切段、同步、第二个规约 kernel 都是固定开销。长文本时切段省下的时间远大于它;短文本本来一眼读完,多一次切分 + 同步 + 合并反而更慢。所以实际实现按序列长度 / 并发动态决定切几段,短序列直接走老路 —— 这也是验收里"短序列不回退"那条标准的由来。
第 6 章 · 放到实际任务里:这个「特性开发」开发什么
RTP-LLM 是阿里开源的推理框架(把模型部署成服务、对外提供接口的那套软件)。「特性」就是它能力清单里的一项(分页 KV、量化、FlashAttention、FlashDecoding…),不是给客户用的功能按钮。这项能力在 GPU 上早有了;换到昇腾 NPU 后底层工具不同,代码搬过去跑不了 ——「适配」就是让同一能力在新硬件上能用。
6.1 四件小事
- 找现成工具:先看看华为有没有把"在 NPU 上算 attention"这个最难的活儿做好。有就直接用,别自己重做。
- 对齐格式:把框架的 paged KV cache、block table、dtype 翻译成算子认识的输入格式(最容易踩坑)。
- 加开关:默认关闭,新实现插到候选名单最前面、老实现原样兜底,一关就回退。
- 测试验收:答案对不对(cos ≥ 0.999)、长文本快多少(≥32K 的 TPOT)、短文本别变慢(<5%)、长稳、可复现命令。
6.2 为什么第一步是"找现成工具"
"翻账本"落到芯片上是一串极底层的事:从显存哪一格搬数据、在哪个计算单元做乘法、中间结果存哪、怎么不溢出。这些不是 Python 能指挥的,得写针对这块芯片编译的底层算子,从零写要吃透芯片架构、几个月起步、数值稍错就生成乱码、还可能比原来更慢。
而芯片厂商会把常用算子写好、调好、测好打包在 CANN / torch_npu 里 —— 我昨天在测试卡上跑通的 1024² matmul,就是 PyTorch 自动调了华为现成的矩阵乘 kernel。所以"找现成工具"解决三件事:
- 跳过最难最花时间的底层算子开发,只做"接线";
- 正确性和性能有厂商背书(按 CANN 9.1.0 / 950PR 调过,版本配套);
- 最值钱:如果现成算子内部本来就会在长序列时自动切 KV,那你连"切账本"都不用写,只要传对参数、打开开关。
为什么必须放第一步:它是分岔口 —— 自带切分 → PR 几十行;不自带 → 得自己补两阶段实现;连算子都没有 → 才从零写。不先查就动手,等于不知道盖平房还是盖楼就先买了钢筋。
6.3 分支里的真实情况(已查证)
feature/ascend 分支已经有昇腾 decode 实现:ascend_decode.py 里在调 torch_npu.npu_fused_infer_attention_score_v2(FIA v2,华为现成融合 attention 算子)。也就是说第 ① 件已经做了一大半。
- 接入链路:
attention/__init__.py注册AscendDecodeImpl到候选名单 →attn_factory.py按顺序先过开关_is_fmha_impl_disabled()再过support(),第一个通过者胜出(名单越前优先级越高)。 - ⚠️ 代码里
_split_kv()是把缓存张量拆成 K 和 V 两个视图(内存布局操作),不是 FlashDecoding 的按序列切段,名字像、含义完全不同。 - 下一步分岔点:确认 CANN 9.1.0 / 950PR 的 FIA v2 是否自带 split-KV。自带 → 参数 + 开关 + 验证即可;不自带 → 才补两阶段实现。
第 7 章 · 从输入到输出的完整流程(单头,纯流程)
设定:单头模型,向量 128 个数,共 4 层,词表 5 万,KV cache 按 block 分页存。
A. Prefill
- 问题文本 → 分词 → 一串 token id。
- 每个 id 查 embedding 表 → 128 个数的输入向量 + 位置编号。
- 一次大矩阵乘批量算出所有 token 的 Q / K / V。
- 每个 token 的 K、V 写进分页 block(每层各存一份)。
- 做 attention(带因果掩码):每个 token 的 q 跟自己及之前的 k 打分
q·k/√d→ softmax → 权重乘对应 v 相加 → 每个 token 得到一个输出向量。 - 残差 A → 归一化 → FFN(128→512→128)→ 残差 B → 本层结束;重复到第 4 层。
- 取最后一个 token 的最终状态向量 → 词表矩阵 → 5 万个 logits → softmax → 采样 → 第一个生成的 token id。
B. Decode 第 N 轮
B1 准备输入:上一轮的 token id → 查 embedding 表 → 输入向量 + 位置编号 → 送进第 1 层。
B2 第 1 层:
- 用本层权重投影出 q / k / v。
- 新 k、v 追加写进本层 KV cache(历史 +1)。
- q 跟本层 cache 里全部历史 k 逐个打分
q·k/√d。 - 所有分数一起 softmax → 权重。
- 权重乘对应 v 全部相加 → 本层输出向量(128 个数,形状不变)。
- 可选分支 FlashDecoding:把历史 K/V 按位置切 N 段 → N 个计算单元并行做 3~5 步 → 各交局部输出向量 + 本段最大分数 + 本段权重和 → 规约 kernel 按第 5 章四步合并成同一个输出向量。
- 残差 A:
x1 = 输入向量 + 输出向量。 RMSNorm(x1)→ FFN(128→512→SiLU→128)→ 残差 B:x2 = x1 + FFN 结果。- 本层输出 =
x2。
B3 第 2/3/4 层:完全重复 B2,各层权重不同、各自 KV cache 不同,全程宽度始终 128 个数。
B4 出字:
- 最后一次 RMSNorm → 最终状态向量。
- 乘 LM head
[50000 × 128]→ 50000 个 logits。 logits / temperature→ softmax → 50000 个概率。- top-k / top-p 后采样或取最高分 → 1 个 token id。
- 解码成文本输出。
B5 闭环与终止:同一个 id 查 embedding 表 → 新输入向量 → 回到 B1;直到采出结束符或达到最大长度。
C. 一轮的计数
| 项目 | 一轮的数量 |
|---|---|
| 层数 | 4 |
| attention(读历史 K/V) | 4 次,每层 1 次,历史每轮 +1 |
| FFN | 4 次 |
| 残差相加 | 8 次(每层 2 次) |
| 归一化 | 8 次(每层 2 次,子层前)+ 1 次最终 |
| LM head → logits | 1 次,产出 50000 个分数 |
| 最终产出 | 1 个 token id = 1 个字 |
真实 Qwen3-0.6B:1024 宽、head_dim=128、16 个 Q 头 / 8 个 KV 头(GQA)、28 层、词表 151936;每层 attention 出口多一步「16 份输出向量拼接成 2048 → 输出投影矩阵压回 1024」,其余流程逐字不变。
切依据 → 结果加权平均;切视角 → 结果拼接。FlashDecoding 分段合并是前者;16 个 head 的结果合并是后者(真 concat,因为视角互补、平均会互相抵消)。
第 8 章 · 名词表
| 名词 | 一句话 | 形状 / 动作 | 在流程哪一步 |
|---|---|---|---|
| embedding 表 | 「token → 一串数字」的查找表 | [词表 × 宽度],按行号取一行,纯查表 | A2、B1、B5 |
| 分页 block | KV cache 按固定 token 数切块存,配页码表 | 每块存 page_size 个 token 的 K/V;每层一份 | A4、B2-2、B2-3 |
| 做 attention | 用 q 从历史 K/V 取信息,压成 1 个向量 | q + 全部历史 k/v → 128 维输出向量 | A5、B2 的 3~5 |
| FFN | 撑宽 → 非线性 → 压回,逐位置加工 | 128→512→128,形状不变 | A6、B2-7 |
| softmax | 把分数变成和为 1 的权重 | 减最大 → exp → 求和 → 各自除以和 | attention 内、LM head 后 |
| LM head | 最终状态向量 → 词表分数 | [词表 × 宽度],每行是一个 token 的模板 | A7、B4-2 |
| head(注意力头) | 模型里的"观察角度",一组独立 Q/K/V 权重 + 一次独立 attention | 每个 head 出一份 128 维输出向量 | 每层 attention |
| kernel | 一次下发到芯片的底层程序 | 启动 → 多核并行 → 全干完写回显存 → 返回 | 每个算子 |
| NPU 计算核心 | 芯片上真正干活的硬件单元 | 一个 kernel 调动几十上百个 | 硬件层 |
| KV cache | 每层存的历史 k/v | 随上下文线性增长,decode 每轮读一遍 | 每层 |
| 残差 | 新 = 旧 + 改动 的直通车 | 两个子层各一条,基准不同 | 每层两次 |
| logits | 词表上未归一化的分数 | 可正可负、和不为 1,不是概率 | LM head 之后 |
三者别混:输入向量(id 查 embedding 得来,进第 1 层)/最终状态向量(4 层走完,只送 LM head 打分、用完即止)/k、v(每层投影出来,追加进 KV cache)。
第 9 章 · 我问过的所有问题(完整清单)
关于任务本身
- RTP-LLM 的 FlashDecoding 特性开发是干什么的?→ 第 1.1、第 6 章
- 用大白话分块讲一遍 → 全文
- 「四件小事」详细展开 → 第 6.1
- 「找现成工具」是什么意思、为什么找、解决什么 → 第 6.2
- 重新整体解释一遍 → 第 1 章
- FlashDecoding 是"要写新算法"吗 → 不是,是适配/搬家(第 1.1)
- 分支里不是已经有算子了吗 → 有,FIA v2(第 6.3)
关于 attention 与输出 8. 「拼接的是什么」→ 不是拼接,是加权合并(第 5.1)
- 局部输出向量 / 本段最大分数 / 本段权重和分别是什么 → 第 3 章
- 多核算完输出的到底是什么 → 1 个完整向量 + 2 个标量(第 3 章)
- "q 和 v 相乘打分"对吗 → 错,q 和 k 打分(第 2.2)
- 一个向量怎么变成词表 logits → 第 0.4、第 8 章
- 「attention 输出的 O 是什么」→ 当前这个字被历史改写后的向量(第 2.4)
- O 是数字 0 还是字母 O → 大写字母 O(Output),已改用「输出向量」
- 单头 decode 从第一层输出向量到出 token 的全过程 → 第 7 章 B
- head 是什么意思 → 第 8 章;head 不是 NPU 计算核心
- 两次残差是不是重复 → 不是,两个子层各一条、基准不同(第 0.4)
- 2~4 层 FFN 后还要残差吗 → 要,每层两次(第 0.4)
- 下一轮 decode 的输入是什么 → 新 id 查 embedding,不是最后一层向量(第 0.3)
- embedding 表是什么 → 第 0.2、第 8 章
- 分页 block 是什么 → 第 8 章
- FFN 是什么 → 第 0.4、第 8 章
- softmax 是什么 → 第 2.3、第 8 章
- LM head 是什么 → 第 0.4、第 8 章
关于合并 25. 什么时候合并 → 第 4 章
- 怎么合并 → 第 5 章
- kernel 是什么意思 → 第 4.1
- 本段最大分数是 softmax 后的数字吗 → 不是,是 softmax 之前的原始分数最大值(第 3 章)
- 本段权重和是什么 → 局部 softmax 的分母(第 3 章)
- q 和 k 是点积吗 → 是(第 2.2)
- 本段最大分数是"全部历史"点积的最大值吗 → 是本段内的(第 3 章)
- "各自取 exp"是什么 → 第 2.3
- exp 和对数的区别 → 指数 vs 反函数(第 2.3)
- 归一化是什么 → 除以指数结果之和(第 2.3)
- 「÷√d」属于 softmax 吗 → 不属于,是打分阶段(第 2.5)
- softmax 输出该叫"概率"还是"权重" → 权重(第 2.6)
- 第四步的分子 / 分母是什么 → 第 5.2、5.3
- 分母为什么恰好等于全局 softmax 分母 → 第 5.3
- 打折系数与 softmax 内部那个 exp 是一回事吗 → 不是(第 5.4)
第 10 章 · 我弄错过的地方(完整清单)
| # | 当时说法 | 正确说法 |
|---|---|---|
| 1 | prefill「一次性把 token 生成 qkv」就完了 | prefill 也做 attention(带因果掩码);QKV 是一次大矩阵乘批量算的,不是逐 token 分开算("可并行"直觉对) |
| 2 | decode「q 和 k、v 计算得到 logics,softmax 选出 token」 | 两层 softmax 混成一层:attention 内 softmax 只产出输出向量、不产 token;logits 是走完剩余层过 LM head 才有的 |
| 3 | 拼写 logics | logits |
| 4 | 「npu 里装着权重,一个单元在用 q 和 k 计算」 | attention 这步不碰权重,用的是 KV cache 的 K/V;权重用在 QKV 投影和 FFN |
| 5 | 「其他单元并没有工作」 | 稍绝对:实际有 batch × head 个块并行,只是远少于核心数,其余大量空闲 |
| 6 | 「根据这个 softmax 选出 token」 | 选 token 靠最后词表 logits 的 softmax / 采样 |
| 7 | 「把 k 分成几个部分」 | K 和 V 一起按位置切,每段要用对应 V 算加权和 |
| 8 | 「几个 cpu 的单元」 | 笔误,是 NPU 计算单元 |
| 9 | 「最后把这几块的结果进行拼接」 | 核心错误:不是 concat,是加权合并(reduce),产出 1 个形状不变的输出向量 |
| 10 | 「每个 token 的 q 和前面 token 的 v 相乘打分」 | q 和 k 打分;V 只在最后被加权 |
| 11 | 「softmax 乘以对应 V 把前面向量相加得到新向量」 | ✅ 完全正确,这就是 attention 的本质 |
| 12 | 把 O 当成数字 0 | 是大写字母 O(Output);约定改叫「输出向量」 |
| 13 | 「第一层输出了第一个加权求和后的向量」 | 那不是"第一个 token"也不是输出,只是中间状态;prefill 早就产出了第一个字 |
| 14 | 「再加一次残差不是已经加过了么」 | 两个子层各一条,第二次加的是 x1 不是 x0,不是重复 |
| 15 | 「把最后一层产出的向量作为下一轮输入」 | 下一轮输入是新 token id 查 embedding 得到的输入向量;最后那个向量已被 LM head 消费、用完即止 |
| 16 | 「根据这个字查对应的向量」 | 经手的是 token id,不是文本;查向量与显示文字是两条并行支路 |
| 17 | 「词表里有多少个字就输出多少个字的分数」 | 是 token,不一定等于完整汉字 |
| 18 | 把 FlashDecoding 当成"要开发新算法" | 是适配/搬家,不是造算子 |
| 19 | 把「找现成工具」理解成从零接 | 现成算子(FIA v2)本来就摆在那儿,先盘弹药再决定写多少代码 |
| 20 | 「q 跟 k 计算 → 向量加权求和 → 再进行 softmax」 | 顺序反了:q·k 打分 → softmax → 权重乘 v 求和 |
| 21 | 「q 和 k 算出来的是一堆向量」 | 是 N 个标量分数;加权求和才是对 v 做的 |
| 22 | 「本段最大分数是 softmax 之后的数字」 | 是 softmax 之前原始分数里的最大值 |
| 23 | 「本段最大分数是 q 和前面所有 k 点积的最大值」 | 是本段内的;各段最大再取最大才是全场最大 |
| 24 | 「本段权重和 = softmax 后数字全加起来再减一个最大值」 | 是 exp(分数−最大值) 这些未归一化值相加;减最大值发生在每个 exp 内部,不是从总和里扣 |
| 25 | 「每一个数字去做对数运算」 | 是指数 exp,不是 log(两者是反函数) |
| 26 | 「点积结果先开根号再除以维度得到 s」 | 是 除以 √d(≈11.3),不是把点积开根号、也不是除以 128 |
| 27 | 把 ÷√d 算进 softmax 流程 | 它属于打分阶段,在 softmax 之前、独立一步 |
| 28 | softmax 流程漏了最后一步 | 还差归一化:每个数除以指数结果的总和 |
| 29 | softmax 输出叫「概率」 | attention 内部准确叫权重(第 2.6) |
| 30 | 「打折系数 = 每个分数减最大值再 exp」 | 打折系数是 exp(本段最大 − 全场最大),每段一个标量(第 5.4) |
| 31 | 「第四步分子是上一步的结果」 | 上一步只给票重;分子是 Σ 票重 × 局部输出向量 |
| 32 | 不知道第四步分母是什么 | 所有票重之和,且正好等于全局 softmax 分母(第 5.3) |
| 33 | 把局部输出向量当成"答案的四分之一" | 每份都是完整草稿;切的是依据不是答案 |
| 34 | 把 head 当成 NPU 计算核心 | head 是模型结构里的观察角度;核心是硬件 |
| 35 | 把 _split_kv() 当成按序列切段 | 那是把缓存张量拆成 K/V 两个视图的内存布局操作 |
另外三个值得记住的区分:
- head ≠ NPU 计算核心 ≠ kernel:模型结构 / 硬件单元 / 底层程序。
- 切依据 → 加权平均;切视角 → 真拼接。
- 输入向量 / 最终状态向量 / k、v 三者别混。
第 11 章 · 我当时「没看明白」、后来弄懂的点
| 当时卡住的 | 一句话答案 |
|---|---|
| 「第 5 块四件小事」 | 它们的本质就是:找工具 → 对齐格式 → 加开关 → 验收,四件都是接线活(第 6 章) |
| 「为什么找现成工具」 | 底层算子极难写、极易错、还得按芯片型号编译;厂商已写好打包好,先盘弹药(第 6.2) |
| 「attention 输出的 O 是什么」 | 当前这个字被历史改写后的一串数字,不是字也不是分数(第 2.4) |
| 「O 是 0 还是字母 O」 | 大写字母 O(Output),已统一改叫「输出向量」 |
| 「head 是什么」 | 模型里的一个观察角度,一组独立 Q/K/V 权重 + 一次独立 attention |
| 「embedding 表是什么」 | 一行对应一个 token 的大矩阵,按行号取一行(第 0.2) |
| 「为什么加两次残差」 | 两个子层各一条直通车,第二次基准是 x1 不是 x0(第 0.4) |
| 「什么时候合并」 | 第一个 kernel 全部结束、经同步点,第二个规约 kernel 里做(第 4.2) |
| 「怎么合并」 | 四步:全场最大 → 打折 → 票重 → 加权平均(第 5.2) |
| 「kernel 是什么」 | CPU 一次下发到芯片的底层计算程序(第 4.1) |
| 「本段最大分数 / 本段权重和」 | 前者是 softmax 前原始分数的最大值,后者是局部 softmax 归一化前的分母(第 3 章) |
| 「各自取 exp 是什么」 | 对每一个分数单独做一次指数运算(第 2.3) |
| 「分母是什么」 | 所有票重之和,等于全局 softmax 分母(第 5.3) |
| 「拼接的是什么」 | 没有东西被拼接;合并的是几份完整草稿(第 5.1) |
第 12 章 · 一段话总结
模型每写一个字都要把前面所有字重看一遍,越写越慢,而老办法在逐字生成时只能让少数计算核心干活、其余空转;FlashDecoding 改成把 K 和 V 一起按位置切成几段,交给多个计算单元并行,各段用「点积 ÷√d → 减本段最大分数 → 取 exp → 求和 → 归一化 → 权重乘 v 相加」算出一份完整的局部输出向量,并附上本段最大分数与本段权重和;第一个 kernel 全部交齐后,第二个规约 kernel 取全场最大分数统一尺子、用 exp(本段最大−全场最大) × 本段权重和 算出票重,把各段草稿按票重加权平均成 1 个形状不变的输出向量,其分母正是所有票重之和、也正好等于不切分时全局 softmax 的分母,因此结果与单核读完全部历史逐位等价 —— 长文本因此变快,答案一个字都不改;而整件事只发生在 attention 内部,跟 FFN、LM head、softmax 采样选字、以及最后输出 token 的链路毫无关系,任务层面要做的是把这项 GPU 上已有的能力适配到昇腾 NPU(接现成算子、对齐 KV 布局、加默认关闭的开关、做正确性与性能验收),不是发明新算法。
附:配图清单(9 张,均为同目录独立 SVG)
| 文件 | 内容 | 对应章节 |
|---|---|---|
| 配图-为什么需要FlashDecoding | 1 个人翻完整本账 vs 4 个人各翻 250 页 | 第 1.2 |
| 配图-splitKV两阶段 | KV 切 4 段 → 4 个计算单元并行 → 规约 → 1 个输出向量 | 第 1.3 |
| 配图-拼接与合并的区别 | 拼接会让维度变 4 倍,加权合并形状不变 | 第 5.1 |
| 配图-合并时机两个kernel | 第一个 kernel、同步点、第二个 kernel、后续层 | 第 4.2 |
| 配图-softmax三段边界 | 打分 / softmax / 加权求和,含每步数字 | 第 2.5 |
| 配图-两个量的位置 | 本段最大分数、本段权重和在计算链中的位置 | 第 3 章 |
| 配图-exp一对一运算 | exp 对每个分数单独运算的数值对照 | 第 2.3 |
| 配图-合并四步 | 合并四步的简化数值流程(早期取整示意) | 第 5.5 |
| 配图-两种exp与分母 | 两种 exp 的区别、第四步分母的含义 | 第 5.4 |
📎 本文的所有结论,也来自另一篇按对话时间线复盘的记录:《从输入到输出:大模型推理全流程复盘(含纠错清单)》,两篇可配合阅读。