07 · 成员变量与缓冲生命周期总表
本篇覆盖 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h 第 917-966 行(50 行的 private: 声明区 + 文件尾)。这段代码本身不含任何逻辑,但它是前三篇所有推断的落点:报告 2/3 里凡是要问"这块 UB 此刻装着什么、还能不能安全覆写""这个标量在设备侧到底有没有被读过",都以本篇两张表为准。结论先行:
- 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。 - 11 个
LocalTensor的声明注释就是权威尺寸公式,与 :156-194 的实际分配量逐条对得上(§3 表的最后一列给出核对结果);只有dotProductFp32(:941)的注释是描述性的、没给公式,真实尺寸是max(stateStrideK_, chunkSize_)(:186/:191)。 - 缓冲的生命周期只有三种形态:常驻整 kernel(11 块 UB 切片 + 队列 + GM 句柄)、每 chunk 重生(K 侧 5 块)、每 V-tile 重生(V 侧 4 块)。没有任何一块是"每 token"或"每 head"重生的。
- 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)。 - UB 尺寸公式在 host(
ComputeTmpBuffBytes)与 device(InitLocalBuffers的off累加)里各写了一遍,两者必须一致;本篇 §10 给出等价的三条不变式并逐情形证明,这是后续 PR 改动时最容易踩的雷。
1. 声明区骨架与四类对象
锚点:chunk_gated_delta_rule.h:917-931
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_是唯一的floatworkspace,其余 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_:918 | inType | [B,T,Nk,Dk] | :113 | PrepareAttnQueryCache :564(DMA)、ComputeAttnMatrix 慢路径 :602(标量)、ComputeAttnInter :821(标量) | 行 stride NK_*realK_;K 侧的 LoadChunkKey 不碰它 |
keyGm_:919 | inType | [B,T,Nk,Dk] | :114 | LoadChunkKey :467(DMA)/:480(标量兜底)、UpdateAndWriteState :867(标量) | 同一份 K 被读两次(Phase 1 向量化、Step 7 标量) |
valueGm_:920 | inType | [B,T,Nv,Dv] | :115 | LoadVBeta :699(DMA)/:725(标量兜底) | 行 stride NV_*realV_ |
gGm_:921 | float | [B,T,Nv] | :120(受 :119 条件保护) | LoadG :255 | hasGamma_==0 时不装填且 :252 短路,永不访问 |
betaGm_:922 | inType | [B,T,Nv] | :116 | LoadBeta :259(调用点 :503/:706/:723) | 每 token 一次标量读 |
initStateGm_:923 | inType | [B,Nv,Dv,Dk] | :117 | LoadStateTile :778(标量转置读) | 公共布局是 [Dv,Dk],内部 tile 是 [Dk,vStep],行列相反(06 篇 §7) |
actualSeqLengthsGm_:924 | int32_t | [B] | :118 | ComputeAvgload :200、Process :213 | 变长的唯一真值来源,取代 T_ |
finalStateGm_:925 | outType | [B,Nv,Dv,Dk] | :122 | 只写:UpdateAndWriteState :911 | 两个出口之一(逐 d 标量 SetValue) |
attnOutGm_:926 | outType | [B,T,Nv,Dv] | :123 | 只写:WriteAttnTileToGm :362(2D DataCopy)/:367(逐行 CopyToGm) | 唯一逐 V-tile 出口;CopyToGm 是文件级模板(:41-:57) |
stateWorkspaceGm_:927 | float | [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
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 :932 | cs * alignK | cs * ak(:156) | 4·cs·alignK_ | 32 KiB | ✓(ak 是 :129 的别名) |
kCumdecayFp32 :933 | cs * alignK | cs * ak(:159) | 同上 | 32 KiB | ✓ |
decayMaskFp32 :934 | cs * cs | cs * cs(:162) | 4·cs² | 16 KiB | ✓ |
chunkVFp32 :935 | cs * avFp32 | cs * avStepAligned(:165) | 4·cs·vStepAligned_ | 4 KiB | ✓(avFp32 ≡ vStepAligned_) |
chunkScoresFp32 :936 | cs * cs | overlapSize(:170,off 按 :172 前进)或 cs*cs(:174) | 4·max(cs², sk·av) 或 4·cs² | 16 KiB | 注释不完整:重叠分支实际是 max(cs*cs, stateStrideK_*avStepAligned)(:169) |
stateInFp32 :937 | dk * avStepAligned | stateStrideK_ * avStepAligned(:171/:176) | 4·sk·av | 8 KiB | 注释写 dk、代码是 stateStrideK_(= align8(dk)),差一个对齐 |
chunkAttnOutFp32 :938 | cs * avFp32 | cs * avStepAligned(:180) | 4·cs·vStepAligned_ | 4 KiB | ✓ |
gCumsumFp32 :939 | cs | cs(:183) | 4·cs | 256 B | ✓ |
deltaFp32 :940 | max(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·cs | 256 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
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 | — | tiling | ComputeAvgload:199、Process:212 | batch 外循环上界 |
T_:945 | :77 td->t | — | tiling | 无 | 死成员;真实长度来自 actualSeqLengthsGm_ |
NK_:946 | :78 td->hqk | — | tiling | ProcessHead:375、ProcessHeadVTile:389、CanCacheAttnQuery:538、LoadChunkKey:450/:457/:478、PrepareAttnQueryCache:553-554、ComputeAttnMatrix:600、ComputeAttnInter:818、UpdateAndWriteState:863 | Q/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:796 | K 侧唯一行 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 | — | tiling | ComputeAvgload:206、Process:222/:229、ShouldSplitVTiles:239、LoadG/LoadBeta:255/:259、WriteAttnTileToGm:353/:357/:365、ProcessHead*:375-392、LoadVBeta:683/:688/:722 | V/beta/g 侧行宽;分核判据的一半 |
realV_:950 | :81 td->dv | — | tiling | InitLocalBuffers:168、ComputeAvgload:205、Process:221、ShouldSplitVTiles:239、ProcessHead:377 / ProcessHeadVTile:391、ProcessChunkVTile:441、ProcessVTiles:652-653、LoadVBeta:683/:688/:722、WriteAttnTileToGm:353/:357/:365 | V 维逻辑长度 |
vStep_:951 | :86 td->vStep | — | tiling | InitLocalBuffers:130/:168、ComputeAvgload:205、Process:221、ShouldSplitVTiles:239、ProcessChunkVTile:440-441、ProcessVTiles:652-653 | 只被这 6 个函数读;从不参与 V 侧向量指令宽度(宽度用 vStepAligned_) |
chunkSize_:952 | :83 td->chunkSize | — | tiling | 9 处 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 | — | tiling | SetGlobalTensors:119、LoadG:252 | 决定 gGm_ 是否有效;=0 时全 kernel 门控退化为 0 ⇒ exp(0)=1(04 篇 §3) |
restUbSize_:955 | :87 td->ubRestBytes | — | tiling | InitLocalBuffers:153 | 只被读一次;UB 预算的全部入口 |
vStepAligned_:956 | :97 先置 0 | InitLocalBuffers: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/:897 | state tile 的行数(不是 K 侧 stride,K 侧 stride 是 alignK_) |
stateWorkspaceStrideV_:958 | :90 ceil(dv,8)*8 | — | 派生 | :378/:392/:763/:886 | 只服务 workspace 寻址,与 vStepAligned_ 是两个不同宽度(06 篇 §14) |
load_:959 | :91 = 0 | IsCurrentBlock:242/:245 | 运行时累加 | IsCurrentBlock:244 | 当前核已领的 token 数(装箱水位) |
usedblk_:960 | :92 = 0 | IsCurrentBlock:246 | 运行时累加 | IsCurrentBlock:243 | 已用掉的箱号 |
avgload_:961 | :93 = 0 | ComputeAvgload:206 | 派生量 | IsCurrentBlock:244 | 每核平均工作量,装箱阈值 |
scale_:962 | :82 td->scaleValue | — | tiling 属性 | PrepareAttnQueryCache:570、ComputeAttnMatrix 慢路径 :602、ComputeAttnInter:821 | 唯一的 float 成员;Q 侧缩放 |
blockIdx_:963 | :98 先置 0 | Init: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
#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) | 周期 |
|---|---|---|---|---|
chunkKFp32 | LoadChunkKey :471(Cast 整块)或 :480(标量兜底) | ComputeKCumdecay :631 Duplicate + :638 Axpy 就地变身为 K_cd | ComputeVNew :796 的标量读是最后一次使用 | 每 chunk 重生 |
kCumdecayFp32 | ComputeKBeta :506(K·β) | ComputeKCumdecay :624 再乘 e^{G_i} 变身 k_cumdecay | ComputeKCumdecay :638 作为 Axpy 源,Phase 5 结束即死 | 每 chunk 重生 |
decayMaskFp32 | PrepareDecayAndExp :529(逐行 Exp,完整矩阵) | ComputeAttnMatrix :592-594(cache 路径)/ :608-610(慢路径)把下三角原地覆写成 | AccumOutput :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_new | Step 7 :865 取 vRow、:871 作为外积 Axpy 的源 | 每 V-tile 重生(借尸部分每 chunk) |
chunkScoresFp32 | ComputeAttnMatrix :581 逐点写 B,ComputeRecursiveAttn :584 就地求逆成 A,:586 置对角 1 | — | 非重叠分支最后一次读 = ComputeValueNew :740;重叠分支下真正的死因是 LoadStateTile :753 的 Duplicate(同址覆写,见 §7) | 每 chunk 重生 |
stateInFp32 | LoadStateTile :753 清零 + :764(workspace DMA)或 :778(初值标量转置) | Step 7 :858 就地衰减、:871 就地加外积 ⇒ 同一块里从 | 非最后一块::887 DataCopy 写 workspace;最后一块::898 触碰 + :901 Cast 进队列,再由 :911 标量写 finalStateGm_ | 每 V-tile 重生 |
chunkAttnOutFp32 | PrepareAttnQueryCache :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 变) |
gCumsumFp32 | ComputeGCumsum :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) |
dotProductFp32 | DotFp32 :290 Mul + :297 WholeReduceSum 就地写 partials | — | DotFp32 内 :305/:309 标量读,函数返回即死 | 每次点积重生(全 kernel 最短暂命) |
expGCumFp32 | PrepareDecayAndExp :518 Exp(向量) | — | UpdateAndWriteState :853 | 每 chunk 重生 |
stateOutQueue_(FP16 staging) | AllocTensor(:347 或 :900) | 两处共用:attn 输出 tile、final state tile | FreeTensor(: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()四处就地覆写,每处都要求前一身份已无未来读者。判定动作:把该缓冲的全部读点列出来,逐个问"它读的是变身前还是变身后"——:838 读的是变身后的 ✓;而 :642-:644 的 outputSync之所以不可省,是因为它保证 Phase 5 对chunkKFp32的向量写在 Phase 6 的标量读(:796)之前完成。 - I4(队列必须闭合):
stateOutQueue_深度 1 ⇒AllocTensor → EnQue → DeQue → FreeTensor四步不可省,缺FreeTensor会让下一次AllocTensor失去排空语义(06 篇 §13)。
7. 借尸与别名清单(全 6 处)
| 借出者 | 借入者 | 行 | 安全性依据 | 副作用 |
|---|---|---|---|---|
chunkVFp32 | K 的 FP16 staging | :456 | Step 1 之前无人需要 v_beta | V 加载多一次覆写(无害) |
chunkVFp32 | Q 的 FP16 staging | :552 | 同上,且 :456 的 K 已被 :471 Cast 消费 | — |
chunkAttnOutFp32 | Q·scale 缓存(正式身份,非借) | :568/:570 | Phase 4 用它做点积 | — |
chunkAttnOutFp32 | V 的 FP16 staging | :687 | Phase 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-:99 | 12 个 tiling 直读 + 3 个派生 stride + 6 个占位(load_/usedblk_/avgload_/pipe_/vStepAligned_/blockIdx_) | :94-95 注释:构造早于 Init/InitLocalBuffers,先把所有成员变成确定值,避免任何"未初始化读" |
Init | :101-:110 | blockIdx_: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-:207 | avgload_: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-106blockIdx_ >= blockDim早退在当前配置下永不触发(hostSetBlockDim(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. 交给后续报告的五条硬结论
- UB 是唯一稀缺资源:
restUbSize_(:955)被 :153 一次性用光,11 块切片无余量校验;因此"要不要缓存 Q/K"不是设计偏好,而是max(cs², sk·av)与cs·vStepAligned_之间的算术(§3 求和)。 - 性能与 UB 的直接因果:Q/K 二次回 GM 标量读(:821/:867)造成的 1.65 万次/
(chunk,tile)标量访存,其根源就是 §3 表里那 16 KiB 的 scores 与 8 KiB 的 state 抢走了位置。任何"加大 vStep""合并 scores/state"的改动都必须同时报 hostComputeTmpBuffBytes的新值。 vStepAligned_与stateWorkspaceStrideV_不可混用(:956-958),它们分别管 tile 行 stride 与 workspace 行 stride;06 篇 §14 的 stride 总表是它们的全部用场。- 删同步的安全边界:读列里出现"标量读 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 同步。 - 两个死成员、一个永不触发的 guard、一处无校验的
InitBuffer:T_(:945)、numChunks_(:953)、Init:104-106、restUbSize_:153。它们是后续 review 的第一个检查清单。