Skip to content

07 · 成员变量与缓冲生命周期总表 ​

本篇覆盖 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h 第 917-966 行(50 行的 private: 声明区 + 文件尾)。这段代码本身不含任何逻辑,但它是前三篇所有推断的落点:报告 2/3 里凡是要问"这块 UB 此刻装着什么、还能不能安全覆写""这个标量在设备侧到底有没有被读过",都以本篇两张表为准。结论先行:

  1. 50 行声明里住着 44 个成员:10 个 GlobalTensor(GM 句柄)、1 个 TPipe*、1 个 TQue、1 个 TBuf、11 个 LocalTensor(全部是 tmpBuff 的切片)、20 个标量成员。任务书里说的"18 个标量成员"只数了 uint32_t(:944-961 共 18 行),另有 float scale_(:962)与 uint64_t blockIdx_(:963),合计 20。
  2. 11 个 LocalTensor 的声明注释就是权威尺寸公式,与 :156-194 的实际分配量逐条对得上(§3 表的最后一列给出核对结果);只有 dotProductFp32(:941)的注释是描述性的、没给公式,真实尺寸是 max(stateStrideK_, chunkSize_)(:186/:191)。
  3. 缓冲的生命周期只有三种形态:常驻整 kernel(11 块 UB 切片 + 队列 + GM 句柄)、每 chunk 重生(K 侧 5 块)、每 V-tile 重生(V 侧 4 块)。没有任何一块是"每 token"或"每 head"重生的。
  4. 21 个"值语义"成员(20 个标量 + pipe_)全部在构造函数里被赋确定值(:76-:98),其中 pipe_/vStepAligned_/blockIdx_/load_/usedblk_/avgload_ 六个是"先占位、后真赋值";另外 23 个(10 个 GlobalTensor + 11 个 LocalTensor + tmpBuff + stateOutQueue_)分别由 SetGlobalTensors :113-:124 与 InitLocalBuffers :138/:153/:156-:194 生效。真正的死成员只有 2 个:T_(:945)与 numChunks_(:953)。
  5. UB 尺寸公式在 host(ComputeTmpBuffBytes)与 device(InitLocalBuffers 的 off 累加)里各写了一遍,两者必须一致;本篇 §10 给出等价的三条不变式并逐情形证明,这是后续 PR 改动时最容易踩的雷。

1. 声明区骨架与四类对象 ​

锚点:chunk_gated_delta_rule.h:917-931

cpp
 private:
  GlobalTensor<inType> queryGm_;
  GlobalTensor<inType> keyGm_;
  GlobalTensor<inType> valueGm_;
  GlobalTensor<float> gGm_;
  GlobalTensor<inType> betaGm_;
  GlobalTensor<inType> initStateGm_;
  GlobalTensor<int32_t> actualSeqLengthsGm_;
  GlobalTensor<outType> finalStateGm_;
  GlobalTensor<outType> attnOutGm_;
  GlobalTensor<float> stateWorkspaceGm_;
  TPipe *pipe_;
  TQue<QuePosition::VECOUT, 1> stateOutQueue_;
  TBuf<TPosition::VECCALC> tmpBuff;
  • :917 的 private: 是冗余重申:类体从 :74 起为 public:,第一个 private: 在 :238,其后直到 :964 再没有 public:,所以 :239-:916 的 29 个 helper 早就私有化了。public: 区(:74-:237)共 6 个函数:构造 :75、Init :101、SetGlobalTensors :112、InitLocalBuffers :127、ComputeAvgload :197、Process :209;入口 cpp 只调后二者的 Init/Process(cpp:34、cpp:35),另 4 个属"公开但仅供自身调用"。全类 35 个成员函数,另有 1 个文件级模板函数 CopyToGm(:41-:57)。这个划分说明作者刻意把 kernel 当成一个只有两个动作的对象,而不是一组可复用的算子库。
  • 模板参数 inType/outType(:72)在入口被实例化为 <half, half>(cpp:33)。所以本篇里 sizeof(inType) == 2、sizeof(outType) == 2、float == 4 是实例化事实而非假设;gGm_ 是唯一的 float 输入、stateWorkspaceGm_ 是唯一的 float workspace,其余 GM 都是 FP16。
  • GlobalTensor<T> 机器语义:只存一个 __gm__ T* 的薄句柄(无长度、无所有权),SetGlobalBuffer 装地址、GetValue/SetValue 走标量访存、DataCopy(dst[offset], ...) 里的下标只是元素偏移量。它不做边界检查,所以 §2 的"未 Set 即未定义"是真实风险而不是教条。
  • TPipe *pipe_ 是裸指针,指向 cpp:32 栈上的 TPipe pipe。生命周期由入口函数保证(对象 op 与 pipe 同作用域);这也是为什么 pipe_ 必须在 Init 里赋值而不能在构造里拿到。
  • TQue<QuePosition::VECOUT, 1> 的第二个模板参数是队列深度 1(与 :34 的 BUFFER_NUM = 1 一致,两处独立写死数字 1);VECOUT 表示"向量管道产出"的队列,EnQue/DeQue 携带 V→消费者的排空语义。
  • TBuf<TPosition::VECCALC> tmpBuff 是一块未切分的 UB 裸区(VECCALC = Vector Calculate 空间)。它与 TQue 的区别是:TBuf 没有队列语义(不记录生产/消费状态),所以 :156-194 的 GetWithOffset 只是指针运算。这决定了 11 块切片之间的隔离完全靠人肉 off 记账(§3/§10)。
  • :931 空行属于本块,无内容。

2. 表 A-0:10 个 GlobalTensor ​

SetGlobalTensors(:112-125,签名 :112、体 :113-:124)按 query→key→value→beta→initialState→actualSeqLengths→(gamma)→finalState→attnOut→workspace 的顺序装地址;只有 gGm_(:120)被 :119 的 if (hasGamma_ != 0) 包住,其余 9 个无条件装填。

成员(行)类型逻辑布局(元素)装填处读取处(函数:行)备注
queryGm_:918inType[B,T,Nk,Dk]:113PrepareAttnQueryCache :564(DMA)、ComputeAttnMatrix 慢路径 :602(标量)、ComputeAttnInter :821(标量)行 stride NK_*realK_;K 侧的 LoadChunkKey 不碰它
keyGm_:919inType[B,T,Nk,Dk]:114LoadChunkKey :467(DMA)/:480(标量兜底)、UpdateAndWriteState :867(标量)同一份 K 被读两次(Phase 1 向量化、Step 7 标量)
valueGm_:920inType[B,T,Nv,Dv]:115LoadVBeta :699(DMA)/:725(标量兜底)行 stride NV_*realV_
gGm_:921float[B,T,Nv]:120(受 :119 条件保护)LoadG :255hasGamma_==0 时不装填且 :252 短路,永不访问
betaGm_:922inType[B,T,Nv]:116LoadBeta :259(调用点 :503/:706/:723)每 token 一次标量读
initStateGm_:923inType[B,Nv,Dv,Dk]:117LoadStateTile :778(标量转置读)公共布局是 [Dv,Dk],内部 tile 是 [Dk,vStep],行列相反(06 篇 §7)
actualSeqLengthsGm_:924int32_t[B]:118ComputeAvgload :200、Process :213变长的唯一真值来源,取代 T_
finalStateGm_:925outType[B,Nv,Dv,Dk]:122只写:UpdateAndWriteState :911两个出口之一(逐 d 标量 SetValue)
attnOutGm_:926outType[B,T,Nv,Dv]:123只写:WriteAttnTileToGm :362(2D DataCopy)/:367(逐行 CopyToGm)唯一逐 V-tile 出口;CopyToGm 是文件级模板(:41-:57)
stateWorkspaceGm_:927float[B,Nv,Dk,stateWorkspaceStrideV_]:124(initParams.workspace)读 LoadStateTile :764、写 UpdateAndWriteState :887来自 GetUserWorkspace(cpp:29);唯一 FP32 的 workspace GM(gGm_ 是唯一的 FP32 输入)

三条要点:

  • 同一份数据两种访问路径是"快/慢双路"的代价:K 在 Phase 1 用 DMA 进 UB(:467),Step 7 又从 GM 逐标量重读(:867);Q 在 Phase 3.5 用 DMA 进 UB(:564),Step 5 又从 GM 逐标量重读(:821)。原因不是"忘了缓存",而是缓存它们的 UB 被别人占了(§7 的借尸清单)。
  • 装填顺序无隐含依赖,但 :119 的条件装填使 hasGamma_ 成为唯一一个能改变"句柄是否有效"的标量成员。任何新增的 gGm_ 读取都必须先看 hasGamma_,否则读到未定义基址;目前只有 :255 一处读,且被 :252 挡住 ✓。注意 CGDRInitParams(:59-:70)里 gOptional 排在第 7 个、attnOut/finalState/workspace 在后,而 :122/:123 的赋值顺序是 finalState 再 attnOut —— 结构体字段顺序与成员顺序不一致,靠名字取字段所以无害,但逐字段对照时容易看错。
  • stateWorkspaceGm_ 是唯一"既读又写"的 GM,也是唯一由 GetUserWorkspace 提供的缓冲(:124 装的是 initParams.workspace,而该字段由 cpp:29-:31 填成 GetUserWorkspace(workspaceGM))。host 申请的字节数是 max(B·Nv·dk·ceil8(dv)·4, 32 MiB)(tiling.cpp :286-:289 + kWorkspaceBytes :39),与 kernel 的实际用量 B·Nv·dk·stateWorkspaceStrideV_·4 之间只有单向包含关系(32 MiB 下限是白给的,02 篇)。

3. 表 A-1:11 个 LocalTensor 的尺寸公式与字节对账 ​

锚点:chunk_gated_delta_rule.h:932-943

cpp
  LocalTensor<float> chunkKFp32;        // cs * alignK (original K)
  LocalTensor<float> kCumdecayFp32;     // cs * alignK (k_beta then k_cumdecay)
  LocalTensor<float> decayMaskFp32;     // cs * cs
  LocalTensor<float> chunkVFp32;        // cs * avFp32 (v_beta, then v_new)
  LocalTensor<float> chunkScoresFp32;   // cs * cs (attn matrix)
  LocalTensor<float> stateInFp32;       // dk * avStepAligned (state [DK, vStep])
  LocalTensor<float> chunkAttnOutFp32;  // cs * avFp32 (output)
  LocalTensor<float> gCumsumFp32;       // cs
  LocalTensor<float> deltaFp32;         // temp: max(stateStrideK_, chunkSize_, vStepAligned_)
  LocalTensor<float> dotProductFp32;    // dot-product multiply/reduction scratch
  LocalTensor<float> expGCumFp32;       // precomputed exp(gCumsum) [cs]

全部 11 个都是 float(4 字节/元素),元素数公式来自注释,实际分配量在 :156-194。示例列取 cs=64, dk=128, dv=128, vStep=16 ⇒ alignK_=128, stateStrideK_=128, vStepAligned_=16, stateWorkspaceStrideV_=128(host 会尽量选更大的 vStep,见 §10,示例只用最小值便于对账)。

成员(行)注释公式实际分配(行)字节公式示例字节与注释是否一致
chunkKFp32 :932cs * alignKcs * ak(:156)4·cs·alignK_32 KiB✓(ak 是 :129 的别名)
kCumdecayFp32 :933cs * alignKcs * ak(:159)同上32 KiB✓
decayMaskFp32 :934cs * cscs * cs(:162)4·cs²16 KiB✓
chunkVFp32 :935cs * avFp32cs * avStepAligned(:165)4·cs·vStepAligned_4 KiB✓(avFp32 ≡ vStepAligned_)
chunkScoresFp32 :936cs * csoverlapSize(:170,off 按 :172 前进)或 cs*cs(:174)4·max(cs², sk·av) 或 4·cs²16 KiB注释不完整:重叠分支实际是 max(cs*cs, stateStrideK_*avStepAligned)(:169)
stateInFp32 :937dk * avStepAlignedstateStrideK_ * avStepAligned(:171/:176)4·sk·av8 KiB注释写 dk、代码是 stateStrideK_(= align8(dk)),差一个对齐
chunkAttnOutFp32 :938cs * avFp32cs * avStepAligned(:180)4·cs·vStepAligned_4 KiB✓
gCumsumFp32 :939cscs(:183)4·cs256 B✓
deltaFp32 :940max(sk, cs, av)deltaSize(:188 = :186-:187 两级 max)4·max(sk,cs,av)512 B✓
dotProductFp32 :941无数值公式dotProductSize(:191 = :186)4·max(sk, cs)512 B注释只说用途;真实约束是 count ≤ 分配量
expGCumFp32 :942[cs]cs(:194)4·cs256 B✓

求和(示例配置):

  • 重叠分支(:168-172):32+32+16+4+16(共享)+4+0.25+0.5+0.5+0.25 = 105.5 KiB
  • 非重叠分支(:173-177):stateInFp32 另占 8 KiB ⇒ 113.5 KiB
  • 外加 stateOutQueue_ 的 max(sk·av·2, cs·av·2) = max(4096, 2048) = 4 KiB(:134-138)
  • tmpBuff 的申请量 restUbSize_ 恒为 ubSize - outQueueMax(host :187),即除队列外全部 UB 交给切片;因此"切片之和 ≤ restUbSize_" 由 host 的 SolveVStep 反向挑选 vStep 来保证(§10)。

dotProductFp32 那行的真实约束值得单列,因为它是注释里唯一没有公式、却决定安全性的一条:DotFp32 的 count 全部是 realK_(:580/:592/:608),而 Mul(:290)按 count 写满、WholeReduceSum(:297)就地回写 partials。分配量 max(sk, cs) ≥ sk ≥ dk = realK_ ⇒ 写入永不越界;partials 只落在 [0, 8·fullRepeats)、尾部乘积只落在 [64·fullRepeats, count),两段不相交(8·fullRepeats ≤ count/8 < 64·fullRepeats)⇒ :305 与 :309 的读区互不覆盖;04 篇 §6 另外给了就地归约不越界所需的上界 count ≤ 512,它由 realK_ ≤ sk 与 host 对 dk 的合法域(本篇 §10 不变式③)共同满足。

4. 标量成员声明 ​

锚点:chunk_gated_delta_rule.h:944-964

cpp
  uint32_t B_;
  uint32_t T_;
  uint32_t NK_;
  uint32_t alignK_;
  uint32_t realK_;
  uint32_t NV_;
  uint32_t realV_;
  uint32_t vStep_;
  uint32_t chunkSize_;
  uint32_t numChunks_;
  uint32_t hasGamma_;
  uint32_t restUbSize_;
  uint32_t vStepAligned_;
  uint32_t stateStrideK_;
  uint32_t stateWorkspaceStrideV_;
  uint32_t load_;
  uint32_t usedblk_;
  uint32_t avgload_;
  float scale_;
  uint64_t blockIdx_;
};

表 B:20 个标量成员的写者与读者 ​

"写"列给真值来源(:94-95 的注释解释了为什么全部成员在构造里先给确定值);"读"列给函数与其行号,同一函数多次读只给首末。

成员(行)写(构造 :76-98)写(Init / 其他)值的来源读它的函数:行备注
B_:944:76 td->b—tilingComputeAvgload:199、Process:212batch 外循环上界
T_:945:77 td->t—tiling无死成员;真实长度来自 actualSeqLengthsGm_
NK_:946:78 td->hqk—tilingProcessHead:375、ProcessHeadVTile:389、CanCacheAttnQuery:538、LoadChunkKey:450/:457/:478、PrepareAttnQueryCache:553-554、ComputeAttnMatrix:600、ComputeAttnInter:818、UpdateAndWriteState:863Q/K 侧 GM 行宽与 GQA 分组
alignK_:947:88 ceil(dk,16)*16—派生InitLocalBuffers:129、LoadChunkKey:452/:480、ComputeKBeta:506、CanCacheAttnQuery:540、ComputeAttnMatrix:580/:590/:592/:608、ComputeKCumdecay:624/:631/:638、ComputeVNew:796K 侧唯一行 stride;:452/:540 要求 alignK_==realK_ 才走扁平 Cast
realK_:948:79 td->dk—tiling覆盖 Phase1~7 全部 K 维循环与 GM 偏移(:377/:378/:391/:392、:449-:453/:457/:459/:471/:478/:479、:506、:535/:538-:541/:553/:554/:556、:568/:570、:580/:592/:600/:601/:608、:624/:632/:638、:762/:775/:777/:795/:818/:820、:856/:863/:866/:885、:909/:910)读点最多(45 处);是"逻辑 K 宽"
NV_:949:80 td->hv—tilingComputeAvgload:206、Process:222/:229、ShouldSplitVTiles:239、LoadG/LoadBeta:255/:259、WriteAttnTileToGm:353/:357/:365、ProcessHead*:375-392、LoadVBeta:683/:688/:722V/beta/g 侧行宽;分核判据的一半
realV_:950:81 td->dv—tilingInitLocalBuffers:168、ComputeAvgload:205、Process:221、ShouldSplitVTiles:239、ProcessHead:377 / ProcessHeadVTile:391、ProcessChunkVTile:441、ProcessVTiles:652-653、LoadVBeta:683/:688/:722、WriteAttnTileToGm:353/:357/:365V 维逻辑长度
vStep_:951:86 td->vStep—tilingInitLocalBuffers:130/:168、ComputeAvgload:205、Process:221、ShouldSplitVTiles:239、ProcessChunkVTile:440-441、ProcessVTiles:652-653只被这 6 个函数读;从不参与 V 侧向量指令宽度(宽度用 vStepAligned_)
chunkSize_:952:83 td->chunkSize—tiling9 处 uint32_t cs = chunkSize_(:128/:379/:393/:406/:426/:515/:577/:618/:833)、:448/:536/:537 容量式、:740 直接用作行 stride、经 :577 的 cs 在 :584 当 ld 实参传入 ComputeRecursiveAttn(:315 注释里也提到它)编译期分块=64;scores/decay 的 stride 恒为它,不是 chunkLen
numChunks_:953:84 td->numChunks—tiling无死成员;真实块数在 :382/:396 现算
hasGamma_:954:85 td->hasGamma—tilingSetGlobalTensors:119、LoadG:252决定 gGm_ 是否有效;=0 时全 kernel 门控退化为 0 ⇒ exp(0)=1(04 篇 §3)
restUbSize_:955:87 td->ubRestBytes—tilingInitLocalBuffers:153只被读一次;UB 预算的全部入口
vStepAligned_:956:97 先置 0InitLocalBuffers:131派生 ceil(vStep,8)*8:407/:427(赋给 avFp32)、:448/:536/:537、:752/:764/:778、Step4~7 全部向量宽度(:793/:797/:800/:803/:805/:815/:822/:825/:842/:857/:858/:868/:871/:887/:897/:911)、:940 注释V-tile 物理行宽;因 host 按 16 枚举 ⇒ 恒等于 vStep_
stateStrideK_:957:89 ceil(dk,8)*8—派生:134/:169/:171/:176/:177/:186、:752/:897state tile 的行数(不是 K 侧 stride,K 侧 stride 是 alignK_)
stateWorkspaceStrideV_:958:90 ceil(dv,8)*8—派生:378/:392/:763/:886只服务 workspace 寻址,与 vStepAligned_ 是两个不同宽度(06 篇 §14)
load_:959:91 = 0IsCurrentBlock:242/:245运行时累加IsCurrentBlock:244当前核已领的 token 数(装箱水位)
usedblk_:960:92 = 0IsCurrentBlock:246运行时累加IsCurrentBlock:243已用掉的箱号
avgload_:961:93 = 0ComputeAvgload:206派生量IsCurrentBlock:244每核平均工作量,装箱阈值
scale_:962:82 td->scaleValue—tiling 属性PrepareAttnQueryCache:570、ComputeAttnMatrix 慢路径 :602、ComputeAttnInter:821唯一的 float 成员;Q 侧缩放
blockIdx_:963:98 先置 0Init:103 GetBlockIdx()硬件Init:104、IsCurrentBlock:243唯一的 uint64_t 成员;身份,不是数据

配对 pipe_:928(:96 先 nullptr、Init:107 真赋值)与 tmpBuff:930、stateOutQueue_:929(无标量赋值,靠 :138/:153 的 InitBuffer 生效)构成"对象类成员"的三条初始化路径。

5. 文件尾 ​

锚点:chunk_gated_delta_rule.h:965-966

cpp

#endif  // CHUNK_GATED_DELTA_RULE_KERNEL_H_
  • :965 空行,:966 头文件保护 #endif,与 :17-18 的 #ifndef/#define 成对。#endif 之后的注释 // CHUNK_GATED_DELTA_RULE_KERNEL_H_ 是 header-guard 配对注释(Google C++ 风格惯例;本仓确实过 cpplint,:20-:21 带 NOLINT(build/include_subdir) 标记)。

6. 表 C:UB 缓冲生命周期(出生 → 复用 → 死亡) ​

"周期"列说明重生频率。"借尸"= 该缓冲在某阶段被当作临时 staging 使用,规则只有一个:只有已死的缓冲才能被借(§7)。

缓冲出生(首次写)中途复用(身份变化)死亡(最后一次被读/落 GM)周期
chunkKFp32LoadChunkKey :471(Cast 整块)或 :480(标量兜底)ComputeKCumdecay :631 Duplicate + :638 Axpy 就地变身为 K_cdComputeVNew :796 的标量读是最后一次使用每 chunk 重生
kCumdecayFp32ComputeKBeta :506(K·β)ComputeKCumdecay :624 再乘 e^{G_i} 变身 k_cumdecayComputeKCumdecay :638 作为 Axpy 源,Phase 5 结束即死每 chunk 重生
decayMaskFp32PrepareDecayAndExp :529(逐行 Exp,完整矩阵)ComputeAttnMatrix :592-594(cache 路径)/ :608-610(慢路径)把下三角原地覆写成 M=(Q⋅k)⋅scale⋅decayAccumOutput :838 标量读(Step 6)每 chunk 重生
chunkVFp32借尸 ×2:LoadChunkKey :456(K 的 FP16 staging)、PrepareAttnQueryCache :552(Q 的 FP16 staging)Step 1 :676 清零 + :709/:711 变 v_beta;Step 4 :792/:793-:805 就地变 v_newStep 7 :865 取 vRow、:871 作为外积 Axpy 的源每 V-tile 重生(借尸部分每 chunk)
chunkScoresFp32ComputeAttnMatrix :581 逐点写 B,ComputeRecursiveAttn :584 就地求逆成 A,:586 置对角 1—非重叠分支最后一次读 = ComputeValueNew :740;重叠分支下真正的死因是 LoadStateTile :753 的 Duplicate(同址覆写,见 §7)每 chunk 重生
stateInFp32LoadStateTile :753 清零 + :764(workspace DMA)或 :778(初值标量转置)Step 7 :858 就地衰减、:871 就地加外积 ⇒ 同一块里从 Sc−1 变成 Sc非最后一块::887 DataCopy 写 workspace;最后一块::898 触碰 + :901 Cast 进队列,再由 :911 标量写 finalStateGm_每 V-tile 重生
chunkAttnOutFp32PrepareAttnQueryCache :568 Cast + :570 Muls(Q·scale 缓存;Phase 4 的 :590/:592 读它)LoadVBeta :687(V 的 FP16 staging,杀死 Q 缓存)→ Step 2 :734/:743(value_new)→ Step 4 :805 被 Add 当右操作数(合成 v_new)→ Step 5 :814/:815/:825(attn_inter)→ Step 6 :836/:842(output)WriteAttnTileToGm :348 的 Cast每 V-tile 重生(身份 5 变)
gCumsumFp32ComputeGCumsum :490(标量 SetValue,S 管道):516 Muls 1.0f(栅栏式触碰);:524/:527 标量读 + 作 Sub 源UpdateAndWriteState :864 标量读每 chunk 重生
deltaFp32三种互不相干的身份:ComputeRecursiveAttn :320(A 的原始行副本)、PrepareDecayAndExp :525/:527(decay 差分)、ComputeAttnMatrix :602(慢路径的一行 Q·scale)同一阶段内每行重写ComputeAttnMatrix :608(DotFp32 的左操作数,最后一次)无固定周期(纯 scratch)
dotProductFp32DotFp32 :290 Mul + :297 WholeReduceSum 就地写 partials—DotFp32 内 :305/:309 标量读,函数返回即死每次点积重生(全 kernel 最短暂命)
expGCumFp32PrepareDecayAndExp :518 Exp(向量)—UpdateAndWriteState :853每 chunk 重生
stateOutQueue_(FP16 staging)AllocTensor(:347 或 :900)两处共用:attn 输出 tile、final state tileFreeTensor(:371 / :914)每 tile / 每 head

生命周期不变式(报告 2/3 直接引用这四条):

  • I1(写入者唯一):任何时刻一块 UB 只有一个管道的写者;stateInFp32 与 chunkScoresFp32 的同址是唯一破坏"读者/写者天然分离"的地方(:168-172)。
  • I2(死才可借):三处借尸(:456 K、:552 Q、:687 V)都发生在"上一身份的最后一次读"之后。其中 :687 是唯一跨函数杀死别处缓存的(Q 缓存死于 Step 1),也是 Step 5 必须回 GM 取 Q 的唯一原因。
  • I3(就地变身必须留读者):chunkKFp32(K→K_cd)、decayMaskFp32(decay→M)、chunkVFp32(v_beta→v_new)、stateInFp32(Sc−1→Sc)四处就地覆写,每处都要求前一身份已无未来读者。判定动作:把该缓冲的全部读点列出来,逐个问"它读的是变身前还是变身后"——:838 读的是变身后的 M ✓;而 :642-:644 的 outputSync 之所以不可省,是因为它保证 Phase 5 对 chunkKFp32 的向量写在 Phase 6 的标量读(:796)之前完成。
  • I4(队列必须闭合):stateOutQueue_ 深度 1 ⇒ AllocTensor → EnQue → DeQue → FreeTensor 四步不可省,缺 FreeTensor 会让下一次 AllocTensor 失去排空语义(06 篇 §13)。

7. 借尸与别名清单(全 6 处) ​

借出者借入者行安全性依据副作用
chunkVFp32K 的 FP16 staging:456Step 1 之前无人需要 v_betaV 加载多一次覆写(无害)
chunkVFp32Q 的 FP16 staging:552同上,且 :456 的 K 已被 :471 Cast 消费—
chunkAttnOutFp32Q·scale 缓存(正式身份,非借):568/:570Phase 4 用它做点积—
chunkAttnOutFp32V 的 FP16 staging:687Phase 4 结束时已死(cache 路径最后一次读是 :592;慢路径不读它)Q 缓存被杀 ⇒ Step 5 :821 回 GM 逐标量读
chunkScoresFp32与 stateInFp32 同址:170/:171条件是 vStep_>=realV_ || ShouldSplitVTiles(),两种情形下 A 都最多再被读一轮Step 3 的 :753 清零直接抹掉 A
deltaFp32三种 scratch 身份:320/:525/:602时间轴不重叠:Phase 3(:525-:529)→ Phase 4 递归(:320/:331)→ Phase 4 慢路径(:602/:608),每个身份用前必重填顺序敏感:若把慢路径的 :602 提到 :320 之前,同一块会被两种用途交叉覆写

8. 初始化时序与"三层写者" ​

阶段位置写什么为什么必须在这一层
构造:75-:9912 个 tiling 直读 + 3 个派生 stride + 6 个占位(load_/usedblk_/avgload_/pipe_/vStepAligned_/blockIdx_):94-95 注释:构造早于 Init/InitLocalBuffers,先把所有成员变成确定值,避免任何"未初始化读"
Init:101-:110blockIdx_:103、pipe_:107,并调 SetGlobalTensors + InitLocalBuffers只有这里能拿到 GetBlockIdx() 与调用者栈上的 TPipe
InitLocalBuffers:127-:195:128-:131 先算 cs/ak/avStepAligned 三个局部别名并回写 vStepAligned_:131;队列 InitBuffer:138、tmpBuff InitBuffer:153、11 块切片 :156-:194切片只吃 :128-:130 的三个局部量,不依赖成员读顺序;vStepAligned_ 的回写必须在切片之前,因为它同时是 :168 重叠判定的输入
ComputeAvgload:197-:207avgload_:206唯一需要读 GM(actualSeqLengthsGm_)才能算出的成员,因此必须排在 SetGlobalTensors 之后

四条观察:

  • "先占位后真赋值"共 6 个成员(:91-:93/:96-:98),这不是风格洁癖:ShouldSplitVTiles()(:239)被 InitLocalBuffers(:168)和 ComputeAvgload(:205)调用,而 IsCurrentBlock 读 blockIdx_/load_/avgload_;一旦哪天有人把 ComputeAvgload 提到 Init 之前,占位值会让它退化而不是读到垃圾。
  • restUbSize_ 只被 :153 读一次,且读之前不做任何校验:InitBuffer(tmpBuff, restUbSize_) 直接吃 tiling 里的数。若 host/device 的 vStep 或重叠判定不一致(§10),后果是切片之和超出 tmpBuff 而没有任何断言会响。:194 之后连 off 的终值都没人和 restUbSize_ 比对——这是本 kernel 最实的一处结构性缺口。
  • Init 的 :104-106 blockIdx_ >= blockDim 早退在当前配置下永不触发(host SetBlockDim(blockDim),:275-279 ⇒ GetBlockNum() == blockDim),但入口 cpp:35 无条件调用 op.Process():真触发的话 Process 会带着 pipe_ == nullptr 与未装填的 GM 句柄跑下去,ComputeAvgload 的第一次 GetValue 即崩。安全来自"launch 维 == blockDim"这条隐式约定,不是来自这个 guard。
  • T_/numChunks_ 两个死成员(:945/:953)只赋值不读取。它们的价值是与 host tiling 结构体逐字段镜像,方便 CGDR_DEBUG_LOG 对账;代价是读者会误以为 kernel 按 T_ 算长度。真实语义是:序列长度取自 actualSeqLengthsGm_(变长、逐 batch),块数在 :382/:396 现算。

9. 成员与常量、模板实例的关系(声明区之外的三条对账) ​

  • 声明区用到的 inType/outType/float 三个类型,与文件头 :34-39 的五个常量共同决定所有尺寸:BUFFER_NUM=1、FP16_NUM_PER_BLOCK=16、FP32_NUM_PER_BLOCK=8、BLOCK_BYTES=32、kExpTaylorTerms=12。其中只有前四个参与 §3 的尺寸公式,kExpTaylorTerms 只影响精度与标量耗时(04 篇 §5)。
  • stateStrideK_ 用 FP32_NUM_PER_BLOCK(8)而 alignK_ 用 FP16_NUM_PER_BLOCK(16):因为前者索引 FP32 行(32B = 8 个 float)、后者索引 FP16 行(32B = 16 个 half)。vStepAligned_/stateWorkspaceStrideV_ 都按 8(服务 FP32 tile)。这一处不对称是全文件最容易看错的一行(:88 vs :89-:90)。
  • 声明区没有 static、没有全局可变状态:所有跨核可见的东西都在 GM(含 workspace)。所以"分核正确性"只需论证GM 区间不相交(06 篇 §12 的 workspace 写域分析),不必考虑 UB 共享。

10. 与 host 的对账:两套尺寸公式、三条不变式 ​

同一个 tmpBuff 用量被写了两遍:host 的 ComputeTmpBuffBytes(tiling.cpp :128-:148)与 device 的 off 累加(:156-194)。要让 restUbSize_ 永远够用,需要下面三条同时成立,本篇逐条核过:

  • 不变式①(切片集合相同):host 逐项列出 2×cs×alignK + cs² + cs×av + (scores/state) + cs×av + cs + delta + dot + cs,与 :156-194 的 11 项一一对应,顺序相同 ⇒ 字节数逐位相等。✓
  • 不变式②(重叠判定相同):host 用 overlapScoresState = (vs >= dv) || allowScoresStateOverlap,其中 allowScoresStateOverlap = (dims.hv < aivNum)(:254);device 用 vStep_ >= realV_ || ShouldSplitVTiles()(:168),后者 = dv > vStep && GetBlockNum() > hv。四情形枚举:
    • vStep ≥ dv:两侧都重叠 ✓
    • vStep < dv ∧ hv < aivNum:blockDim = min(hv·vTileCount, aivNum) > hv(因 vTileCount > 1 且 aivNum > hv)⇒ device 也重叠 ✓
    • vStep < dv ∧ hv ≥ aivNum:blockDim = min(hv, aivNum) = aivNum ≤ hv ⇒ device 不重叠,host 也不重叠 ✓
    • vTileCount == 1 归入第一条 ✓ ⇒ 恒一致,但一致性依赖 SetBlockDim 的 blockDim = min(workItems, aivNum) 规则(:275)。改动 blockDim 计算方式(例如恒用 aivNum)会直接造成 device 比 host 多要 8 KiB ⇒ UB 静默越界。
  • 不变式③(vStep 选得下):SolveVStep 从 maxVStep = CeilAlign(dv, 16) 起、按 16 递减枚举到 16(tiling.cpp :167-:178),取第一个满足 tmpBuff + outQueue ≤ ubSize 的值 ⇒ 两个含义:① vStep_ 恒为 16 的倍数 ⇒ vStepAligned_ == vStep_(声明注释里两个名字是同一个数);② vStep_ 可以大于 dv(当 dv 不是 16 倍数时 CeilAlign(dv,16) > dv),这正是 :168 要写 vStep_ >= realV_ 而不是 == 的原因。顺带一条:host :182-:184 的 if (vStepVal > dv) vStepVal = CeilAlign(dv, 16); 在数学上是恒等赋值(枚举起点就是 CeilAlign(dv,16),比它大的候选不存在),所以这段代码只是把 :159 注释里 "<= dv" 的说法圆回来,没有实际效果。
  • 附带一条 host 侧事实:kExpTaylorTerms、alignK_ 等派生量不进 tiling,device 自己从 dk/dv 重算(:88-90),host 也算一遍(tiling.cpp 里 alignK)⇒ 派生公式也是"两份实现",改 Ceil 的基数(8/16)必须同时改两处,否则 §3 表的"示例字节"就会与 host 的预算脱钩。

11. 交给后续报告的五条硬结论 ​

  1. UB 是唯一稀缺资源:restUbSize_(:955)被 :153 一次性用光,11 块切片无余量校验;因此"要不要缓存 Q/K"不是设计偏好,而是 max(cs², sk·av) 与 cs·vStepAligned_ 之间的算术(§3 求和)。
  2. 性能与 UB 的直接因果:Q/K 二次回 GM 标量读(:821/:867)造成的 1.65 万次/(chunk,tile) 标量访存,其根源就是 §3 表里那 16 KiB 的 scores 与 8 KiB 的 state 抢走了位置。任何"加大 vStep""合并 scores/state"的改动都必须同时报 host ComputeTmpBuffBytes 的新值。
  3. vStepAligned_ 与 stateWorkspaceStrideV_ 不可混用(:956-958),它们分别管 tile 行 stride 与 workspace 行 stride;06 篇 §14 的 stride 总表是它们的全部用场。
  4. 删同步的安全边界:读列里出现"标量读 UB"(GetValue)的位置就是必需的 V→S 同步的位置——实测清单是 17 处标量读 UB(:305/:309、:320、:331、:524、:581、:593、:609、:621、:635、:740、:796、:819、:838、:853、:864、:911)+ 10 处标量写 UB(:320、:480、:490、:581、:586、:594、:602、:610、:726、:778),去重后 25 行(:320/:581 同行读写)。这些行是不可删的 S↔V 同步点;不落在清单里的 TQueSync/PipeBarrier 都是保守写法,可批量删(05 篇 §13、06 篇 §16 的"保守"标签由此而来)。另有 10 行标量读 GM(:200/:213/:255/:259/:480/:602/:725/:778/:821/:867),它们只要求 S 管道自身的顺序性,不需要与 V 同步。
  5. 两个死成员、一个永不触发的 guard、一处无校验的 InitBuffer:T_(:945)、numChunks_(:953)、Init:104-106、restUbSize_:153。它们是后续 review 的第一个检查清单。