06 · V-Tile 七步处理:无 Cube 矩阵乘的完整形态
本篇覆盖 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h 第 649-916 行(268 行,占整个 kernel 的 28%)。结论先行:
- 这 268 行是唯一与 V 维有关的代码,被切成"1 个循环(:649)+ 1 个编排(:659)+ 7 个步骤函数"。每处理一个
[chunkLen, vStep]的 V-tile,就把七步完整跑一遍。 - 七步之间靠三块 FP32 tile 缓冲的身份轮换串起来:
chunkVFp32是 v_beta 又是 v_new,chunkAttnOutFp32依次是 Q 缓存 → V 的 FP16 staging → value_new → attn_inter → 最终 output,stateInFp32与chunkScoresFp32在特定分支下是同一块内存。顺序因此不可交换,§0 的"身份时间表"是本篇最重要的图。 - 中间 block state 始终留在 FP32(写 workspace,:885-887),只有
isLastChunk为真那一次才Cast成 FP16 写finalState(:896-914,判据在 :878)。原因是源码 :876-877 自己写明的:逐块 cast 会让误差累积且结果依赖 chunk_size。 - 公共接口把 state 定义成
[B, Nv, Dv, Dk](910B 惯例),kernel 内部用[Dk, vStep],所以转置只发生在两个接口边界:入口 :775-780(标量转置读)、出口 :908-913(标量转置写)。内部计算全程不转置。 - 本区间是指令量黑洞:
dk=128, vStep=16时每个(chunk, tile)约 2.87 万条 16 元素向量指令 + 约 1.66 万次标量取系数。§15 给逐步骤的账,并指出哪几处是可以安全省掉的。
0. 前置:七步总表与缓冲身份时间表
七步(函数入口行号):
| 步骤 | 函数 | 锚点 | 数学式 | 读 | 写 |
|---|---|---|---|---|---|
| 1 | LoadVBeta | :674 | v_beta = V ⊙ β | valueGm_,betaGm_ | chunkAttnOutFp32(staging) → chunkVFp32 |
| 2 | ComputeValueNew | :732 | value_new = A·v_beta | chunkScoresFp32(=A),chunkVFp32 | chunkAttnOutFp32 |
| 3 | LoadStateTile | :750 | 取 | stateWorkspaceGm_ 或 initStateGm_ | stateInFp32 |
| 4 | ComputeVNew | :789 | v_new = value_new − K_cd·S | chunkKFp32(=K_cd),stateInFp32,chunkAttnOutFp32 | chunkVFp32 |
| 5 | ComputeAttnInter | :811 | attn_inter = (Q·scale·e^G)·S | queryGm_,expGCumFp32,stateInFp32 | chunkAttnOutFp32 |
| 6 | AccumOutput | :832 | out = attn_inter + M·v_new | decayMaskFp32(=M),chunkVFp32 | chunkAttnOutFp32 |
| 出 | WriteAttnTileToGm | :341(04 篇) | — | chunkAttnOutFp32 | attnOutGm_ |
| 7 | UpdateAndWriteState | :849 | S ← e^{G_l}S + Σ e^{G_l−G_i}k_i⊗v_new_i | expGCumFp32,gCumsumFp32,keyGm_,chunkVFp32,stateInFp32 | workspace / finalStateGm_ |
缓冲身份时间表(一行 = 一个时间点,列 = 缓冲)。"死"表示该缓冲的内容已无消费者,因此被拿去当别的用——这就是本 kernel 复用 UB 的唯一规则:
| 时间点 | chunkVFp32 | chunkAttnOutFp32 | chunkScoresFp32 | stateInFp32 |
|---|---|---|---|---|
| Phase 1/3.5(:456/:552) | K/Q 的 FP16 staging | Q·scale 缓存(活) | 未写 | — |
| Phase 4(:576) | staging 残值(死) | Q·scale 缓存(活,被 DotFp32 读) | A(活) | 与 scores 重叠则同址 |
| Step 1(:687) | v_beta(活) | V 的 FP16 staging(Q 缓存被覆写) | A(活) | — |
| Step 2(:734) | v_beta(活) | value_new(活) | A(活,最后一次读 :740) | 与 scores 重叠则同址(未写) |
| Step 3(:753) | v_beta(死,Step2 已消费完) | value_new(活) | 已被清零覆盖(重叠分支) | state(活) |
| Step 4(:793) | v_new(活) | value_new(活,:805 读后死) | 死 | state(活) |
| Step 5/6 | v_new(活) | attn_inter → output(活) | 死 | state(活) |
| Step 7 | v_new(活,最后读) | 已由 WriteAttnTileToGm 落 GM | 死 | 更新后的 state → GM |
关键读法:Step 3 必须在 Step 2 之后,不只是数据依赖,而是 stateInFp32 与 chunkScoresFp32 在 InitLocalBuffers(:168-172,重叠分支)里取同一 off——Step 3 的 Duplicate(:753)会把 A 矩阵就地抹掉。:749 的注释 "overlaps chunkScoresFp32 — must run after Step 2" 说的就是这件事。
本篇涉及的 AscendC 原语的机器语义(Cast/Duplicate/Muls/Axpy/PipeBarrier/DataCopyParams/TQueSync/GetValue/SetValue/ReinterpretCast/likely/Ceil)已在 04 篇 §0 逐条给过,本篇不重复,只在具体行上引用。另外两条本篇反复用到的约定:
avFp32就是vStepAligned_(:407、:427 两个Process*Chunk*里各赋值一次),它是所有[cs, V]形 FP32 tile 的行 stride。代码里两个名字并存,纯属参数传递的可读性产物。SetFlag<HardEvent::A_B>(id)会被编译进 A 管道的指令流,WaitFlag<HardEvent::A_B>(id)被编译进 B 管道的指令流。所以源码里紧挨着的两行在硬件上不是紧挨着的:前者是"A 流到此打卡",后者是"B 流在此设闸"。本篇把这类三段式(S→MTE2、V→MTE2、DMA、MTE2→V)当作一个整体看。
1. ProcessVTiles:V 维切块与尾块夹取
锚点:chunk_gated_delta_rule.h:649-658
// ---- Phase 6: per v-tile processing (attn matrix and state tile overlap) ----
__aicore__ inline void ProcessVTiles(int32_t t_start, uint32_t chunkLen, uint64_t head_i, uint64_t qkHead,
uint32_t avFp32, uint64_t stateBaseOffset, uint64_t workspaceStateBaseOffset,
uint32_t c, bool isLastChunk) {
for (uint32_t v_i = 0; v_i < realV_; v_i += vStep_) {
uint32_t curV = (v_i + vStep_ > realV_) ? realV_ - v_i : vStep_;
ProcessOneVTile(t_start, chunkLen, head_i, qkHead, avFp32, stateBaseOffset, workspaceStateBaseOffset, c,
isLastChunk, v_i, curV);
}
}- :652
v_i是本 tile 在 Dv 维的列起点,步长vStep_。它同时是 GM 列偏移和 workspace 列偏移,两者都直接加在元素下标上(:688/:763/:886/:909)。 - :653
curV尾块夹取:末 tile 可能curV < vStep_。此后有两个宽度:逻辑宽度curV(决定读写 GM 多少列、DMA 块长)和物理宽度avFp32(决定行 stride 和所有向量指令的元素数)。所有七步代码都按avFp32/vStepAligned_做向量运算,只在跨 GM 边界和Cast元素数时退回curV。这个"宽物理、窄逻辑"的写法让内层循环零分支,代价是 padding 列参与运算——必须靠 :676、:753、:793 的清零保证 padding 无害。 - 循环体内没有任何跨 tile 的流水或双缓冲:每个 tile 独立跑完七步并把 state 写回 GM/workspace。V-tile 之间完全无数据依赖(各自的 state 列不相交、A 矩阵和 K_cd 与 V 无关),这正是 §0 之外唯一可以分核并行的维度,于是有了
ProcessChunkVTile(:423)+ShouldSplitVTiles(:239)这条路。 - 注意这里不重算 Phase 1~5:走
ProcessVTiles说明ShouldSplitVTiles()为假,一个核要把同一 chunk 的所有 tile 做完,而 Phase 1-5 的产物(A、K_cd、M)在 tile 间是可复用的,所以ProcessChunk只在循环前算一次(:414-420)。反之走ProcessChunkVTile时每 tile 一次调用,Phase 1-5 被重复算Ceil(dv/vStep)遍——用重复计算换并行度。
2. ProcessOneVTile:七步编排
锚点:chunk_gated_delta_rule.h:659-673
__aicore__ inline void ProcessOneVTile(int32_t t_start, uint32_t chunkLen, uint64_t head_i, uint64_t qkHead,
uint32_t avFp32, uint64_t stateBaseOffset, uint64_t workspaceStateBaseOffset,
uint32_t c, bool isLastChunk, uint32_t v_i, uint32_t curV) {
LoadVBeta(t_start, head_i, chunkLen, v_i, curV, avFp32);
ComputeValueNew(chunkLen, curV, avFp32);
LoadStateTile(stateBaseOffset, workspaceStateBaseOffset, v_i, curV, c);
ComputeVNew(chunkLen, avFp32);
ComputeAttnInter(t_start, qkHead, chunkLen, avFp32);
AccumOutput(chunkLen, avFp32);
WriteAttnTileToGm(t_start, chunkLen, head_i, v_i, curV, avFp32);
UpdateAndWriteState(t_start, chunkLen, v_i, curV, qkHead, stateBaseOffset, workspaceStateBaseOffset, avFp32,
isLastChunk);
}- 这个函数一条向量指令、一个同步都没有,纯粹是顺序声明。它的价值在于:七步的所有正确性约束都被下推到"每步自己保证出口可见性"这一条纪律上,编排层因此不需要插入
PipeBarrier。 - :668 输出写回排在 state 更新之前。功能上两者无依赖(一个读
chunkAttnOutFp32,一个读chunkVFp32+stateInFp32),但两者共用同一条stateOutQueue_(BUFFER_NUM=1,:34/:138),谁先谁后只影响 FP16 staging 的占用顺序;排在前面还有一层好处:output 落 GM 后chunkAttnOutFp32即释放,下一 tile 的 Step 1 可以立刻拿它当 staging。 - 传参里
v_i/curV只出现在 Step 1、Step 3、WriteAttnTileToGm、Step 7,而 Step 2/4/5/6 拿不到 V 列位置——因为它们的行 stride 恒为avFp32,padding 与真实列的边界对这四步不可见。这是 §0 表里"物理宽度运算"的落地方式。
3. Step 1(上):V 的二维跨步 DMA
锚点:chunk_gated_delta_rule.h:674-703
// Step 1: v_beta = V * beta -> chunkVFp32.
__aicore__ inline void LoadVBeta(int32_t t_start, uint64_t head_i, uint32_t chunkLen, uint32_t v_i, uint32_t curV,
uint32_t avFp32) {
Duplicate(chunkVFp32, 0.0f, chunkLen * avFp32);
PipeBarrier<PIPE_V>();
// The compact output tile is dead until ComputeValueNew, so reuse it as
// FP16 staging and replace chunkLen*curV scalar GM loads with one strided
// standard DataCopy. Only use the fast path when both row width and source
// gap are exactly representable in 32-byte blocks.
uint64_t srcRowGapElems = static_cast<uint64_t>(NV_) * realV_ - curV;
uint64_t srcRowGapBytes = srcRowGapElems * sizeof(inType);
if (likely((curV * sizeof(inType)) % BLOCK_BYTES == 0 && srcRowGapBytes % BLOCK_BYTES == 0 &&
srcRowGapBytes / BLOCK_BYTES <= 65535)) {
LocalTensor<inType> valueLocal = chunkAttnOutFp32.template ReinterpretCast<inType>();
uint64_t vOff = (static_cast<uint64_t>(t_start) * NV_ + head_i) * realV_ + v_i;
uint16_t rowBlocks = static_cast<uint16_t>(curV * sizeof(inType) / BLOCK_BYTES);
uint16_t srcRowGapBlocks = static_cast<uint16_t>(srcRowGapBytes / BLOCK_BYTES);
DataCopyParams copyParams{static_cast<uint16_t>(chunkLen), rowBlocks, srcRowGapBlocks, 0};
event_t scalarToMte2 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::S_MTE2));
SetFlag<HardEvent::S_MTE2>(scalarToMte2);
WaitFlag<HardEvent::S_MTE2>(scalarToMte2);
event_t vectorToMte2 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::V_MTE2));
SetFlag<HardEvent::V_MTE2>(vectorToMte2);
WaitFlag<HardEvent::V_MTE2>(vectorToMte2);
DataCopy(valueLocal, valueGm_[vOff], copyParams);
event_t mte2ToVector = static_cast<event_t>(pipe_->FetchEventID(HardEvent::MTE2_V));
SetFlag<HardEvent::MTE2_V>(mte2ToVector);
WaitFlag<HardEvent::MTE2_V>(mte2ToVector);- :676
Duplicate(chunkVFp32, 0.0f, chunkLen*avFp32)把整个 tile 清零。为什么必须清::709 的Cast每行只写curV个元素,[curV, avFp32)的 padding 列再也没人写过;而 Step 2 的Axpy按avFp32全宽累加(:743),padding 若是 staging 残值就会污染value_new。同一段清零也覆盖慢路径(:726 只SetValue前curV列)。 - :677
PipeBarrier<PIPE_V>不是装饰。此刻chunkAttnOutFp32里还躺着 Phase 3.5 缓存的 Q·scale,它最后被 V 管道的DotFp32→Mul(:290)读过(Phase 4 的 :592)。接下来 MTE2 要用 V 覆写同一块内存,必须先让 V 把对旧内容的读全部退休,否则 WAR 竞争。同理慢路径下它保证Duplicate(V 写)先于 :726 的标量SetValue(S 写)落地。 - :683/:684 源行间距 =
NV_*realV_ - curV个 FP16 元素。GM 里 V 是[B, T, NV_, Dv]扁平排布,相邻两行(t 与 t+1,同一 head)之间隔了整个 head 维,所以是"跨步"拷贝而不是连续拷贝。用NV_*realV_-curV而不是(NV_-1)*realV_+...是因为blockLen已经吃掉了本行的curV个元素,gap 只补剩下的。 - :685-686 三个快路径条件,逐个对应
DataCopyParams的字段宽度:①curV*2 % 32 == 0⇒blockLen能以 32B 块精确表示(curV是 16 的倍数或至少 16 的倍数级);②gap*2 % 32 == 0⇒srcGap同理;③gap/32 ≤ 65535⇒srcGap是uint16_t。三者缺一即退回纯标量。- 还有一个没写进条件但恒成立的前提:每行的 GM 起始地址要 32B 对齐。
vOff = (t*NV_+head_i)*realV_ + v_i,其中v_i是vStep_的倍数,host 侧SolveVStep只按 16 枚举 vStep ⇒v_i*2字节恒为 32 的倍数。若哪天 host 允许vStep取非 16 倍数,这个快路径会静默算错地址而不是退化——是 §15 隐患清单里最值钱的一条。
- 还有一个没写进条件但恒成立的前提:每行的 GM 起始地址要 32B 对齐。
- :687
ReinterpretCast<inType>把 FP32 tile 的头指针当成 FP16 视图用,容量翻倍:chunkAttnOutFp32有cs*avFp32个 FP32 =2*cs*avFp32个 FP16 槽位,而 staging 只需chunkLen*curV ≤ cs*vStep_个 ⇒ 恒够用。 - :691
DataCopyParams{chunkLen, rowBlocks, srcRowGapBlocks, 0}:blockCount= 行数(不是字节数),blockLen= 每行块数,srcGap= 行间距块数,dstGap = 0⇒ 目的端紧凑堆放成[chunkLen, curV],正好让 :709 用valueLocal[i*curV]寻址。 - :693-702 三段式与 :461-470 逐字同构(05 篇 §5 已拆解)。这里只补一句:
S_MTE2那一对在本函数里其实是多余的,因为 :677 已经把 V 排空、地址与参数是标量寄存器里的即时值;保留它是照抄模板,属"保守"类。
4. Step 1(下):逐行 Cast+Muls 与标量兜底
锚点:chunk_gated_delta_rule.h:704-731
TQueSync<PIPE_S, PIPE_V> betaValueSync;
for (uint32_t i = 0; i < chunkLen; i++) {
float betaVal = LoadBeta(t_start + i, head_i);
betaValueSync.SetFlag(0);
betaValueSync.WaitFlag(0);
Cast(chunkVFp32[i * avFp32], valueLocal[i * curV], RoundMode::CAST_NONE, curV);
PipeBarrier<PIPE_V>();
Muls(chunkVFp32[i * avFp32], chunkVFp32[i * avFp32], betaVal, curV);
PipeBarrier<PIPE_V>();
}
TQueSync<PIPE_V, PIPE_S> valueSync;
valueSync.SetFlag(0);
valueSync.WaitFlag(0);
return;
}
for (uint32_t i = 0; i < chunkLen; i++) {
int32_t t = t_start + i;
uint64_t vOff = (static_cast<uint64_t>(t) * NV_ + head_i) * realV_;
float beta_val = LoadBeta(t, head_i);
for (uint32_t v = 0; v < curV; v++) {
float v_val = static_cast<float>(valueGm_.GetValue(vOff + v_i + v));
chunkVFp32.SetValue(i * avFp32 + v, v_val * beta_val);
}
}
}- :704 循环外构造一次、循环内反复
SetFlag/WaitFlag:TQueSync对象的构造会向TPipe申请一对事件通道,放进循环就是每行一次申请/释放,白付管理开销。这个"对象在环外、打点在环内"的写法在全文件 9 处TQueSync里都一致。 - :706-708 每行的
betaVal是标量 GM 读(:259betaGm_.GetValue),紧接着 S→V 同步,再作为Muls的标量系数。这条同步是每行新增一次标量取数逼出来的,不能提到循环外合并(对比 :501-507 的ComputeKBeta,那里LoadBeta也在循环里,同样逐次打点)。 - :709
Cast(..., CAST_NONE, curV):FP16→FP32 精确无损(FP32 尾数包含 FP16 全部位),CAST_NONE即默认的就近无饱和。元素数用curV而不是avFp32——padding 列不参与,保持 :676 的 0。 - :711
Muls目标与源同为chunkVFp32[i*avFp32],就地乘 β。这是逐行 16 元素的短向量操作,两条指令(Cast、Muls)各覆盖 ≤32B;把它跟 :683 的 DMA 对比就明白本 kernel 的性能结构:搬运已经向量化了,逐元素算术还全是 16 宽。 - :714-716
valueSync(V→S)出口收口。实测全文件没有任何chunkVFp32.GetValue(脚本核对:GetValue只出现在chunkScoresFp32/decayMaskFp32/gCumsumFp32/expGCumFp32/chunkKFp32/dotProductFp32/stateLocal上),Step 2/4/6/7 都只以向量方式读chunkVFp32(同管道按序),所以这一对属"保守收口"。删掉可省一次管道排空。 - :719
return:快路径直接结束函数,慢路径从 :721 起。两条路径语义等价(同样的chunkVFp32内容、同样的 padding 为零),这是判定"删条件走慢路"类改动安全性的基准:任何时刻两条路径必须给出逐位相同的 tile。 - :721-729 慢路径是纯标量三重循环,共
chunkLen*curV次 GM 标量读 + 同量 UB 标量写(16 列 × 64 行 = 1024 对),相比快路径的"1 次 DMA + 64 次 16 宽向量指令"差约两个数量级。所以生产配置下这个分支基本不走;它的存在价值是保证 dk/dv/NV 任意组合都有正确兜底,而不是性能。 - 慢路径出口没有任何同步对象,与快路径的 :714-716 不对称,这恰好证明那对
valueSync是模板带进来的保守件。慢路径的安全性另有来源::726 的标量SetValue与 Step 2 的 :743 向量Axpy之间,被 Step 2 第一次循环的coefficientSync(:741-742,S→V)隔开——"标量写 UB → 向量读 UB"由消费端而不是生产端授权,这与 :783-785 的初值同步(生产端授权)是两种可行写法。
5. Step 2:value_new = A · v_beta(下三角 GEMV)
锚点:chunk_gated_delta_rule.h:732-749
// Step 2: value_new_tile = attn @ v_beta_tile (lower-tri attn, sum k <= i) -> chunkAttnOutFp32.
__aicore__ inline void ComputeValueNew(uint32_t chunkLen, uint32_t curV, uint32_t avFp32) {
(void)curV;
Duplicate(chunkAttnOutFp32, 0.0f, chunkLen * avFp32);
PipeBarrier<PIPE_V>();
TQueSync<PIPE_S, PIPE_V> coefficientSync;
for (uint32_t i = 0; i < chunkLen; i++) {
LocalTensor<float> outRow = chunkAttnOutFp32[i * avFp32];
for (uint32_t k = 0; k <= i; k++) {
float score = chunkScoresFp32.GetValue(i * chunkSize_ + k);
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
Axpy(outRow, chunkVFp32[k * avFp32], score, avFp32);
PipeBarrier<PIPE_V>();
}
}
}- :733
(void)curV;是把形参显式转 void 以消除"未使用参数"告警。它不是废话,而是信息:作者知道curV在这里不需要,因为整个 tile 按avFp32全宽算,padding 列已由 :676 归零、算出来还是 0。 - :734
Duplicate覆写chunkAttnOutFp32:V 的 FP16 staging 残值就此作废(Step 1 已消费完)。 - :738 行指针
outRow在 i 循环外取一次,内层Axpy反复累加同一行——这就是"软件矩阵乘"的标准骨架:目标行常驻,源行按 k 扫过,系数标量喂。 - :739
k <= i:A = (I−B)⁻¹ 是下三角(对角在 :586 被置 1),所以第 i 行只有k ≤ i非零。这里省掉的不仅是计算量(2080 vs 4096 次 Axpy),更是正确性:上三角槽位里躺的是Duplicate清零后的 0,即便读到也不影响结果,但循环上界仍是语义声明。 - :740 注意 stride 写成
i * chunkSize_而 Step 6(:838)写成i * cs(cs是 :835 的局部别名)。同一个数两种拼法,属文件内的风格漂移,不影响正确性;chunkScoresFp32的分配量是cs*cs(:170-173),行 stride 恒为chunkSize_与chunkLen无关。 - :743
Axpy(dst, src, coeff, count)机器语义:dst[j] += coeff * src[j],coeff是标量、count个 FP32 同拍流水。此处dst与src分属两块 tile,无重叠,不依赖"Axpy 目标即源"的未定义行为。 - 与 :630-643 的
ComputeKCumdecay后半结构完全相同(同一下三角 GEMV 模板),差别只有三个操作数。这两处 + Step 4/5/6/7 合计 6 处同构代码,是全文件最值得抽成一个TriGemv(dstBuf, srcBuf, coefBuf, ld, count)的地方(07 篇 §5 的"重复模板盘点")。
6. Step 3(上):tile 清零与 FP32 workspace 读
锚点:chunk_gated_delta_rule.h:750-772
// Step 3: load state tile into stateInFp32 (overlaps chunkScoresFp32 — must run after Step 2).
__aicore__ inline void LoadStateTile(uint64_t stateBaseOffset, uint64_t workspaceStateBaseOffset, uint32_t v_i,
uint32_t curV, uint32_t c) {
uint32_t stateTileElem = stateStrideK_ * vStepAligned_;
Duplicate(stateInFp32, 0.0f, stateTileElem);
PipeBarrier<PIPE_V>();
if (c > 0) {
PipeBarrier<PIPE_ALL>();
event_t vectorToMte2 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::V_MTE2));
SetFlag<HardEvent::V_MTE2>(vectorToMte2);
WaitFlag<HardEvent::V_MTE2>(vectorToMte2);
uint32_t alignedV = Ceil(curV, FP32_NUM_PER_BLOCK) * FP32_NUM_PER_BLOCK;
DataCopyParams stateParams{1, static_cast<uint16_t>(alignedV / FP32_NUM_PER_BLOCK), 0, 0};
for (uint32_t d = 0; d < realK_; d++) {
uint64_t rowOff = workspaceStateBaseOffset + static_cast<uint64_t>(d) * stateWorkspaceStrideV_ + v_i;
DataCopy(stateInFp32[d * vStepAligned_], stateWorkspaceGm_[rowOff], stateParams);
}
PipeBarrier<PIPE_ALL>();
event_t mte2ToVector = static_cast<event_t>(pipe_->FetchEventID(HardEvent::MTE2_V));
SetFlag<HardEvent::MTE2_V>(mte2ToVector);
WaitFlag<HardEvent::MTE2_V>(mte2ToVector);
return;
}- :752/:753 清零范围是整个带 padding 的 tile:
stateStrideK_行 ×vStepAligned_列。stateInFp32的分配量恰为stateStrideK_ * avStepAligned(:171/:176),所以这次Duplicate覆盖全缓冲,无越界。padding 列会被 Step 4/5 的vStepAligned_宽 Axpy 读,必须为 0。 - 两条分支的唯一判据是
c > 0(chunk 序号),不是hasGamma_也不是isLastChunk。含义:c == 0时上一状态来自用户的 initial_state(FP16,公共布局);c > 0时来自自己的 FP32 workspace。这就是"中间态 FP32、接口态 FP16"的读端。 - :756
PipeBarrier<PIPE_ALL>排空全部管道;:757-759V_MTE2闸口要求"MTE2 必须等 V 把 :753 的清零做完"——这是必需的 WAR 保护:DMA 的目标stateInFp32与Duplicate的目标是同一块内存。于是 :756 的 PIPE_ALL 与它重叠(更强的手段包住更弱的手段),属可省的保守叠加。 - :760
alignedV = Ceil(curV, 8) * 8:FP32 的 32B 块 = 8 元素,所以 DMA 元素数要向上取到 8 的倍数。curV ≤ vStep_ ≤ vStepAligned_,取整后不会写出 tile 之外(同一行的 padding 列被覆盖成 workspace 的 padding 值,随后又被 :753 的…不——顺序是先清零后 DMA,所以alignedV > curV时 padding 列会被 workspace 的陈旧 padding 覆盖,见本小节末尾的"隐患")。 - :761
DataCopyParams{1, alignedV/8, 0, 0}:blockCount = 1,靠 :762 的d循环发realK_条独立 DMA。 - :763 读地址:workspace 布局
[B, Nv, Dk, align8(Dv)](:378 的workspaceStateBaseOffset = (b*NV_+h)*realK_*stateWorkspaceStrideV_是每-head 基址,行 stride =stateWorkspaceStrideV_),列起点v_i。 - :764 写地址:
stateInFp32[d * vStepAligned_](tile 行 stride =vStepAligned_)。源行宽与目地的行宽是两个不同的量,这正是 :761 只能blockCount=1的原因吗?不是:目标端dstGap = (vStepAligned_-alignedV)/8块、源端srcGap = (stateWorkspaceStrideV_-alignedV)/8块,两者都是 8 的倍数相减后除 8,恒为整数。也就是说这里可以合成一条blockCount = realK_的 2D DataCopy,把 128 次 DMA 指令下发压成 1 次。这是本篇找到的最明确的一处向量化欠账(对比 :691 和 :356-360,作者在别处都会用 2D)。风险点:workspace 的 padding 列(dv到stateWorkspaceStrideV_之间)会被一起搬进来,但那部分列本 tile 不读,无害。 - :766-769 出口:
PIPE_ALL+MTE2_V闸口,让 Step 4 的向量 Axpy 保证看到 DMA 结果。MTE2_V必需,PIPE_ALL(:766)同样冗余。
7. Step 3(下):初值的标量转置加载
锚点:chunk_gated_delta_rule.h:773-788
// The public interface follows 910B and stores state as [B, Nv, Dv, Dk].
// The internal FP32 tile is [Dk, vStep], so transpose while loading.
for (uint32_t d = 0; d < realK_; d++) {
for (uint32_t v = 0; v < curV; v++) {
uint64_t stateOffset = stateBaseOffset + static_cast<uint64_t>(v_i + v) * realK_ + d;
stateInFp32.SetValue(d * vStepAligned_ + v, static_cast<float>(initStateGm_.GetValue(stateOffset)));
}
}
// Initial state is loaded by scalar GetValue/SetValue and is consumed by
// vector Muls in ComputeVNew/ComputeAttnInter.
TQueSync<PIPE_S, PIPE_V> initialStateSync;
initialStateSync.SetFlag(0);
initialStateSync.WaitFlag(0);
}- :775-780 双重标量循环,一次读 + 一次写:
init[b,h,v,d] → tile[d,v]。这就是 §结论 4 说的"转置只在接口边界"。元素数realK_*curV(dk=128、vStep=16 ⇒ 2048 对标量访存),每 head 只付一次(只在c==0走),所以不心疼。 - 为什么不用 DMA + 向量转置?AIV-only 下没有 MDM/Transpose 类指令,UB 内转置只能"逐列 strided 读 + 逐行写"或
DataCopyExtParams的字节粒度搬运,而 FP16 的[Dv, Dk]里相邻d(步长 1)要变成行——任何向量化方案都要么需要 2B 粒度的 gather,要么需要一块临时全 tile 缓冲。用标量换掉一块 UB,是这个 kernel 在 UB 预算下的合理选择。 - :778 写侧带
vStepAligned_stride、读侧带realK_stride,两个 stride 分别来自 §14 的两条布局公式,读者核对 state 布局时以这两行为准。 - :783-785
initialStateSync(S→V)是真正必需的同步:标量写进 UB 的内容,接下来由 V 管道作为Axpy的源行读(Step 4 的 :797 取行、:800 的Axpy)。"标量写 UB → 向量读 UB"这一类全 kernel 只有 4 处::498-:500(K/g 的标量兜底路径)、:322-:324(递归行系数写deltaFp32)、:604-:606(慢路径的一行 Q·scale)、:783-:785(初值转置写stateInFp32)。四处都是TQueSync<PIPE_S, PIPE_V>,方向一致,缺一不可。 - 与
c > 0分支的不对称:那条在 :770 直接return,没有 S→V 同步——因为它的数据是 MTE2 搬进来的,向量侧的可见性由 :767-769 的MTE2_V闸口负责。看懂这一对差异,就看懂了"谁写的数据由谁负责放行"。 - 隐患(padding 列的初值路径):
v ≥ curV的列在 :753 已清零且此处不写 ⇒ 保持 0,与 :760 的alignedV语义一致。但d ≥ realK_的 padding 行(stateStrideK_ > realK_时存在,即dk % 8 != 0)两条分支都不写、却会被 Step 7 的 :897-901 整块Cast——读到的是Duplicate的 0,落进 FP16 也是 0,再被 :908-913 的循环上界丢掉。链条闭合:padding 行/列在整个七步中都不会污染结果,条件是 :753 的清零范围必须等于 tile 分配量(相等,见 :171/:176)。
8. Step 4:v_new = value_new − K_cd · S
锚点:chunk_gated_delta_rule.h:789-810
// Step 4: v_new = value_new - (k_cumdecay @ state) -> chunkVFp32 (reused).
__aicore__ inline void ComputeVNew(uint32_t chunkLen, uint32_t avFp32) {
TQueSync<PIPE_S, PIPE_V> coefficientSync;
for (uint32_t i = 0; i < chunkLen; i++) {
LocalTensor<float> vpRow = chunkVFp32[i * avFp32];
Duplicate(vpRow, 0.0f, vStepAligned_);
PipeBarrier<PIPE_V>();
for (uint32_t d = 0; d < realK_; d++) {
float kcd = chunkKFp32.GetValue(i * alignK_ + d);
LocalTensor<float> stRow = stateInFp32[d * vStepAligned_];
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
Axpy(vpRow, stRow, kcd, vStepAligned_);
PipeBarrier<PIPE_V>();
}
Muls(vpRow, vpRow, -1.0f, vStepAligned_);
PipeBarrier<PIPE_V>();
Add(vpRow, vpRow, chunkAttnOutFp32[i * avFp32], vStepAligned_);
PipeBarrier<PIPE_V>();
}
}
// Step 5: attn_inter = (q * exp(g_cumsum)) @ state -> chunkAttnOutFp32.- 这是七步里最贵的一步:内层
realK_次(dk 上限)逐 (i,d) 的标量 UB 读 + 一次 16 宽 Axpy ⇒cs*dk = 8192次(vStep=16)。§15 有账。 - :796
chunkKFp32.GetValue之所以合法,靠的是 Phase 5 出口 :642-644 的outputSync(V→S)——此刻chunkKFp32的身份已经是 K_cd,而它是被 :639 的Axpy用向量方式覆写的。05 篇 §13 把这条列为"跨函数必需",本篇确认它是唯一的可见性来源。 - :792/:793 就地复用 v_beta 行:先
Duplicate清掉 v_beta(Step 2 已在 :743 消费完最后一行,见 §0 表),再累加K_cd·S。Step 2 与 Step 4 的顺序因此不可交换。 - :793 宽度用
vStepAligned_而非avFp32:同一常量、两种写法。同一函数内 :792 的偏移用avFp32、:793 的元素数用vStepAligned_,读代码时容易误以为是两个量——实际ProcessChunk里avFp32 = vStepAligned_。 - :803-805 取负再加,实现
v_new = value_new − K_cd·S。这里有一个可以合并的冗余:内层循环结束时vpRow = Σ kcd·S = K_cd·S,要的是value_new − vpRow,用一条Sub(chunkAttnOutFp32[i*avFp32]? ...)形态即可:Add(dst=a, src1=?)/Sub在本文件已用过(:527Sub(deltaFp32, deltaFp32, gCumsumFp32, cs)),说明三操作数形式可用。改成Sub(vpRow, chunkAttnOutFp32[i*avFp32], vpRow, vStepAligned_)(语义dst = src1 - src2)就能省掉 :803 这次全行乘 −1。作者选 Muls+Add 大概率是为了让"先取负、再相加"两步与 :788 的注释公式逐字对齐。收益:每行少一次 16 元素向量写;每 tile 少cs条指令。 - :805
Add(vpRow, vpRow, chunkAttnOutFp32[i*avFp32], ...):dst与src0同一张量。AscendC 的三地址向量指令对dst==src1是常见且安全的(同管道按序),但与 :527 的Sub一样,这类重叠是 UB 复用逼出来的,值得在 review 后续 PR 时列为"改动前需硬件确认"清单。 - Step 4 之后
chunkAttnOutFp32里的 value_new 只剩这一次读取(:805),随即在 Step 5 被Duplicate(:815)逐行覆写。依赖链:Step 5 的第 i 行写必须在 Step 4 的第 i 行读之后——同一条 V 管道按序,故无需额外同步。这是"七步为何不需要步间 barrier"的一般性答案:跨步依赖全是 V→V 的按序链,唯一的例外是 Step 3 的 DMA(MTE2↔V)和标量取系数(S↔V)。
9. Step 5:attn_inter = (Q·scale·e^G) · S
锚点:chunk_gated_delta_rule.h:811-831
__aicore__ inline void ComputeAttnInter(int32_t t_start, uint64_t qkHead, uint32_t chunkLen, uint32_t avFp32) {
TQueSync<PIPE_S, PIPE_V> coefficientSync;
for (uint32_t i = 0; i < chunkLen; i++) {
LocalTensor<float> outRow = chunkAttnOutFp32[i * avFp32];
Duplicate(outRow, 0.0f, vStepAligned_);
PipeBarrier<PIPE_V>();
int32_t t = t_start + i;
uint64_t qkOff = (static_cast<uint64_t>(t) * NK_ + qkHead) * realK_;
float gExp = expGCumFp32.GetValue(i);
for (uint32_t d = 0; d < realK_; d++) {
float qd = static_cast<float>(queryGm_.GetValue(qkOff + d)) * scale_ * gExp;
LocalTensor<float> stRow = stateInFp32[d * vStepAligned_];
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
Axpy(outRow, stRow, qd, vStepAligned_);
PipeBarrier<PIPE_V>();
}
}
}- :819
gExp每行一次 UB 标量读(expGCumFp32是 Phase 3 用向量Exp写的,可见性由 :519-522expSync授权)。注意它读的是按cs长度算出的数组,而i < chunkLen ≤ cs,落在有效区内——尾部(chunkLen..cs)的Exp是对残值的计算,从不被读(05 篇 §8)。 - :821 是本 kernel 唯一从 GM 逐标量读 Q 的地方(另一处是慢路径 :601)。为什么不用 Phase 3.5 的 Q 缓存?因为那块缓存就在
chunkAttnOutFp32,而 Step 1 的 :687 已经把同一区域拿去当 V 的 staging 了。UB 不够 → Q 缓存活不过 K 阶段 → Q 必须二次入场,且这次只能走标量 GM 读。这是一条完整的因果链,也是 §15 里"最贵的取舍"。 - :821 的
* scale_ * gExp在 d 循环内做:每个系数 2 次标量乘。scale_是 FP32 标量成员(:964)、gExp每行一个,都可以提前折进 state 侧或折进一次Muls,但因为Axpy的系数只能是标量寄存器,乘法次数不能靠缓存减少,只能靠把 gExp 与 scale 合并成一个每行标量(scale_ * gExp提到 :819 同行即可省cs*dk次标量乘)。这是零风险微优化。 - 与 Step 4 的对称性:两者形状完全一样(
for i: for d: Axpy(目标行, stateInFp32[d], 标量系数)),差别只在系数来源(K_cd 在 UB,Q 在 GM)和目标 tile(v_new 在 chunkVFp32,attn_inter 在 chunkAttnOutFp32)。所以 Step 4+Step 5 合计2*cs*dk = 16384次 Axpy,是全 kernel 的一半开销。 - 数学核对:
out_i = e^{G_i}·(Q_i·scale)·S,与 docs/PR-406-00e39ab5/01 的attn_inter = e^{g_cumsum}·Q·S一致;qkHead用于 Q 的偏移、head_i用于 state 的列空间,二者在本函数里分别由 :818 与stateInFp32(Step 3 已按 head 定位)承担。
10. Step 6:out = attn_inter + M · v_new
锚点:chunk_gated_delta_rule.h:832-848
_// Step 6: output = attn_inter + attn_i @ v_new (accumulate into chunkAttnOutFp32).
_aicore__ inline void AccumOutput(uint32_t chunkLen, uint32_t avFp32) {
uint32_t cs = chunkSize_;
TQueSync<PIPE_S, PIPE_V> coefficientSync;
for (uint32_t i = 0; i < chunkLen; i++) {
LocalTensor<float> outRow = chunkAttnOutFp32[i * avFp32];
for (uint32_t jj = 0; jj <= i; jj++) {
float a = decayMaskFp32.GetValue(i * cs + jj);
LocalTensor<float> vRow = chunkVFp32[jj * avFp32];
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
Axpy(outRow, vRow, a, vStepAligned_);
PipeBarrier<PIPE_V>();
}
}
}decayMaskFp32到这里已经换了身份。Phase 3 写的是纯 decay;Phase 4 后半(cache 路径 :592-594,慢路径 :608-610)把下三角含对角原地覆写成 。所以 :838 取的 a已是最终权重,不需要再乘注意力分数。上三角仍是裸 decay 且从不被读(:837 的jj <= i)。这是全 kernel 最隐蔽的一处别名:单看 Step 6 会以为它把 QK 分数漏乘了。- 与 Step 2 的区别只在系数缓冲(
chunkScoresFp32= A vsdecayMaskFp32= M)和源 tile(v_beta vs v_new)。两者共用同一个下三角 GEMV 模板。 - :836 拿到的
outRow不清零:Step 5 已把 attn_inter 写进同一行,本步是"累加"。这与 Step 2/4/5 的显式Duplicate形成对比,也是 §0 身份表里chunkAttnOutFp32在 Step 5→6 保持"活"的原因。 - :842 源
vRow是 Step 4 产出的 v_new,宽度vStepAligned_;chunkVFp32自 Step 4 之后不再被写,直到 Step 7 只读、下一 tile 的 :676 才重新清零。同一条 chunkVFp32 在一个 tile 内被消费 3 次(Step 2 作为 v_beta、Step 6/7 作为 v_new),中间被 Step 4 就地改写——所以"Step 2 早于 Step 4 早于 Step 6 早于 Step 7"是硬顺序,交换任何一对都会静默算错。
11. Step 7(上):状态衰减与秩 1 累加
锚点:chunk_gated_delta_rule.h:849-874
// Step 7: decay state, add (K * exp(gLast - g))^T @ v_new, then write the state tile back to GM.
__aicore__ inline void UpdateAndWriteState(int32_t t_start, uint32_t chunkLen, uint32_t v_i, uint32_t curV,
uint64_t qkHead, uint64_t stateBaseOffset,
uint64_t workspaceStateBaseOffset, uint32_t avFp32, bool isLastChunk) {
TQueSync<PIPE_S, PIPE_V> coefficientSync;
float gLastExp = expGCumFp32.GetValue(chunkLen - 1);
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
for (uint32_t d = 0; d < realK_; d++) {
LocalTensor<float> stRow = stateInFp32[d * vStepAligned_];
Muls(stRow, stRow, gLastExp, vStepAligned_);
PipeBarrier<PIPE_V>();
}
for (uint32_t i = 0; i < chunkLen; i++) {
int32_t t = t_start + i;
uint64_t qkOff = (static_cast<uint64_t>(t) * NK_ + qkHead) * realK_;
float diffExp = ScalarExp(gCumsumFp32.GetValue(chunkLen - 1) - gCumsumFp32.GetValue(i));
LocalTensor<float> vRow = chunkVFp32[i * avFp32];
for (uint32_t d = 0; d < realK_; d++) {
float coeff = static_cast<float>(keyGm_.GetValue(qkOff + d)) * diffExp;
LocalTensor<float> stRow = stateInFp32[d * vStepAligned_];
coefficientSync.SetFlag(0);
coefficientSync.WaitFlag(0);
Axpy(stRow, vRow, coeff, vStepAligned_);
PipeBarrier<PIPE_V>();
}
}- :853
gLastExp = e^{G_{chunkLen-1}}:本 chunk 最后一个位置的累计门控指数,即整个 chunk 的总衰减。用它先把旧状态整体衰减(:856-860),是的第一项。 - :854-855 这对 S→V 同步放在整个函数开头,而不是像别处一样紧贴取数。作用:让 :856 起的
Muls系数gLastExp(标量寄存器)在 V 侧可见。它是全区间唯一一处"一次同步服务多条向量指令"的写法,因为后续Muls全用同一个系数。 - :856-860
realK_条 16 宽Muls。若把整块 tile 当成一维数组,一条Muls(stateInFp32, stateInFp32, gLastExp, stateStrideK_*vStepAligned_)就够——这里逐行拆开是因为 padding 行(d ≥ realK_)不该被乘?乘 1 之外的值也无害(那些行从不被逻辑读,只在 :897 被整块 Cast 后由 :908 循环丢掉)。所以逐行是风格选择,合并成一条可省realK_-1条指令(本区间第二处零风险优化)。 - :864
ScalarExp(G_l − G_i):软件 exp(:263 的 12 项 Taylor 实现,04 篇 §4),不用Exp硬件指令,因为它要的是一个标量而不是向量。为什么不用现成的?它本来在 decayMaskFp32的第chunkLen-1行(列i),但 Phase 4 已把该行下三角覆写成 M,原值被销毁。为什么不用expGCumFp32[l]/expGCumFp32[i]?因为e^{G_i}下溢为 0 时是除零。用ScalarExp(差)是唯一"既有原值又无分支风险"的路。- 参数符号:
g ≤ 0时G单调不增,G_l − G_i ≤ 0(i ≤ l),故ScalarExp走val < 0分支 =1/exp(|val|)。即使|val| > 88.72(FP32exp溢出界限)使中间量为inf,倒数后得 0,恰是数学极限——这条路径对负参数天然安全,与 :518/:528 的硬件Exp(正参数会真溢出)形成对照。 - 真正会挂的是
absVal为inf/NaN::267 的while (reduced > 1.0f)永不退出 ⇒ kernel 死循环。要触发需要某个g为大正数使G上溢,kernel 对g的符号没有任何校验(04 篇 §4 已列)。
- 参数符号:
- :867
keyGm_.GetValue:又一次逐标量 GM 读原始 K。chunkKFp32里存的是 K_cd(Phase 5 覆写)、kCumdecayFp32是,都不是裸 K,所以 Step 7 只能回 GM 取。与 Step 5 的 Q 同因:UB 里没有留给裸 K 的位置( chunkKFp32是唯一一份,已被复用)。 - :871
Axpy(stRow, vRow, coeff, vStepAligned_):stRow是 state 的第 d 行(V 方向 16 列),vRow是 v_new 的第 i 行(同样 16 列)。所以一次 Axpy 就把外积的第 d 个分量沿 V 维摊开——这是无 Cube 下做外积的标准形状:外层 i(token)、内层d(K 维)、向量轴 = V 维。循环顺序i外d内还让vRow在整个内层循环里保持热点复用,比反过来(每 d 重读 K 一列)更省。 - 累加目标
stRow与 Step 4/5 读它的 Axpy 之间存在 WAR:Step 4/5 的行读必须早于这里的行写。调用顺序(:665/:666 早于 :669)+ V 管道按序即保证,无需同步。
12. Step 7(中):非最后块写 FP32 workspace
锚点:chunk_gated_delta_rule.h:875-895
// Preserve the state in FP32 between chunks. Casting to final_state (FP16)
// after every chunk accumulates quantization error and makes the result
// depend on chunk_size.
if (!isLastChunk) {
PipeBarrier<PIPE_ALL>();
event_t vectorToMte3 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::V_MTE3));
SetFlag<HardEvent::V_MTE3>(vectorToMte3);
WaitFlag<HardEvent::V_MTE3>(vectorToMte3);
uint32_t alignedV = Ceil(curV, FP32_NUM_PER_BLOCK) * FP32_NUM_PER_BLOCK;
DataCopyParams stateParams{1, static_cast<uint16_t>(alignedV / FP32_NUM_PER_BLOCK), 0, 0};
for (uint32_t d = 0; d < realK_; d++) {
uint64_t rowOff = workspaceStateBaseOffset + static_cast<uint64_t>(d) * stateWorkspaceStrideV_ + v_i;
DataCopy(stateWorkspaceGm_[rowOff], stateInFp32[d * vStepAligned_], stateParams);
}
PipeBarrier<PIPE_ALL>();
event_t mte3ToMte2 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::MTE3_MTE2));
SetFlag<HardEvent::MTE3_MTE2>(mte3ToMte2);
WaitFlag<HardEvent::MTE3_MTE2>(mte3ToMte2);
return;
}- 这就是**"中间态保持 FP32"的代码位置**::885-887 与 Step 3 的 :763-764 是同一条 (源/目) 地址公式的正反两面,宽度 FP32、布局
[Dk, align8(Dv)]。写与读的 tile 完全对称,是自检该 kernel 时最容易核对的一对。 - :880-882
V_MTE3:MTE3(出)必须等 V 把 :856-875 的更新全部退休。必需。 - :890-892
MTE3_MTE2:本核下一个 chunk 的 Step 3 会用 MTE2 读同一块 workspace。这是"同一内存的写→读"跨 chunk 闸口,必需;也正因为它必需,可以确认 workspace 在本 kernel 里被当作同一核内的临时寄存器堆用,不承担跨核通信。 - 跨核为何无需锁:workspace 的
[d, v_i..v_i+curV)元素区间由(b*NV_+h, v_i)唯一决定,IsCurrentBlock(:241)保证一个(head, tile)只被一个核认领 ⇒ 每个区间只有一个写者一个读者,且都在同一核内。这条论证是"分核按 head/tile、不分 chunk"的完整理由;任何改动若让两个核处理同一(b,h,v_i)(例如把 chunk 维也拆核)都会立刻破坏它。 - 每 chunk 一次的 GM 往返量:
realK_ * alignedV * 4字节(dk=128、vStep=16 ⇒ 8 KB 写 + 8 KB 读)。相对 FP16 接口态翻倍,是 FP32 保真度的代价。 alignedV与 :760 同式,所以 §6 指出的"可合成 2D DMA"在读写两侧都成立(写侧srcGap = 0、dstGap = (stateWorkspaceStrideV_-alignedV)/8)。
13. Step 7(下):最后块 cast FP16 写 final_state
锚点:chunk_gated_delta_rule.h:896-916
// Only the final chunk is cast to the public FP16 final_state output.
uint32_t alignedElem = stateStrideK_ * vStepAligned_;
Muls(stateInFp32, stateInFp32, 1.0f, alignedElem);
PipeBarrier<PIPE_V>();
LocalTensor<outType> stateLocal = stateOutQueue_.AllocTensor<outType>();
Cast(stateLocal, stateInFp32, RoundMode::CAST_NONE, alignedElem);
PipeBarrier<PIPE_V>();
stateOutQueue_.EnQue<outType>(stateLocal);
stateLocal = stateOutQueue_.DeQue<outType>();
TQueSync<PIPE_V, PIPE_S> finalStateSync;
finalStateSync.SetFlag(0);
finalStateSync.WaitFlag(0);
for (uint32_t v = 0; v < curV; v++) {
uint64_t stateRowOffset = stateBaseOffset + static_cast<uint64_t>(v_i + v) * realK_;
for (uint32_t d = 0; d < realK_; d++) {
finalStateGm_.SetValue(stateRowOffset + d, stateLocal.GetValue(d * vStepAligned_ + v));
}
}
stateOutQueue_.FreeTensor(stateLocal);
}- 与 :878 合起来就是本篇结论 3:判据是
isLastChunk(由 :384/:397 的c + 1 == numC传入,numC按真实序列长度算,因此"最后一块"是每个 batch 各自的最后一条 chunk,不是全局末尾)。 - :897/:898
Muls(..., 1.0f, alignedElem)数值上是恒等,作用是造一次"V 管道内的自我依赖 + 全 tile 触碰",配合 :899 的PipeBarrier<PIPE_V>确保 :856-874 的所有行更新都已落地,再交给 :901 的Cast整块读。同样的"乘 1 当栅栏"手法在 :344(WriteAttnTileToGm入口)和 :516(PrepareDecayAndExp入口)各出现一次——全文件三处,语义一致。严格说这里PipeBarrier<PIPE_V>已足够(V 按序),Muls是冗余的第三次保险。 - :897
alignedElem = stateStrideK_ * vStepAligned_:Cast覆盖含 padding 的整块 tile,不是realK_*curV。因此 :900 的队列缓冲必须容得下stateStrideK_*vStepAligned_*2字节——这正是 :134-138 把stateTileBytes与chunkOutBytes取 max 后InitBuffer的理由。 - :900-904 队列四步曲
AllocTensor → (V 写) → EnQue → DeQue。stateOutQueue_是TQue<QuePosition::VECOUT, 1>(:929,且BUFFER_NUM = 1,:34/:138——无双缓冲),所以:AllocTensor从空闲池取唯一那块;EnQue把它挂成"已提交给 V 输出";DeQue阻塞到 V 真正写完;FreeTensor(:914)归还。若少了FreeTensor(:914),队列容量 1 会被永久占住——后续AllocTensor(下一个 tile 的 :347 或本函数的 :900)拿到的就是同一块内存且失去 DeQue 带来的排空语义,属难查的正确性缺陷。这是本篇认为最应该在报告 2/3 里作为"生命周期不变式"记下来的一条。 - :905-907
finalStateSync(V→S)::911 用标量stateLocal.GetValue读 FP16 tile,读之前必须让队列/ V 的写对整个标量侧可见。必需。 - :908-913 转置写出,与 :775-780 严格互逆:
final[b,h,v,d] = tile[d,v]。用curV × realK_双标量循环(SetValue到 GM = 每元素一次 MTE3 标量写),dk=128, vStep=16时 2048 对,每 (head, tile) 只付一次(只在最后一块)。 - 为什么出口不也用 DMA?出口要 FP32→FP16 转置,DMA 不能转置,向量化转置又需要第二块 UB;且写出后不再被读,标量路径最简单。代价是
dv很大时 final_state 的写出会成为尾延迟——但相对七步的 2.87 万条向量指令,2048 次标量写是小头。 - 数值口径:
CAST_NONE是 FP32→FP16,超出 ±65504 会得 ±inf(不饱和)。state 经过e^{G_l}衰减与 v_new 累加,正常配置下不会超;但 :911 的写出没有任何数值检查,与"只有最后一块才量化"的误差论述(:876-877)共同构成 state 的精度画像:跨块零量化误差累积,接口一次量化。
14. state tile 与全部 stride 总表
七步里出现的每一种"行 stride"和"块基址",一张表钉死(定义处行号 + 使用处行号):
| 缓冲 / 对象 | 逻辑布局 | 行 stride | 元素宽 | 定义处 | 主要使用处 |
|---|---|---|---|---|---|
chunkVFp32 | [cs, vStep] FP32 | avFp32 = vStepAligned_ | curV(GM 侧)/vStepAligned_(向量侧) | :165 | :676/:709/:711/:743/:792/:803/:841/:870 |
chunkAttnOutFp32 | [cs, vStep] FP32(另兼 FP16 staging / Q 缓存) | avFp32 | 同上 | :180 | :687/:734/:738/:805/:814/:836/:345 |
stateInFp32 | [Dk, vStep] FP32 | vStepAligned_ | vStepAligned_(含 padding 列) | :171/:176 | :753/:764/:778/:797/:822/:857/:868/:887/:898/:901 |
chunkScoresFp32(A) / decayMaskFp32(M) | [cs, cs] FP32 | chunkSize_(=64,恒为编译期分块大小,非 chunkLen) | cs | :170/:174 / :162 | :740/:838 |
chunkKFp32(K→K_cd) / kCumdecayFp32 | [cs, dk] FP32 | alignK_ = ceil(dk,16)*16 | realK_ | :157/:161 | :796/:634/:638 |
gCumsumFp32 / expGCumFp32 | [cs] FP32 | — | cs | :183/:194 | :819/:853/:864 |
valueGm_ / attnOutGm_ | [B,T,Nv,Dv] FP16 | NV_*realV_(token 行) | realV_(head 行) | :113/:120/:122 | :683/:688/:909(类比)/:341 系列 |
queryGm_ / keyGm_ | [B,T,Nk,dk] FP16 | NK_*realK_ | realK_ | :111/:118/:457 | :818/:863 |
initStateGm_ / finalStateGm_ | [B,Nv,Dv,Dk] FP16/outType | realK_ | realK_ | :377 | :778/:909 |
stateWorkspaceGm_ | [B,Nv,Dk,ceil(dv,8)*8] FP32 | stateWorkspaceStrideV_ | 同上 | :378/:92 | :763/:886 |
两条 V 方向宽度的区别是这张表的精髓:vStepAligned_ = ceil(vStep,8)*8 描述"计算 tile 的一行",stateWorkspaceStrideV_ = ceil(dv,8)*8 描述"workspace 里一整条 dk 行"。前者 ≤ 后者,二者的差就是 Step 3/7 必须带 v_i 列偏移、并且只能按 d 逐行 DMA 的全部原因。
tile 内存图(dk=128、vStep=16、末 tile curV=8 时):
stateInFp32 (FP32, 128 × 16)
col: 0 ......... 7 | 8 ....... 15 ← vStepAligned_=16
row d=0: [ 真实 8 列 ][ 0(:753 清零)]
row d=1: [ 真实 8 列 ][ 0 ]
...
row d=127:[ 真实 8 列 ][ 0 ]
(stateStrideK_=128 = realK_ ⇒ 无 padding 行;dk=100 时会有 124-127 行全 0)15. 指令量账与隐患清单
以 cs=64, dk=128, vStep=16(avFp32=16)为单位 (chunk, v-tile):
| 步骤 | 向量指令(约) | 宽度 | 标量系数读 | 备注 |
|---|---|---|---|---|
| 1 LoadVBeta | 1 Duplicate + cs×(Cast+Muls) ≈ 129 | 16 | cs 次 GM(β) | 1 条 DMA |
| 2 ComputeValueNew | 2080 Axpy + 1 Duplicate | 16 | 2080 次 UB | |
| 3 LoadStateTile | dk 条 DMA(或初值 2048 对标量) | — | — | §6 指出可压成 1 条 2D DMA |
| 4 ComputeVNew | 8192 Axpy + 64×(Duplicate+Muls+Add) = 8384 | 16 | 8192 次 UB | Muls 可与 Add 合并成 Sub ⇒ 省 64 |
| 5 ComputeAttnInter | 8192 Axpy + 64 Duplicate = 8256 | 16 | 8192 次 GM + UB 64 | 最贵:GM 标量读 |
| 6 AccumOutput | 2080 Axpy | 16 | 2080 次 UB | — |
| 7 UpdateAndWriteState | dk×Muls + cs*dk×Axpy = 8320 | 16 | 8192 次 GM(K) + UB 129 + 64 次 ScalarExp(≈24 标量乘/次) | — |
| 出 WriteAttnTileToGm | 3 条(Muls+Cast+DMA) | 全 tile | — | 04 篇 §8 |
| 合计 | ≈ 2.9 万条 16 宽向量指令 | 单条 64 B | ≈ 2.1 万次标量取系数,其中 ≈1.65 万次是 GM 标量读 | — |
三条结论:
- "无 Cube"的代价不是算术吞吐,是指令下发与标量访存。2.9 万条 64B 向量指令把 UB 带宽用掉约 1.8 MB,但更致命的是每次 Axpy 前要一次标量取系数(S→V 打点 + 可能的 GM 标量读)。Step 5/7 的 1.65 万次 GM 标量读完全可以避免——如果 UB 里还留着 Q/K 的 FP32 副本。这就是
ubRestBytes预算(02 篇)与七步性能之间的直接因果。 - 零风险优化只有 4 处,且都不改语义:§6/§12 的
realK_次 DMA → 1 次 2D DMA;§8 的Muls(-1)+Add→Sub;§11 的realK_次Muls→ 1 次整块Muls;§9 的scale_提前折入gExp。其余(每 Axpy 一次的coefficientSync、每行一次的PipeBarrier<PIPE_V>)能省但要先证明 V 管道对同缓冲区读写的按序性,不是零风险。 - 两条硬隐患:① §3 的快路径把"
v_i是 16 的倍数 ⇒ GM 行首 32B 对齐"当作隐式前提,host 的SolveVStep若改变枚举步长会静默错地址(不是退化到慢路径);② §11 的ScalarExp在|g|导致G上溢成inf时会死循环(:267 的while),kernel 对g的符号与幅度没有任何校验,:518/:528 的硬件Exp也没有饱和。