FlashDecoding 零基础学习总结:从 attention 三段式到 split-KV 合并

2 阅读33分钟

本文按「完全没接触过」的顺序写:先补最基础的概念,再讲 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",纯访存瓶颈,人多本来能快很多。

为什么需要 FlashDecoding

大白话:原来 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 个答案。

split-KV 两阶段

因为合并时把各段统一到同一把尺子,合出来的结果和"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 对每个分数单独运算

为什么减最大值: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,全部相加 → 输出向量

打分、softmax、加权求和三段边界

定稿版(可直接引用):

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、没信息量),它是归一化之前的分母。

因为局部输出向量已经被自己的分母除过、丢掉了票数和尺子信息,所以三样必须一起交。

两个量在 softmax 里的位置

补一句范围:本段最大分数取的是「本段内」的最大值,各段最大分数再取最大,才是合并用的全场最大分数。


第 4 章 · 什么时候合并

4.1 kernel 是什么

kernel = CPU 一次下发到 NPU/GPU 上跑的底层计算程序,可以理解成"派给芯片的一趟活儿":

  • CPU 发一条启动指令,附带"输入在显存哪儿、输出写哪儿、开多少个计算单元";
  • 芯片上几十上百个计算单元并行各干一小块;
  • 全部干完、结果写回显存,才返回。

它不是 Python 函数,是已编译成这块芯片机器指令的程序。华为把常用 kernel 打包在 CANN / torch_npu 里,调用 torch.mm 或 npu_fused_infer_attention_score_v2 背后就是它在跑。

三个特性:

  1. kernel 结束边界 = 天然同步点(第一个没结束,第二个不会启动);
  2. 两个 kernel 之间不共享中间状态,只能靠显存传数据;
  3. 启动一次有固定开销(下发、参数、调度、返回),所以工程上倾向于减少 kernel 数量、把几步融成一个 kernel。

别把 kernel(程序)、head(模型结构里的观察角度)、**计算核心(硬件单元)**混成一回事。

4.2 合并时机

不是"算完顺手拼一下",而是明确分成两个 kernel:

  1. 第一个 kernel:N 段并行,各交一份局部输出向量 + 本段最大分数 + 本段权重和,写进显存临时缓冲。
  2. 同步点:必须等所有段都交齐 —— 因为要取全场最大分数当统一尺子,少一段就定不了尺子、算不准票重。
  3. 第二个 kernel(规约):做第 5 章那四步,产出 1 个形状不变的输出向量。这个 kernel 很轻,通常只要少量计算单元。
  4. 合并完才进入残差 → 归一化 → FFN → 下一层(下一层又重新分一次段、再合一次)。

合并时机:两个 kernel

每生成 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 个标量
用途算本段权重,加总得本段权重和统一尺子,乘本段权重和得票重

两种 exp 与分母

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 四件小事

  1. 找现成工具:先看看华为有没有把"在 NPU 上算 attention"这个最难的活儿做好。有就直接用,别自己重做。
  2. 对齐格式:把框架的 paged KV cache、block table、dtype 翻译成算子认识的输入格式(最容易踩坑)。
  3. 加开关:默认关闭,新实现插到候选名单最前面、老实现原样兜底,一关就回退。
  4. 测试验收:答案对不对(cos ≥ 0.999)、长文本快多少(≥32K 的 TPOT)、短文本别变慢(<5%)、长稳、可复现命令。

6.2 为什么第一步是"找现成工具"

"翻账本"落到芯片上是一串极底层的事:从显存哪一格搬数据、在哪个计算单元做乘法、中间结果存哪、怎么不溢出。这些不是 Python 能指挥的,得写针对这块芯片编译的底层算子,从零写要吃透芯片架构、几个月起步、数值稍错就生成乱码、还可能比原来更慢。

而芯片厂商会把常用算子写好、调好、测好打包在 CANN / torch_npu 里 —— 我昨天在测试卡上跑通的 1024² matmul,就是 PyTorch 自动调了华为现成的矩阵乘 kernel。所以"找现成工具"解决三件事:

  1. 跳过最难最花时间的底层算子开发,只做"接线";
  2. 正确性和性能有厂商背书(按 CANN 9.1.0 / 950PR 调过,版本配套);
  3. 最值钱:如果现成算子内部本来就会在长序列时自动切 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

  1. 问题文本 → 分词 → 一串 token id。
  2. 每个 id 查 embedding 表 → 128 个数的输入向量 + 位置编号。
  3. 一次大矩阵乘批量算出所有 token 的 Q / K / V。
  4. 每个 token 的 K、V 写进分页 block(每层各存一份)。
  5. 做 attention(带因果掩码):每个 token 的 q 跟自己及之前的 k 打分 q·k/√d → softmax → 权重乘对应 v 相加 → 每个 token 得到一个输出向量。
  6. 残差 A → 归一化 → FFN(128→512→128)→ 残差 B → 本层结束;重复到第 4 层。
  7. 取最后一个 token 的最终状态向量 → 词表矩阵 → 5 万个 logits → softmax → 采样 → 第一个生成的 token id。

B. Decode 第 N 轮

B1 准备输入:上一轮的 token id → 查 embedding 表 → 输入向量 + 位置编号 → 送进第 1 层。

B2 第 1 层:

  1. 用本层权重投影出 q / k / v。
  2. 新 k、v 追加写进本层 KV cache(历史 +1)。
  3. q 跟本层 cache 里全部历史 k 逐个打分 q·k/√d。
  4. 所有分数一起 softmax → 权重。
  5. 权重乘对应 v 全部相加 → 本层输出向量(128 个数,形状不变)。
    • 可选分支 FlashDecoding:把历史 K/V 按位置切 N 段 → N 个计算单元并行做 3~5 步 → 各交局部输出向量 + 本段最大分数 + 本段权重和 → 规约 kernel 按第 5 章四步合并成同一个输出向量。
  6. 残差 A:x1 = 输入向量 + 输出向量。
  7. RMSNorm(x1) → FFN(128→512→SiLU→128)→ 残差 B:x2 = x1 + FFN 结果。
  8. 本层输出 = x2。

B3 第 2/3/4 层:完全重复 B2,各层权重不同、各自 KV cache 不同,全程宽度始终 128 个数。

B4 出字:

  1. 最后一次 RMSNorm → 最终状态向量。
  2. 乘 LM head [50000 × 128] → 50000 个 logits。
  3. logits / temperature → softmax → 50000 个概率。
  4. top-k / top-p 后采样或取最高分 → 1 个 token id。
  5. 解码成文本输出。

B5 闭环与终止:同一个 id 查 embedding 表 → 新输入向量 → 回到 B1;直到采出结束符或达到最大长度。

C. 一轮的计数

项目一轮的数量
层数4
attention(读历史 K/V)4 次,每层 1 次,历史每轮 +1
FFN4 次
残差相加8 次(每层 2 次)
归一化8 次(每层 2 次,子层前)+ 1 次最终
LM head → logits1 次,产出 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
分页 blockKV 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 章 · 我问过的所有问题(完整清单)

关于任务本身

  1. RTP-LLM 的 FlashDecoding 特性开发是干什么的?→ 第 1.1、第 6 章
  2. 用大白话分块讲一遍 → 全文
  3. 「四件小事」详细展开 → 第 6.1
  4. 「找现成工具」是什么意思、为什么找、解决什么 → 第 6.2
  5. 重新整体解释一遍 → 第 1 章
  6. FlashDecoding 是"要写新算法"吗 → 不是,是适配/搬家(第 1.1)
  7. 分支里不是已经有算子了吗 → 有,FIA v2(第 6.3)

关于 attention 与输出 8. 「拼接的是什么」→ 不是拼接,是加权合并(第 5.1)

  1. 局部输出向量 / 本段最大分数 / 本段权重和分别是什么 → 第 3 章
  2. 多核算完输出的到底是什么 → 1 个完整向量 + 2 个标量(第 3 章)
  3. "q 和 v 相乘打分"对吗 → 错,q 和 k 打分(第 2.2)
  4. 一个向量怎么变成词表 logits → 第 0.4、第 8 章
  5. 「attention 输出的 O 是什么」→ 当前这个字被历史改写后的向量(第 2.4)
  6. O 是数字 0 还是字母 O → 大写字母 O(Output),已改用「输出向量」
  7. 单头 decode 从第一层输出向量到出 token 的全过程 → 第 7 章 B
  8. head 是什么意思 → 第 8 章;head 不是 NPU 计算核心
  9. 两次残差是不是重复 → 不是,两个子层各一条、基准不同(第 0.4)
  10. 2~4 层 FFN 后还要残差吗 → 要,每层两次(第 0.4)
  11. 下一轮 decode 的输入是什么 → 新 id 查 embedding,不是最后一层向量(第 0.3)
  12. embedding 表是什么 → 第 0.2、第 8 章
  13. 分页 block 是什么 → 第 8 章
  14. FFN 是什么 → 第 0.4、第 8 章
  15. softmax 是什么 → 第 2.3、第 8 章
  16. LM head 是什么 → 第 0.4、第 8 章

关于合并 25. 什么时候合并 → 第 4 章

  1. 怎么合并 → 第 5 章
  2. kernel 是什么意思 → 第 4.1
  3. 本段最大分数是 softmax 后的数字吗 → 不是,是 softmax 之前的原始分数最大值(第 3 章)
  4. 本段权重和是什么 → 局部 softmax 的分母(第 3 章)
  5. q 和 k 是点积吗 → 是(第 2.2)
  6. 本段最大分数是"全部历史"点积的最大值吗 → 是本段内的(第 3 章)
  7. "各自取 exp"是什么 → 第 2.3
  8. exp 和对数的区别 → 指数 vs 反函数(第 2.3)
  9. 归一化是什么 → 除以指数结果之和(第 2.3)
  10. 「÷√d」属于 softmax 吗 → 不属于,是打分阶段(第 2.5)
  11. softmax 输出该叫"概率"还是"权重" → 权重(第 2.6)
  12. 第四步的分子 / 分母是什么 → 第 5.2、5.3
  13. 分母为什么恰好等于全局 softmax 分母 → 第 5.3
  14. 打折系数与 softmax 内部那个 exp 是一回事吗 → 不是(第 5.4)

第 10 章 · 我弄错过的地方(完整清单)

#当时说法正确说法
1prefill「一次性把 token 生成 qkv」就完了prefill 也做 attention(带因果掩码);QKV 是一次大矩阵乘批量算的,不是逐 token 分开算("可并行"直觉对)
2decode「q 和 k、v 计算得到 logics,softmax 选出 token」两层 softmax 混成一层:attention 内 softmax 只产出输出向量、不产 token;logits 是走完剩余层过 LM head 才有的
3拼写 logicslogits
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 之前、独立一步
28softmax 流程漏了最后一步还差归一化:每个数除以指数结果的总和
29softmax 输出叫「概率」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)

文件内容对应章节
配图-为什么需要FlashDecoding1 个人翻完整本账 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

📎 本文的所有结论,也来自另一篇按对话时间线复盘的记录:《从输入到输出:大模型推理全流程复盘(含纠错清单)》,两篇可配合阅读。