Skip to content

04 · 调度谓词、标量装载与向量化原语 ​

本篇覆盖 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h 第 239-373 行,即 private 区的第一批函数。结论先行:

  1. 这 135 行里只有 3 个是"业务函数"(ComputeRecursiveAttn、WriteAttnTileToGm、DotFp32),其余 6 个是决策谓词 / 标量装载器(ShouldSplitVTiles、IsCurrentBlock、LoadG、LoadBeta、ScalarExp、CanCacheAttnQuery 的同伴)。它们共同构成整个 kernel 的"控制平面"。
  2. ComputeRecursiveAttn(:317-340)是全 kernel 数学密度最高的一段:它用 O(n²) 条前缀 Axpy 完成了 910B 版本用矩阵求逆做的事,且是"逐行前代换"而非"分块求逆"。
  3. DotFp32(:289-316)+ Axpy 是本 PR "纯 Vector 无 Cube" 特征的两块基石:所有点积(出标量)走 DotFp32,所有矩阵-向量乘(出向量)走"标量取系数 → Axpy 行累加"。

本篇出现的 AscendC 机器语义(后续章节直接引用,不再重复解释):

概念机器含义本区间出现处
32B blockUB/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 + FetchEventIDMTE 参与的跨管同步走事件 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

cpp
  __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

cpp
  __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 是在推进水位之前算的,所以"跨越阈值的那一项"归属于被填满的那个核,不会丢失也不会重复。

关键性质(四条,构成正确性论证):

  1. 不重不漏:每个工作项恰好对应一个 usedblk_ 取值,因此恰好被一个核认领一次。其余核全部 continue。
  2. 零核间通信:所有核以完全相同的顺序、相同的初始状态(load_=0, usedblk_=0,构造函数 :91-93)遍历工作项列表,纯确定性计算 ⇒ 各核独立算出同一套分配结果。代价是每个核都要空跑整个三重循环的判定部分(标量比较,可忽略)。
  3. 分箱是全局一次性的:load_/usedblk_ 在 Process(:212-235)的 batch 循环和 head 循环里都不重置,跨 batch 跨 head 持续累计。若在某处误重置,长尾 batch 会被重复塞给编号小的核。
  4. 箱数不会超过核数: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

cpp
  __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 域衰减的幺元(e0=1),因此"无 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

cpp
  __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

cpp
  __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——三个层次:

  1. 硬件层面:Exp 是向量指令,源和目都必须落在 UB 的 LocalTensor 上(见 docs/PR-406 §2 的存储约束)。要在标量流程里算一个 float 的指数,唯一走硬件的办法是:SetValue 进 UB → S→V 同步 → Exp → PipeBarrier<PIPE_V> → V→S 同步 → GetValue 读回。即每次 exp 换 5 次跨管动作 + 2 次 UB 元素访存。
  2. 调用频率层面:唯一调用点是 UpdateAndWriteState(:864)的 diffExp,每 chunk 调 chunkLen ≤ 64 次。这个量级下上面的往返成本与软件多项式的成本同阶,且软件版还能完全留在标量管、不打断向量管的流水(不引入新的 V 侧依赖)。
  3. 精度/范围层面: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取绝对值,符号最后用倒数补|x|
:266-270折半到 (0,1],次数记 kx=2kr,r∈(0,1]
:271-27612 项泰勒,term 递推式乘 r/ier≈∑i=012ri/i!
:277-279平方 k 次ex=(er)2k
:280-282负输入取倒数e−x=1/ex
  • 递推 term *= reduced / i 而不是每次算 ri/i!:一次乘法 + 一次除法,数值上也更稳定(不会出现大幂次除大阶乘的中间溢出)。
  • kExpTaylorTerms = 12(:39)。r≤1 时截断误差 ≤113!⋅(几何修正)≈1.6×10−10,在 FP32(ε≈1.2×10−7)下已完全被舍入噪声淹没 ⇒ 项数不是瓶颈。
  • 真正的误差来源是 :277-279 的 k 次平方:每次平方把相对误差翻倍,k 次 ⇒ 相对误差放大 2k 倍。k=10(即 |x|∼103)时 210×1.2×10−7≈1.2×10−4——已经不可接受。所以 ScalarExp 的适用域是 |x|≲20(k≤5)的温和输入,本 kernel 的用法满足,但任何后续改动若把它用于大指数(例如把 eG 整体搬来标量算)就会静默掉精度。这是排查数值不一致时应优先看的一处(docs/PR-406 §7 也把 ScalarExp 列为精度排查首位)。
  • 1.0f / result(:281):标量管有除法。用倒数而不是把负号带进折半循环,是为了让循环体只处理 x≥0(避免 r 为负时泰勒的高次项正负交替带来的更大截断误差)。
  • while (reduced > 1.0f) 用 > 1.0f 而非 >= 1.0f:r=1 时 e1 的 12 项近似已到位,且 k 少一次 ⇒ 少一次误差放大。

6. DotFp32:一次吃 64 元素的软件点积 ​

锚点:chunk_gated_delta_rule.h:289-316

cpp
  __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 的三重收益:

  1. 一条指令做完 64 次加法(标量做要 64 次 GetValue + 64 次加法,差两个量级);
  2. count = realK_ 通常是 64 的整数倍(dk = 64/128/256)⇒ tail = 0,标量收尾只剩 fullRepeats 个数;
  3. 归约深度固定为 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)为什么合法:部分和写在偏移 0,8,16,…,8(R−1),其中 R=fullRepeats=⌊count/64⌋。只要 8(R−1)<64 即 R≤8(亦即 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 被调 ≈2×6422=4096 次,每次含 1 条 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

cpp
  __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:

Bij=−βi(ki⋅kj)eGi−Gj,0≤j<i<L

严格下三角、上三角与对角线为 0。本函数把它就地变成 A,循环体是

Aij=Bij+∑j<m<iBimAmj,Aii=0 (由外部 :586 置 1)

把 B 补上对角线成 B~=I+B(外部 :585-587 做的正是这个),令 A=(I−B)−1 的严格下部分加单位阵,可验证:

(I−B)A=I⟺Aij−∑j≤m<iBimAmj=0(j<i)⟺Aij=Bij+∑j<m<iBimAmj

即代码实现的是 A=(I−B)−1=(I+tril−1(diag(β)KK⊤⊙D))−1,其中 Dij=eGi−Gj。910B 版本对同一个矩阵调用显式求逆(PR 描述里的"递归修正替代矩阵求逆"),本 commit 改为按行前代换:不做高斯消元、不生成增广矩阵、不需要除法,O(L2) 次行加法。

等价地,A=∑p=0L−1Bp(B 严格下三角故幂零)。代码的写法是按路径首条边分解(left-looking:首条边 Bim,余下 Amj)

7.2 逐行对应 ​

行机器动作为什么必须这样
:318i = 1 .. chunkLen-1第 0 行没有 j<0,B00 位置是 0,外部直接置对角线 1 ⇒ 第 0 行不需要修正
:319-321把第 i 行的前 i 个原始 Bik 抄进 deltaFp32防自污染:buf 第 i 行马上会被下面的 Axpy 反复改写,而公式里的系数必须是求逆前的 Bik。不抄就会用"修正过的 Aik"当系数,算出 ∑pBp 的错误组合
:320buf.GetValue(i*ld + k):标量读只有 i ≤ 64 个数,且读出的目的就是当标量系数用
:322-324TQueSync<PIPE_S, PIPE_V>:319-321 是标量管道写 UB,:332 是向量管道读 UB(buf[k*ld] 行、deltaFp32 值),必须 S→V 打点。这与 :300-302 的 V→S 方向相反,二者不可互换
:325outRow = buf[i*ld]行视图:一个 LocalTensor 起点,长度天然受 ld 约束
:326-329注释(原文保留)明确写了"prefix j<k"和"element k itself remains untouched",是理解 count=k 的唯一文档
:330k = 1 .. i-1 递增递增是被累加行 Amj 已定型的条件(m<i 的行必须已求完逆);m=0 时 A0j 无下三角元素,故从 1 起
:331coeff = deltaFp32.GetValue(k)读的是 :320 存的原始 Bik
:332Axpy(outRow, buf[k*ld], coeff, k)一条向量指令做 outRow[0..k) += B_{ik} · A 的第 k 行[0..k)。第四个参数写成 k 而不是 ld/chunkLen 是全函数最要紧的一处:它把"长度为 k 的前缀"作为更新域
:333PipeBarrier<PIPE_V>:332 的下一条 Axpy 依赖它的结果(同管道、同一 dst 行),必须排队列排空;不加则两条 Axpy 对 outRow 同段的读写可能乱序
:336-338TQueSync<PIPE_V, PIPE_S>出口方向同步:本函数最后动作是向量写 buf,而调用方 :585-587 立刻用标量 SetValue 写对角线、:635 又用标量读 Aik。没有这个 V→S 点,标量侧可能读到未落地的行

count=k 为什么恰好覆盖三角递推(原注释 :326-329 的展开证明):固定列 j,它对哪些 k 的 Axpy 可见?答案是 k>j(前缀长度 k 覆盖下标 j 当且仅当 k≥j+1)。结合外层 k≤i−1,第 j 列恰好累加 ∑j<k<iBikAkj —— 与 §7.1 的公式逐项一致。于是"整行的前缀 Axpy 序列"和"每列各自的标量递推"是同一件事的两种看法,前者只需 (i−1)i2 条向量指令即可同时更新该行所有列,而后者要 O(i2) 次标量乘法。这就是"递归修正的向量化"的全部机制:递推的行间依赖不可并行,但每次行更新的列内运算可以打包成一条 Axpy。

代价与后果:i 每加 1 就多 i-1 条 Axpy,每 chunk 共 ∑i=1L−1(i−1)=(L−1)(L−2)2≈1953 条,每条前面一次标量 GetValue、后面一条 PipeBarrier<PIPE_V> ⇒ 同步指令数与计算指令数 1:1。这是后续 PR 合并同步粒度的第一个靶子。


8. WriteAttnTileToGm:输出 tile 的 FP16 落盘 ​

锚点:chunk_gated_delta_rule.h:341-373

cpp
  __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 == 0UB 里的行跨距是整块。注意 avFp32 是按 8(FP32 块)对齐出来的(:130),这里却要求 16(FP16 块)对齐——只有当 vStep_ 本身 16 对齐时才成立,而 host 的 SolveVStep 以 FP16_NUM_PER_BLOCK=16 为步长枚举,恰好保证了这点。若哪天 host 改成 8 步长,这条会静默导致快路径失效、性能掉一档而无报错
gmRowStride % 16 == 0GM 行跨距 NV*dv 整块,否则行间 gap 无法用块数表达
(avFp32 - curV)/16 <= UINT16_MAXsrcGap(UB 内行末跳过的块数)要能塞进 u16
(gmRowStride - curV)/16 <= UINT16_MAXdstGap(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 偏移计算的一致风格)。