04 · 调度谓词、标量装载与向量化原语
本篇覆盖 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h 第 239-373 行,即 private 区的第一批函数。结论先行:
- 这 135 行里只有 3 个是"业务函数"(
ComputeRecursiveAttn、WriteAttnTileToGm、DotFp32),其余 6 个是决策谓词 / 标量装载器(ShouldSplitVTiles、IsCurrentBlock、LoadG、LoadBeta、ScalarExp、CanCacheAttnQuery的同伴)。它们共同构成整个 kernel 的"控制平面"。 ComputeRecursiveAttn(:317-340)是全 kernel 数学密度最高的一段:它用 O(n²) 条前缀 Axpy 完成了 910B 版本用矩阵求逆做的事,且是"逐行前代换"而非"分块求逆"。DotFp32(:289-316)+Axpy是本 PR "纯 Vector 无 Cube" 特征的两块基石:所有点积(出标量)走 DotFp32,所有矩阵-向量乘(出向量)走"标量取系数 → Axpy 行累加"。
本篇出现的 AscendC 机器语义(后续章节直接引用,不再重复解释):
| 概念 | 机器含义 | 本区间出现处 |
|---|---|---|
| 32B block | UB/GM 搬运的最小单位。BLOCK_BYTES=32(:37)→ fp16 一行 16 个元素、fp32 一行 8 个元素才凑满一块 | :344、:352、:359-361 |
GetValue/SetValue | 标量管道逐元素读写 UB(LocalTensor)或 GM(GlobalTensor)。一次一条 4B/2B 访存,无并行;一条向量指令可覆盖 16~32 路,所以"标量循环"与"向量指令"的开销差一个量级以上 | :320、:331、:305、:309 |
PipeBarrier<PIPE_X> | 等 X 管道上已发射的全部指令执行完。只在同管道内建立完成顺序,不跨管道 | :333、:346、:349、:368 |
TQueSync<A,B> | 跨管道方向性事件对。SetFlag(0) 在 A 侧打点("A 此前的动作到此为止"),WaitFlag(0) 让 B 侧等这个点。模板参数就是方向;0 是事件槽位号 | :300-302、:322-324、:336-338 |
HardEvent::X_Y + FetchEventID | MTE 参与的跨管同步走事件 ID(pipe_->FetchEventID 取一个空闲事件,再 SetFlag/WaitFlag),语义同上但要显式拿 ID | 本区间无(见 05/06 篇的 DMA 三段式) |
TQue<VECOUT,1> | 出向队列:AllocTensor(生产者申请 VECOUT 位置的空间)→ 写 → EnQue(提交)→ 消费者 DeQue(拿到已提交数据)→ FreeTensor(归还)。BUFFER_NUM=1 ⇒ 无 double buffer,生产/消费严格串行 | :347-351、:371 |
Cast(dst, src, RoundMode::CAST_NONE, n) | 向量管道的数据类型转换。fp32→fp16 会丢精度;CAST_NONE 不是"不转换",而是"不显式指定舍入、走硬件默认(就近偶数)"。fp16→fp32 是无损扩展 | :348 |
Muls(dst, src, k, n) / Muls(dst, src, k) | 向量 × 标量。k=1.0f 时不改数值,只强制数据在向量管道落地("×1.0 刷新"惯用法) | :345 |
Duplicate(dst, v, n) | 向量广播:把标量 v 填进 dst[0..n)。本 kernel 主要用它做行清零 | :332 邻近(ComputeKCumdecay) |
Axpy(dst, src, c, n) | dst[i] += c * src[i],i ∈ [0, n)。c 是标量。第四个参数 n 决定"只更新前缀",这是本 kernel 递归修正的关键 | :332 |
WholeReduceSum(dst, src, len, times, dstStride, srcStride, srcRepeatStride) | 分组归约:times 次,每次把 len 个元素求和后写一个标量,相邻结果在 dst 中隔 dstStride 个元素 | :297 |
| UB 容量上限 | 单核 UB 是唯一可存放 LocalTensor 的地方,容量由 host 侧 ubRestBytes 记账(05 篇 :448/:536-537 的容量判定全部来自这个上限) | :344 附近的 alignedElem |
1. ShouldSplitVTiles:V-tile 并行开关
锚点:chunk_gated_delta_rule.h:239-240
__aicore__ inline bool ShouldSplitVTiles() { return realV_ > vStep_ && GetBlockNum() > NV_; }一行三件事:
realV_ > vStep_:V 维在数值上确实需要多 tile(host 的SolveVStep只在dv大到塞不进 UB 时才会给出vStep < dv)。GetBlockNum() > NV_:实际启动的核数(= host 的SetBlockDim(blockDim))超过 head 数,说明 host 判定"head 数填不满核"。- 两者与都成立才切 tile 模式,因为该模式的收益是"把 (head, vTile) 二元组当工作项",只有核有富余时才有意义;否则每个核反而要把 K 侧准备(Phase 1~5)重做一遍(见 05 篇
ProcessChunkVTile),纯亏。
注意 device 侧用的是 GetBlockNum() 而不是 tiling 里的 vectorCoreNum:前者是本任务实际下发到几个核,后者是平台有几个核。host 决定 blockDim 时用的条件是 dims.hv < aivNum && vTileCount > 1(tiling.cpp:267-279),device 复核时换成"实际 blockDim > NV_"。二者等价(blockDim = min(hv·vTileCount, aivNum)),但实现位置不同,改动任何一边都要同时验证另一边——这是 host/device 隐式契约之一。
该谓词被三处读:ComputeAvgload(:205,决定工作项数)、InitLocalBuffers(:168,决定 scores/state 是否 overlap)、Process(:220,决定走哪条分派路径)。第三处是控制流,前两处是内存布局——所以 InitLocalBuffers 与 host ComputeTmpBuffBytes 的 overlapScoresState 判定必须逐位一致,否则 tmpBuff 的切分与 host 的记账错位,表现为越界写而不是编译错误。
2. IsCurrentBlock:按真实序列长度的贪心装箱
锚点:chunk_gated_delta_rule.h:241-250
__aicore__ inline bool IsCurrentBlock(int32_t seqlen) {
load_ += seqlen;
bool ret = (blockIdx_ == usedblk_ && seqlen > 0);
if (load_ >= avgload_) {
load_ = 0;
usedblk_++;
}
return ret;
}逐行:
load_ += seqlen(:242):把"当前工作项的负载"累进桶里。负载单位是token 数,不是 head 数也不是 chunk 数——因为本算子每 token 的算力近似恒定,token 数就是算力。ret = (blockIdx_ == usedblk_ && seqlen > 0)(:243):usedblk_是"当前水位编号",即"此刻正在被分配工作项的那个核的编号"。只有本核编号等于水位时才认领。seqlen > 0是防御:Process已在 :215-218 跳过seqLen <= 0的 batch,理论上不会传 0 进来。if (load_ >= avgload_) { load_ = 0; usedblk_++; }(:244-247):桶装满了平均份额就把水位推进一格、桶清零。return ret(:248):注意ret是在推进水位之前算的,所以"跨越阈值的那一项"归属于被填满的那个核,不会丢失也不会重复。
关键性质(四条,构成正确性论证):
- 不重不漏:每个工作项恰好对应一个
usedblk_取值,因此恰好被一个核认领一次。其余核全部continue。 - 零核间通信:所有核以完全相同的顺序、相同的初始状态(
load_=0, usedblk_=0,构造函数 :91-93)遍历工作项列表,纯确定性计算 ⇒ 各核独立算出同一套分配结果。代价是每个核都要空跑整个三重循环的判定部分(标量比较,可忽略)。 - 分箱是全局一次性的:
load_/usedblk_在Process(:212-235)的 batch 循环和 head 循环里都不重置,跨 batch 跨 head 持续累计。若在某处误重置,长尾 batch 会被重复塞给编号小的核。 - 箱数不会超过核数:
avgload_ = Ceil(realT·NV_·W, GetBlockNum())(:206),总负载 =realT·NV_·W(W=每 head 工作项数,:205),故箱数 ≤ blockDim ⇒ 不存在"没有核认领的工作项"。
为什么这样能防长尾:朴素做法是 head_i % blockDim == blockIdx_(按 head 数均分)。变长序列下若 batch0 长 2048、batch1 长 8,按 head 均分会让同一个核既扛 batch0 的 head 又扛 batch1 的 head,而 batch0 那个 head 的耗时是 batch1 全部 head 之和的百倍 ⇒ 整机被最慢的核拖住。装箱按 token 加权后,长序列的 head 单独占箱(甚至一个 head 就超过 avgload_,直接独占一核并立刻推进水位),短序列的 head 多个共享一箱。均衡目标是"每核 token 数 ≈ avgload_",而不是"每核 head 数相等"。
两个可推敲的细节:
avgload_是Ceil的,所以最后一只箱通常装不满 ⇒ 尾部核提前空闲。理论上让avgload_用总负载/核数的浮点值并做"累计份额"比较更平,但收益是个位数百分比,且要引入 64 位除法,作者选择Ceil+ 整数比较。- 装箱顺序 =
Process的遍历顺序 = (batch, head[, vTile]) 字典序。这是"首次适应递减"的退化版(不排序),依赖"batch 顺序本身没有病态规律"的假设。真要对抗极端长尾,需先按 seqLen 排序——但排序要在标量管上对 GM 里的actualSeqLengths做,收益不足以进第一版实现。
3. LoadG:可选门控输入的标量装载
锚点:chunk_gated_delta_rule.h:251-257
__aicore__ inline float LoadG(int32_t t, uint64_t head_i) {
if (hasGamma_ == 0) {
return 0.0f;
}
return gGm_.GetValue(t * NV_ + head_i);
}hasGamma_ == 0返回 0.0f 而不是跳过:0 是 log 域衰减的幺元(),因此"无 g 输入"在下游 gCumsumFp32、expGCumFp32、decayMaskFp32里自然退化为恒等,不需要为hasGamma写两套算法。这是把可选输入的分支下沉到一个幺元上,而不是上浮到控制流里。t * NV_ + head_i:g 的布局是[T, Nv](per-value-head),所以步长是NV_;注意 t 是拼接后的全局 token 下标(seq0起算),不是 batch 内下标。- 为什么用标量
GetValue:每 chunk 只有chunkLen ≤ 64个 g,且必须在标量侧做前缀和(前缀和本质串行,向量化收益为负)。走 DMA 反而要为 256B 立事件三段式(见 05 篇 :461-470),得不偿失。 gGm_是GlobalTensor<float>(:921),绑定发生在SetGlobalTensors的hasGamma_ != 0分支(:119-121)。也就是说hasGamma_ == 0时gGm_根本没绑定地址——:252 的提前 return 同时是功能分支和空指针防护,二者不可拆分。
4. LoadBeta:beta 的标量装载与类型提升
锚点:chunk_gated_delta_rule.h:258-262
__aicore__ inline float LoadBeta(int32_t t, uint64_t head_i) {
inType bVal = betaGm_.GetValue(t * NV_ + head_i);
return static_cast<float>(bVal);
}- 与
LoadG同布局[T, Nv],但类型是inType(FP16)而非 FP32 ⇒ 必须显式static_cast<float>。这是本 kernel "输入 FP16、中间全 FP32" 精度策略的一个入口点。 - 该函数被
ComputeKBeta(:503)与LoadVBeta(:706、:723)读,因此同一个 beta 值每 chunk 至少被标量读两次(K 侧一次、V 侧一次),tile 模式下还会每个 V-tile 再读一次。作者没有把它缓存进 UB(64 个数 = 256B,缓存能省一次 GM 标量访存/行),原因是缓存后要引入 S→V 同步,而 beta 的消费点分散在 4 个函数里,生命周期管理成本高于省下的访存。这是可读性优先于微优化的取舍。 inType bVal单独一行而非直接 return:让betaGm_.GetValue的返回类型(__gm__ inType&解引用)到inType的退化在源码里可见。
5. ScalarExp:标量管上的软件指数
锚点:chunk_gated_delta_rule.h:263-288
__aicore__ inline float ScalarExp(float val) {
float absVal = (val >= 0.0f) ? val : -val;
int k = 0;
float reduced = absVal;
while (reduced > 1.0f) {
reduced *= 0.5f;
k++;
}
float result = 1.0f;
float term = 1.0f;
for (int i = 1; i <= kExpTaylorTerms; i++) {
term *= reduced / static_cast<float>(i);
result += term;
}
for (int i = 0; i < k; i++) {
result *= result;
}
if (val < 0.0f) {
result = 1.0f / result;
}
return result;
}
// Multiply a contiguous FP32 row pair on the vector pipe, then preserve the
// partial sums on the vector pipe. Each 64-element repeat writes one scalar
// at an eight-element stride; only those partials are accumulated by scalar.(285 行为空行;286-288 是 DotFp32 的文档注释,随本块一并列出以免出现行号空洞。)
为什么不用硬件 Exp——三个层次:
- 硬件层面:
Exp是向量指令,源和目都必须落在 UB 的LocalTensor上(见 docs/PR-406 §2 的存储约束)。要在标量流程里算一个float的指数,唯一走硬件的办法是:SetValue进 UB → S→V 同步 →Exp→PipeBarrier<PIPE_V>→ V→S 同步 →GetValue读回。即每次 exp 换 5 次跨管动作 + 2 次 UB 元素访存。 - 调用频率层面:唯一调用点是
UpdateAndWriteState(:864)的diffExp,每 chunk 调chunkLen ≤ 64次。这个量级下上面的往返成本与软件多项式的成本同阶,且软件版还能完全留在标量管、不打断向量管的流水(不引入新的 V 侧依赖)。 - 精度/范围层面:
g是 log 域衰减(负数、绝对值小于 1),G_last - G_i ≥ 0且通常很小,是良态输入;同时val也可能是 0(hasGamma_==0时gCumsumFp32全 0,k=0、reduced=0⇒result停在 1.0f,泰勒项全为 0,结果精确等于 1.0,与"无门控"的数学语义完全一致)。这条恒等路径是hasGamma缺省行为可信的依据。
参数缩减 + 恢复的逐段语义:
| 行 | 作用 | 数学 |
|---|---|---|
| :264 | 取绝对值,符号最后用倒数补 | |
| :266-270 | 折半到 k | |
| :271-276 | 12 项泰勒,term 递推式乘 | |
| :277-279 | 平方 k 次 | |
| :280-282 | 负输入取倒数 |
- 递推
term *= reduced / i而不是每次算:一次乘法 + 一次除法,数值上也更稳定(不会出现大幂次除大阶乘的中间溢出)。 kExpTaylorTerms = 12(:39)。时截断误差 ,在 FP32( )下已完全被舍入噪声淹没 ⇒ 项数不是瓶颈。 - 真正的误差来源是 :277-279 的 k 次平方:每次平方把相对误差翻倍,k 次 ⇒ 相对误差放大
倍。 (即 )时 ——已经不可接受。所以 ScalarExp的适用域是( )的温和输入,本 kernel 的用法满足,但任何后续改动若把它用于大指数(例如把 整体搬来标量算)就会静默掉精度。这是排查数值不一致时应优先看的一处(docs/PR-406 §7 也把 ScalarExp 列为精度排查首位)。 1.0f / result(:281):标量管有除法。用倒数而不是把负号带进折半循环,是为了让循环体只处理(避免 为负时泰勒的高次项正负交替带来的更大截断误差)。 while (reduced > 1.0f)用> 1.0f而非>= 1.0f:时 的 12 项近似已到位,且 k少一次 ⇒ 少一次误差放大。
6. DotFp32:一次吃 64 元素的软件点积
锚点:chunk_gated_delta_rule.h:289-316
__aicore__ inline float DotFp32(LocalTensor<float> lhs, LocalTensor<float> rhs, uint32_t count) {
Mul(dotProductFp32, lhs, rhs, count);
constexpr uint32_t kReduceRepeatElements = 64;
constexpr uint32_t kReduceDstStride = 8;
constexpr uint32_t kReduceSrcRepeatStride = 8;
uint32_t fullRepeats = count / kReduceRepeatElements;
uint32_t tail = count % kReduceRepeatElements;
if (fullRepeats > 0) {
WholeReduceSum(dotProductFp32, dotProductFp32, kReduceRepeatElements, fullRepeats, kReduceDstStride, 1,
kReduceSrcRepeatStride);
}
TQueSync<PIPE_V, PIPE_S> mulSync;
mulSync.SetFlag(0);
mulSync.WaitFlag(0);
float sum = 0.0f;
for (uint32_t i = 0; i < fullRepeats; i++) {
sum += dotProductFp32.GetValue(i * kReduceDstStride);
}
uint32_t tailOffset = fullRepeats * kReduceRepeatElements;
for (uint32_t i = 0; i < tail; i++) {
sum += dotProductFp32.GetValue(tailOffset + i);
}
return sum;
}
// Compute recursive intra-chunk attn matrix in-place.
// buf is stored with leading dimension ld (= chunkSize_), and only [chunkLen, chunkLen] is valid.
// buf is lower-triangular (excluding diagonal), with upper tri (incl diagonal) = 0.(313 行为空行;314-316 是 ComputeRecursiveAttn 的文档注释。)
三段式:Mul 出 128 个积 → WholeReduceSum 分组归约成 fullRepeats 个部分和 → 标量把部分和与尾部零头加起来。这是"向量干粗活、标量干细活"的最小可用组合:真正的求和树在向量单元里做完,标量只处理 1~4 个数。
为什么一次吃 64 个元素(kReduceRepeatElements = 64):WholeReduceSum 的 reduceLength 是硬件一条指令能吞下的归约宽度。对 FP32 而言 64 个元素 = 8 个 32B block = 向量单元一次 repeat 的数据量。取 64 的三重收益:
- 一条指令做完 64 次加法(标量做要 64 次
GetValue+ 64 次加法,差两个量级); count = realK_通常是 64 的整数倍(dk = 64/128/256)⇒tail = 0,标量收尾只剩fullRepeats个数;- 归约深度固定为 6 层两两合并,误差上界可预测(
log2(64)=6层 FP32 加法)。
kReduceDstStride = 8 的机器含义:部分和之间隔 8 个 FP32 元素 = 正好一个 32B block。三个理由:
- 每条归约指令写一个 4B 标量。若两个部分和落在同一个 32B block 里,两次独立写会互相踩(block 是 UB 写入/一致性检查的最小粒度,同块两次部分写要么需要读-改-合并,要么彼此覆盖)。隔一整块 ⇒ 每次写独占一块,零合并。
- 让标量侧
GetValue(i * 8)的寻址退化成"块首地址 + i×32B",是标量访存里最便宜的形式。 - 与 DMA 对齐体系自洽:
FP32_NUM_PER_BLOCK = 8(:36)在全 kernel 表示同一件事,这里复用同一个数而不是写新的魔数。
srcStride = 1、kReduceSrcRepeatStride = 8:srcStride=1 表示一次归约内部源元素连续(Mul 的输出本来就是连续的);srcRepeatStride 是相邻两次归约的源起点间隔,单位为 32B block:8 块 × 8 元素 = 64 元素,正好接上第 1 组,与 kReduceRepeatElements=64 自洽。
in-place(dst 与 src 同为 dotProductFp32)为什么合法:部分和写在偏移 count ≤ 512),所有部分和都落在第 0 组 [0,64) 内,而第 0 组的输入要么已被本次归约消费、要么其数据本来就不再需要(本函数只关心部分和)。当前 count = realK_ ≤ 256 恒满足该前提。反过来说:若未来把 dk 提到 576 以上,这个 in-place 写法会静默产生错误——一个真实的隐性约束。
tail 处理的正确性:Mul 写了恰好 count 个元素,所以 [tailOffset, count) 全是本次的新鲜乘积,标量直接相加不会读到上一轮的残留。同时 tail < 64 ⇒ 尾部最多 63 次标量访存,最坏情况(dk=127 之类非对齐形状,即 test_non_multiple_reduce_width 覆盖的场景)才付这个代价。
容量约束:dotProductFp32 的元素数是 max(stateStrideK_, chunkSize_)(:186)。它必须同时装下 count 个乘积(count = realK_ ≤ stateStrideK_ ✓)和 fullRepeats 个部分和(写在 <64 的位置,需 stateStrideK_ > 64,否则 cs=64 兜住——count<64 时 fullRepeats=0,不写部分和)✓。
调用点::580(k_beta·k)、:592/:608(q·k),三处的 count 都是 realK_。也就是说这个函数每 chunk 被调 Mul + 2 条 WholeReduceSum + 1 对 TQueSync + 2 次 GetValue。它是本 PR 的性能热点本体,Cube 化 PR 就是把这 4096 次点积整体换成 Matmul。
TQueSync<PIPE_V, PIPE_S>(:300-302)为什么必要:紧接着的 :305/:309 用标量 GetValue 读向量写的数据。没有这个 V→S 点,标量可能在归约还在流水线里时就读到旧值。注意它同时是"全 kernel 的向量写入对随后标量读取可见"的兜底点之一——05 篇会解释 decayMaskFp32 的标量读为什么也间接依赖它。
样板行说明::291-293 是 constexpr 而非 #define,让三个魔数带类型、可被 static_assert,且只在本函数可见;:303 float sum = 0.0f 从 0 起累,FP32 下加法顺序 = 部分和的偏移顺序,与参考实现一致;:311 return sum 走标量返回,调用方 :581/:592/:608 立刻在标量侧乘 decay。
7. ComputeRecursiveAttn:递归修正 = 单位下三角矩阵求逆的前代换
锚点:chunk_gated_delta_rule.h:317-340
__aicore__ inline void ComputeRecursiveAttn(LocalTensor<float> &buf, uint32_t chunkLen, uint32_t ld) {
for (uint32_t i = 1; i < chunkLen; i++) {
for (uint32_t k = 0; k < i; k++) {
deltaFp32.SetValue(k, buf.GetValue(i * ld + k));
}
TQueSync<PIPE_S, PIPE_V> rowSync;
rowSync.SetFlag(0);
rowSync.WaitFlag(0);
LocalTensor<float> outRow = buf[i * ld];
// For each k, update the prefix j<k in one vector operation:
// out[j] += original_row[k] * recursive_row_k[j].
// For every j this visits k=j+1..i-1 in the same order as the scalar
// recurrence, while element k itself remains untouched.
for (uint32_t k = 1; k < i; k++) {
float coeff = deltaFp32.GetValue(k);
Axpy(outRow, buf[k * ld], coeff, k);
PipeBarrier<PIPE_V>();
}
}
TQueSync<PIPE_V, PIPE_S> recursiveSync;
recursiveSync.SetFlag(0);
recursiveSync.WaitFlag(0);
}7.1 它在算什么(先把数学钉死)
ComputeAttnMatrix 的前半段(05 篇 :578-583)已经把直接项填进 buf:
严格下三角、上三角与对角线为 0。本函数把它就地变成
把
即代码实现的是
等价地,
7.2 逐行对应
| 行 | 机器动作 | 为什么必须这样 |
|---|---|---|
| :318 | i = 1 .. chunkLen-1 | 第 0 行没有 |
| :319-321 | 把第 i 行的前 i 个原始 deltaFp32 | 防自污染:buf 第 i 行马上会被下面的 Axpy 反复改写,而公式里的系数必须是求逆前的 |
| :320 | buf.GetValue(i*ld + k):标量读 | 只有 i ≤ 64 个数,且读出的目的就是当标量系数用 |
| :322-324 | TQueSync<PIPE_S, PIPE_V> | :319-321 是标量管道写 UB,:332 是向量管道读 UB(buf[k*ld] 行、deltaFp32 值),必须 S→V 打点。这与 :300-302 的 V→S 方向相反,二者不可互换 |
| :325 | outRow = buf[i*ld] | 行视图:一个 LocalTensor 起点,长度天然受 ld 约束 |
| :326-329 | 注释(原文保留) | 明确写了"prefix j<k"和"element k itself remains untouched",是理解 count=k 的唯一文档 |
| :330 | k = 1 .. i-1 递增 | 递增是被累加行 |
| :331 | coeff = deltaFp32.GetValue(k) | 读的是 :320 存的原始 |
| :332 | Axpy(outRow, buf[k*ld], coeff, k) | 一条向量指令做 outRow[0..k) += B_{ik} · A 的第 k 行[0..k)。第四个参数写成 k 而不是 ld/chunkLen 是全函数最要紧的一处:它把"长度为 k 的前缀"作为更新域 |
| :333 | PipeBarrier<PIPE_V> | :332 的下一条 Axpy 依赖它的结果(同管道、同一 dst 行),必须排队列排空;不加则两条 Axpy 对 outRow 同段的读写可能乱序 |
| :336-338 | TQueSync<PIPE_V, PIPE_S> | 出口方向同步:本函数最后动作是向量写 buf,而调用方 :585-587 立刻用标量 SetValue 写对角线、:635 又用标量读 |
count=k 为什么恰好覆盖三角递推(原注释 :326-329 的展开证明):固定列 Axpy。
代价与后果:GetValue、后面一条 PipeBarrier<PIPE_V> ⇒ 同步指令数与计算指令数 1:1。这是后续 PR 合并同步粒度的第一个靶子。
8. WriteAttnTileToGm:输出 tile 的 FP16 落盘
锚点:chunk_gated_delta_rule.h:341-373
__aicore__ inline void WriteAttnTileToGm(int32_t t_start, uint32_t chunkLen, uint64_t head_i, uint32_t v_i,
uint32_t curV, uint32_t avFp32) {
uint32_t totalElem = chunkLen * avFp32;
uint32_t alignedElem = Ceil(totalElem, FP32_NUM_PER_BLOCK) * FP32_NUM_PER_BLOCK;
Muls(chunkAttnOutFp32, chunkAttnOutFp32, 1.0f, alignedElem);
PipeBarrier<PIPE_V>();
LocalTensor<outType> outLocal = stateOutQueue_.AllocTensor<outType>();
Cast(outLocal, chunkAttnOutFp32, RoundMode::CAST_NONE, alignedElem);
PipeBarrier<PIPE_V>();
stateOutQueue_.EnQue<outType>(outLocal);
outLocal = stateOutQueue_.DeQue<outType>();
constexpr uint32_t outElemPerBlock = BLOCK_BYTES / sizeof(outType);
uint64_t gmRowStride = static_cast<uint64_t>(NV_) * realV_;
bool canUse2DStore = curV % outElemPerBlock == 0 && avFp32 % outElemPerBlock == 0 &&
gmRowStride % outElemPerBlock == 0 && (avFp32 - curV) / outElemPerBlock <= UINT16_MAX &&
(gmRowStride - curV) / outElemPerBlock <= UINT16_MAX;
uint64_t outOff = (static_cast<uint64_t>(t_start) * NV_ + head_i) * realV_ + v_i;
if (likely(canUse2DStore)) {
DataCopyParams outParams{static_cast<uint16_t>(chunkLen), static_cast<uint16_t>(curV / outElemPerBlock),
static_cast<uint16_t>((avFp32 - curV) / outElemPerBlock),
static_cast<uint16_t>((gmRowStride - curV) / outElemPerBlock)};
DataCopy(attnOutGm_[outOff], outLocal, outParams);
} else {
for (uint32_t i = 0; i < chunkLen; i++) {
uint64_t rowOff = (static_cast<uint64_t>(t_start + i) * NV_ + head_i) * realV_ + v_i;
DataCopyExtParams outParams{1, static_cast<uint32_t>(curV * sizeof(outType)), 0, 0, 0};
CopyToGm(attnOutGm_[rowOff], outLocal[i * avFp32], outParams);
PipeBarrier<PIPE_MTE3>();
}
}
stateOutQueue_.FreeTensor(outLocal);
}(373 行为空行。)
这是全 kernel 唯一一条四个 DataCopyParams 字段全部非平凡的出向 DMA,也是 TQue 五件套最完整的示范。
前半:为什么先 Muls(..., 1.0f, ...)(:345-346)。上一个写 chunkAttnOutFp32 的人是 AccumOutput(06 篇 :832-846),它用标量取系数 + Axpy 更新每一行,且只更新 chunkLen 行、每行 vStepAligned_ 长度。Muls(×1.0) 的作用是把整块 [chunkLen, avFp32] 在向量管道上重写一遍,配合 :346 的 PipeBarrier<PIPE_V> 保证随后 Cast 读到的地址范围与向量管道的写入历史完全对齐。它与 :516、:898 的 Muls(×1.0) 是同一惯用法(详见 05/06 篇)。数值上严格恒等(FP32 × 1.0f 精确),代价是一条向量指令。
alignedElem(:344)= Ceil(chunkLen*avFp32, 8)*8。因为 avFp32 = vStepAligned_ 本身是 8 的倍数,totalElem 必然 8 对齐 ⇒ alignedElem == totalElem,这一行是防御性写法。它同时定义了 Cast 的元素数:padding 列也被转成 FP16,随后由 2D copy 的 dstGap 丢掉。
中段:TQue 五件套(:347-351 + :371)。 AllocTensor<outType>() → 从 VECOUT 队列拿一块可写空间(生产者视角); Cast → 把 FP32 结果转成对外接口 FP16; EnQue<outType> → 提交,声明"生产者不再写这块"; DeQue<outType> → 以消费者视角取回已提交的 tensor(此后交给 MTE3 读); FreeTensor → 归还给队列池。 BUFFER_NUM = 1(:34)⇒ 队列只有 1 个槽,AllocTensor 必须等上一 tile 的 FreeTensor,于是"Cast → DMA 出 → 释放"完全串行,没有流水。这是 PR #446(双缓冲)候选点的源码位置。
canUse2DStore 的五个条件(:354-356),outElemPerBlock = 32B/2B = 16(FP16):
| 条件 | 含义 |
|---|---|
curV % 16 == 0 | 每行有效 FP16 元素数是整块 ⇒ 可用"块数"描述行 |
avFp32 % 16 == 0 | UB 里的行跨距是整块。注意 avFp32 是按 8(FP32 块)对齐出来的(:130),这里却要求 16(FP16 块)对齐——只有当 vStep_ 本身 16 对齐时才成立,而 host 的 SolveVStep 以 FP16_NUM_PER_BLOCK=16 为步长枚举,恰好保证了这点。若哪天 host 改成 8 步长,这条会静默导致快路径失效、性能掉一档而无报错 |
gmRowStride % 16 == 0 | GM 行跨距 NV*dv 整块,否则行间 gap 无法用块数表达 |
(avFp32 - curV)/16 <= UINT16_MAX | srcGap(UB 内行末跳过的块数)要能塞进 u16 |
(gmRowStride - curV)/16 <= UINT16_MAX | dstGap(GM 内行末跳过的块数)要能塞进 u16 |
后两条是典型的"参数宽度即正确性条件":DataCopyParams 的四个字段全是 uint16_t,gap 太大不会被硬件拒绝,而是被 static_cast 截断 ⇒ 搬错地址。所以在构造参数之前必须自己判。
快路径四元组(:359-361):{blockCount=chunkLen, blockLen=curV/16, srcGap=(avFp32-curV)/16, dstGap=(gmRowStride-curV)/16}。语义:在 UB 源里取一行 curV/16 块,行末跳 (avFp32-curV)/16 块(丢掉本行 padding);落到 GM 时每行末尾跳 (gmRowStride-curV)/16 块(跨过同一 token 其它 head/其它列的数据)。这就是"输出 tile 紧凑有效、但两侧都带跨距"的双 gap 搬运,一条 DataCopy 完成 chunkLen 行。
慢路径(:363-370):逐行 CopyToGm(:41-57 的模板封装)。注意 DataCopyExtParams{1, curV*sizeof(outType), 0, 0, 0} 的第二个字段是字节数而非块数——DataCopyExtParams 允许非 32B 整倍数的 blockLen(内部再按 sizeof(T) 折算成块并处理首尾零头),这正是 CopyToGm 里 AlignUp(elem, numPerBlock) 判定的用途;不整除时 CopyToGm 自己退化成逐块 DataCopy + 每块一次 PipeBarrier<PIPE_MTE3>。外层 :368 再来一次 PipeBarrier<PIPE_MTE3>:MTE3 队列里堆太多同地址附近的写会反压,逐行栅栏把写压力摊平(同时确保 attnOutGm_[rowOff] 的写序与行序一致)。
outOff(:357)与 rowOff(:365)都是 FP16 元素下标(GlobalTensor::operator[] 按元素偏移),布局 [T, NV, dv]。快路径只需 t_start 那一行的起点(后续行由 blockCount/srcGap/dstGap 表达),慢路径必须逐行重算——这就是两条路径代码形态不同的根因。
样板行说明::342 的 curV/avFp32 分开传,是因为本函数既要知道有效宽(搬运量)又要知道 UB 行跨距(gap);:343 声明在函数首行、:352 用 constexpr 定义 FP16 块元素数、:353 的 gmRowStride 用 static_cast<uint64_t> 先升位再乘,避免 NV_ * realV_ 在 uint32 里溢出(最大形状下 16*256=4096 不溢出,但这是全 kernel GM 偏移计算的一致风格)。