01 · PR !1158:beta 缓冲重叠的字节级推演
结论先行
- !1158 在
op_kernel/chunk_gated_delta_rule.h里的全部改动是 1 个 hunk、删 1 行、增 2 行(其中 1 行是注释), 净行数 +1(2461 → 2462)。实查命令与输出见 §1。 - 全树对比(
diff -rq)确认:!1158 与 !1174 都只落在op_kernel/chunk_gated_delta_rule.h一个文件, op_host 的 4 个文件、op_kernel/*.cpp、chunk_gated_delta_rule_tiling_data.h逐字节未变。 - 涉事代码是同一块 UB 内存上的 fp16→fp32 原地加宽 Cast。按 32B repeat 推进模型推演, 写窗口(32B)恒为读窗口(16B)的 2 倍且前向延伸,因此第 2 个 repeat 起读到的全部是上一个 repeat 的输出。 幸存集合 = 扁平平铺的前 8 个元素 =
8 = BLOCK_BYTES / sizeof(float) = 32 / 4。 - 由此推出一个比既有文档更强的结论:损坏集合是
{(t,h) : t·NV_ + h ≥ 8}, 不仅 "head 序号 ≥8 的列",所有t ≥ 1行的 beta 同样落在损坏区(因为 slab 是[chunkLen][NV_]行主序)。 docs 里"NV ≤ 8 的窄 shape 不受影响"这一条与字节算术不符,见 §8。 - 修复把 DMA 目的从
chunkVFp32换成chunkAttnOutFp32,两者容量完全相等(都是cs * avStepAligned个 fp32), 所以原有的容量护栏slabElements <= slabCapacity一字不改即仍然成立; 生命周期上它夹在"上一 chunk 的WriteAttnTileToGm末次读"与"本 chunk 的PrepareAttnQueryCache下次写"之间, 证明见 §5。 - 快照里没有测试文件(每个
src/<commit>/只有 7 个文件),所以"!1158 新增 87 行测试"无法从src/复核, 只能引用 docs 与用户提供的二手记录,本文全程标注出处。
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:812-865
1. diff 实测基线(逐字节)
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:812-864;88d8b965/op_kernel/chunk_gated_delta_rule.h:812-865
wc -l src/*/op_kernel/chunk_gated_delta_rule.h 实查(本机,工作区当前状态):
966 src/00e39ab5/op_kernel/chunk_gated_delta_rule.h
2474 src/048d0427/op_kernel/chunk_gated_delta_rule.h
2462 src/88d8b965/op_kernel/chunk_gated_delta_rule.h
2480 src/a9c66ddb/op_kernel/chunk_gated_delta_rule.h
2461 src/d0906c3e/op_kernel/chunk_gated_delta_rule.hdiff src/d0906c3e/op_kernel/chunk_gated_delta_rule.h src/88d8b965/op_kernel/chunk_gated_delta_rule.h 的全部输出, 一行不差:
819c819,820
< LocalTensor<inType> betaLocal = chunkVFp32.template ReinterpretCast<inType>();
---
> // Keep the FP16 source separate because an in-place widening Cast overwrites unread beta values.
> LocalTensor<inType> betaLocal = chunkAttnOutFp32.template ReinterpretCast<inType>();规模量化(实查 grep -c):hunk 数 = 1;^[<>] 行数 = 3(删 1 + 增 2)。
这一处改动落在 LoadChunkCoefficients(d0906c3e/88d8b965 均起于 :812)内部, canUseBetaDma 分支的第一行。删除行在 d0906c3e 是 :819,替换后的两行在 88d8b965 是 :819-820。
改动是否只落在 kernel.h —— diff -rq 全树实查:
Files src/d0906c3e/op_kernel/chunk_gated_delta_rule.h and src/88d8b965/op_kernel/chunk_gated_delta_rule.h differ(这是 diff -rq src/d0906c3e src/88d8b965 的唯一一行输出。)另外单独对 op_host/、op_kernel/ 跑 diff -rq 也各自只在 kernel.h 上报 differ。 每个快照的文件清单实查为 7 个:op_host/{_def.cpp,_infershape.cpp,_tiling.cpp,_tiling.h}、 op_kernel/{chunk_gated_delta_rule.cpp,chunk_gated_delta_rule.h,chunk_gated_delta_rule_tiling_data.h}。 没有任何测试文件,见 §6。
2. LoadChunkCoefficients 完整上下文
修复前的整段(含 bug 行)逐字在 src/d0906c3e/ 的 :812-837;它与修复后文本的唯一差异就是 §1 粘贴的那一个 hunk: d0906c3e 的 :819 单行 LocalTensor<inType> betaLocal = chunkVFp32.template ReinterpretCast<inType>(); (6 空格缩进,即 §1 diff 的 < 行)被 88d8b965 的 :819-820 两行——新增注释 + 换掉源缓冲——取代, 其余 25 行逐字节相同、行号整体 +1(d0906c3e :812-818 ↔ 88d8b965 :812-818,d0906c3e :820-837 ↔ 88d8b965 :821-838)。
下面整段取修复后终态,与 88d8b965/op_kernel/chunk_gated_delta_rule.h:812-838 逐字节相同(beta 分支的结尾):
__aicore__ inline void LoadChunkCoefficients(int32_t t_start, uint64_t head_i, uint32_t chunkLen) {
uint64_t slabElements = static_cast<uint64_t>(chunkLen) * NV_;
uint64_t slabCapacity = static_cast<uint64_t>(chunkSize_) * vStepAligned_;
LocalTensor<uint32_t> coefficientOffsets = dotProductFp32.template ReinterpretCast<uint32_t>();
bool offsetsReady = false;
bool canUseBetaDma = slabElements <= slabCapacity && slabElements * sizeof(inType) % BLOCK_BYTES == 0;
if (likely(canUseBetaDma)) {
// Keep the FP16 source separate because an in-place widening Cast overwrites unread beta values.
LocalTensor<inType> betaLocal = chunkAttnOutFp32.template ReinterpretCast<inType>();
event_t vectorToMte2 = static_cast<event_t>(pipe_->FetchEventID(HardEvent::V_MTE2));
SetFlag<HardEvent::V_MTE2>(vectorToMte2);
WaitFlag<HardEvent::V_MTE2>(vectorToMte2);
DataCopy(betaLocal, betaGm_[static_cast<uint64_t>(t_start) * NV_], slabElements);
event_t mte2ToVector = static_cast<event_t>(pipe_->FetchEventID(HardEvent::MTE2_V));
SetFlag<HardEvent::MTE2_V>(mte2ToVector);
WaitFlag<HardEvent::MTE2_V>(mte2ToVector);
Cast(chunkVFp32, betaLocal, RoundMode::CAST_NONE, slabElements);
PipeBarrier<PIPE_V>();
BuildCoefficientOffsets(coefficientOffsets, chunkLen);
offsetsReady = true;
Gather(betaFp32, chunkVFp32, coefficientOffsets, static_cast<uint32_t>(head_i * sizeof(float)), chunkLen);
PipeBarrier<PIPE_V>();
} else {
for (uint32_t i = 0; i < chunkLen; ++i) {
betaFp32.SetValue(i, LoadBeta(t_start + i, head_i));
}
}放大看改动处与它上面那条护栏(即上面第 6-9 行;只有 :819-820 是新写的,:817-818 一字未动)。 锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:817-820:
bool canUseBetaDma = slabElements <= slabCapacity && slabElements * sizeof(inType) % BLOCK_BYTES == 0;
if (likely(canUseBetaDma)) {
// Keep the FP16 source separate because an in-place widening Cast overwrites unread beta values.
LocalTensor<inType> betaLocal = chunkAttnOutFp32.template ReinterpretCast<inType>();2.1 三个常量与类型
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:35-37
constexpr uint64_t FP16_NUM_PER_BLOCK = 16;
constexpr uint64_t FP32_NUM_PER_BLOCK = 8;
constexpr int64_t BLOCK_BYTES = 32;BLOCK_BYTES / sizeof(T) 这个式子在文件里本来就是官方分块模型(:46 int64_t numPerBlock = BLOCK_BYTES / sizeof(T);)。 inType 是模板形参(:74 template <typename inType, typename outType, uint32_t kSpecializedDk>), 本算子按 fp16 实例化,故 sizeof(inType) == 2;FP16_NUM_PER_BLOCK = 16 = 32/2、 FP32_NUM_PER_BLOCK = 8 = 32/4 两个常量的定义本身就是这个 32B 模型的直接体现。
2.2 beta 板布局与 stride 关系
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:359-362、780-787、813
- GM 侧 beta 是
[T, NV]的 fp16 板。标量兜底路径印证行主序:LoadBeta(:359-362)内betaGm_.GetValue(t * NV_ + head_i)。 - DMA 搬的是整板一块:
betaGm_[t_start * NV_]起、slabElements = chunkLen * NV_个 fp16, 即[chunkLen][NV_]连续区域,共2·chunkLen·NV_字节。 - Gather 的列抽取靠字节偏移完成,见
BuildCoefficientOffsets:
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:780-787(该函数体在 !1174 中一字未改,在 048d0427 快照里整体后移 12 行至 792-799)
__aicore__ inline void BuildCoefficientOffsets(LocalTensor<uint32_t> offsets, uint32_t chunkLen) {
LocalTensor<int32_t> signedOffsets = offsets.template ReinterpretCast<int32_t>();
CreateVecIndex<int32_t>(signedOffsets, 0, chunkLen);
PipeBarrier<PIPE_V>();
Muls(signedOffsets, signedOffsets, static_cast<int32_t>(NV_ * sizeof(float)), chunkLen);
PipeBarrier<PIPE_V>();
}偏移 = i · NV_ · 4 字节,srcIdxStrideBytes = head_i · 4 —— 也就是说 Cast 之后的 chunkVFp32 被当作 [chunkLen][NV_] 的 fp32 板来抽列,行 stride = NV_·4 字节。
因此元素下标 i(平铺,0-based)与 (t, h) 的对应是 i = t·NV_ + h。这个对应是 §3 全部算术的基础。
2.3 快速路径的进入条件(实查 host 侧参数)
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:817、82、135;src/88d8b965/op_host/chunk_gated_delta_rule_tiling.cpp:32、110、171、299
canUseBetaDma 两项条件在真实 shape 下几乎恒成立:
slabElements · 2 % 32 == 0⟺chunkLen·NV_ % 16 == 0。chunkSize_ = 64(锚点:src/88d8b965/op_host/chunk_gated_delta_rule_tiling.cpp:32constexpr uint32_t kDefaultChunkSize = 64;),64·NV_ % 16 == 0恒成立。slabElements ≤ slabCapacity⟺chunkLen·NV_ ≤ chunkSize_·vStepAligned_。vStepAligned_ = Ceil(vStep_, 8)·8 ≥ 8(锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:135), 故整 chunk(chunkLen == 64)时条件退化为NV_ ≤ vStepAligned_。
NV_ = tilingData->hv(锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:82), hv 取自 value 分支的头维(锚点:src/88d8b965/op_host/chunk_gated_delta_rule_tiling.cpp:110 dims.hv = vDims.GetDim(kDimH);), vStep 由 host 在 UB 容量内尽量取大(同文件 :171 SolveVStep、:299 uint32_t preferredVStep = cubeDk;)。 这条推论很重要:bug 的前置条件不是"宽 GQA",而是"走 DMA 快速路径",而后者在常规 head 数下普遍成立。
3. 字节级覆盖过程推演
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:819-831
3.1 起点:源和目的是同一个地址
修复前那个 betaLocal 不是别人的缓冲,它就是 Cast 的目的缓冲本身:src/d0906c3e/ 的 :819 逐字为 LocalTensor<inType> betaLocal = chunkVFp32.template ReinterpretCast<inType>(); (6 空格缩进,即 §1 那段 diff 的 < 行;!1158 换掉的正是这条语句的源缓冲名)。于是同一个 betaLocal 在 d0906c3e 的 :823 是 DMA 的写入目的、在 :827 又是 Cast 的源,而 :827 的目的还是 chunkVFp32 自己。 DMA 与 Cast 这两条指令修复前后逐字节相同,只是被上面新增的那行注释整体推后 1 行,在 88d8b965 里是 :824 与 :828; 逐字见 88d8b965/op_kernel/chunk_gated_delta_rule.h:824-828:
DataCopy(betaLocal, betaGm_[static_cast<uint64_t>(t_start) * NV_], slabElements);
...
Cast(chunkVFp32, betaLocal, RoundMode::CAST_NONE, slabElements);ReinterpretCast 只换元素类型解释、不换基址。于是 betaLocal 与 chunkVFp32 基址相同: fp16 视图占据该地址起的前 2·slabElements 字节,fp32 视图占据前 4·slabElements 字节。 Cast 的 dst 是 fp32 视图、src 是 fp16 视图 —— 原地加宽。
3.2 repeat 窗口表
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:827、35-37
设 R 为 repeat 序号(0-based)。Vector 管一条 Cast 指令按 32B 目的块推进,每 repeat 产出 8 个 fp32、消耗 8 个 fp16:
repeat R | 读窗口(fp16 源,字节) | 读元素 | 写窗口(fp32 目的,字节) | 写元素 | 与更早写区间的关系 |
|---|---|---|---|---|---|
| 0 | [0, 16) | 0..7 | [0, 32) | 0..7 | 无(首个) |
| 1 | [16, 32) | 8..15 | [32, 64) | 8..15 | 读区间 ⊂ R0 写的 [0,32) ✗ |
| 2 | [32, 48) | 16..23 | [64, 96) | 16..23 | 读区间 ⊂ [0,64) ✗ |
R | [16R, 16R+16) | 8R..8R+7 | [32R, 32R+32) | 8R..8R+7 | 前 R 个 repeat 已连续写出 [0, 32R) |
一般式(读区间完全落入已写出区间 ⟸ 读区间右端 ≤ 已写区间的右端点):
16R + 16 ≤ 32R ⟺ 16 ≤ 16R ⟺ R ≥ 1即从第 2 个 repeat(R = 1)起,读窗口整体落在前面所有 repeat 已经写过的字节里, 不是"部分相交"而是"完全被覆盖"。写窗口比读窗口宽 2 倍、且向前多延伸 16B,这多出来的 16B 正好吃掉下一个 repeat 的读窗口。
3.3 幸存集合与"8"的算术
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:35-37、46
幸存 ⟺ R = 0 ⟺ 平铺元素 i ∈ [0, 8)。
8 的来源是 32B / 4B,不是 32B / 2B:
每 repeat 产出元素数 = BLOCK_BYTES / sizeof(目的类型) = 32 / 4 = 8理由:repeat 的推进节拍由目的(写)侧的 32B 块决定,一个 32B 目的块只装得下 8 个 fp32, 因此这条 Cast 每 repeat 只消费 8 个 fp16(= 16B 源)。源侧的 16B 块数(FP16_NUM_PER_BLOCK = 16) 描述的是"32B 里装几个 fp16",不是"一个 repeat 消费几个 fp16"。 两个常量并排写在 :35-37 正是这层对照:16 = 32/2、8 = 32/4,决定损坏边界的是后者。
3.4 从"前 8 个元素"到"(t,h) 平面"
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:813、823、831
代回 i = t·NV_ + h,损坏集合与幸存集合为:
幸存: t·NV_ + h < 8
损坏: t·NV_ + h ≥ 8按 NV_(即 hv)展开:
NV_ | 幸存的 (t,h) | 损坏范围 |
|---|---|---|
| 16 | (0, 0..7) 共 8 个 | (0, 8..15) + 所有 t ≥ 1 |
| 8 | (0, 0..7) 共 8 个(= 第 0 行整行) | 所有 t ≥ 1,即 7/8 的板 |
| 4 | (0, 0..3)、(1, 0..3) 共 8 个 | 所有 t ≥ 2 |
| 2 | (0..3, h) 共 8 个 | 所有 t ≥ 4 |
NV_ ≥ 8 是 GQA/MHA 的现实区间,于是:
- "head 序号 ≥8 的列全变垃圾"成立(第 0 行的
h ≥ 8直接落在损坏集); - 但它只是损坏集的一部分。更强的结论是:只要走 DMA 快速路径且
chunkLen ≥ 2,t ≥ 1的 beta 全部损坏, 与 head 数宽窄无关。docs 的"窄 shape 不受影响"这一条不成立,见 §8。
3.5 为什么单条指令内部不报错
锚点:src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:827;src/d0906c3e/op_kernel/chunk_gated_delta_rule.h:35-37
Cast 是流式执行的:每 32B 块读入、运算、写出,块间顺序推进,硬件不知道也不需要知道源与目的在内存上相交。 这与 §2 的算术合起来构成一条硬约束:加宽变换的原地执行在字节计数上就不可行 (n 个源元素 = 2n 字节,n 个目的元素 = 4n 字节,目的区间必然超出源区间一倍),与具体调度无关。 重叠判定是程序员的责任 —— 这正是 AscendC 与普通 C++(std::transform 原地跑没问题)的语义差别之一。
一个必须诚实交代的不确定性:上表假设"repeat R 的读发生在 repeat R-1 的写之后",即读/写按 repeat 严格串行。 真实 V 管可能带一级以上的预取(读 R+1 与写 R 重叠),那会把幸存前缀从 8 个元素推大一点(16、24……), 但不会改变"只有常数级前缀幸存"这个量级结论,也不会改变修复的正确性 —— 修复消除了别名本身。 快照无法回答预取深度,本文不猜;能回答它的只有 docs/PR-1158-88d8b965/02-实验复现.md §4 的 revert 实验。
4. 为什么 !1135 自带的测试没拦住
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:834-838(else 分支,即被绕过的那条)
可复核的三点:
- 存在两条语义等价的路径。
canUseBetaDma为假时走for循环LoadBeta(t_start+i, head_i)标量逐元素读(:834-838), 这条路径永不触发 bug。任何让护栏判假的 shape(例如NV_ > vStepAligned_,见 §2.3)都会绕开损坏区, 产生"同样的代码、有些 shape 对、有些 shape 错"的表象 —— 这正是 shape 相关静默错误的典型结构。 - 触发条件不在测试断言里。测试断言的是输出张量的 cosine/nrmse,而 bug 的作用点是中间 UB 缓冲的地址别名。 别名不会改变任何静态可见的量(编译通过、shape 正确、内存不越界),只会污染数值。
- 损坏是"部分列/部分行"而非"全体"。§3.4 表明损坏集在
(t,h)平面上是有结构的子集, 随机小 shape + 宽容差下,被污染列的贡献可以被衰减与归一化稀释;要稳定暴露它, 必须让 head 维本身成为被测变量。
实测记录(二手,出处标明):d0906c3e 正确性 12 passed / 1 failed / 13 skipped, failed 的是 test_accuracy_recurrent_reference[fp16],cos=0.9996 达标但 nrmse=0.0269 > 0.02 —— 出处 PLAN.md:93 与 docs/PR-1135-d0906c3e/02-实验复现.md:68,蓝区 310P3 / CANN 8.3.RC1,2026-09-08。 docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:81-82 明确记为"beta 覆盖 bug 没被任何现有用例抓到"。 注意:PLAN.md:93 却把这条 fail 归给"!1158/!1174 修复的精度问题", 而 docs/PR-1135-d0906c3e/00-PR总览与实验复现.md:57 把它归给"FP16 分裂的残差在长序列累积"。 同一个 failed 用例出现三种归因,见 §8。
5. chunkAttnOutFp32 此刻确实空闲:生命周期证明
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:736-756
5.1 本 chunk 内的调用顺序
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:745-755
ProcessChunkVTile 的函数体顺序(函数体整体 :736-756,下面逐字取它的调用段 :745-755):
LoadChunkKey(qkHead, t_start, chunkLen);
LoadChunkCoefficients(t_start, head_i, chunkLen);
ComputeKBeta(chunkLen);
PrepareDecayAndExp(chunkLen);
PrepareAttnQueryCache(qkHead, t_start, chunkLen);
ComputeAttnMatrix(qkHead, t_start, chunkLen);
ComputeKCumdecay(chunkLen);
uint32_t v_i = vTileIdx * 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);LoadChunkCoefficients 是 chunk 内第 2 个被调用的函数,PrepareAttnQueryCache 是第 5 个。 所以借用窗口右端由 PrepareAttnQueryCache 钉死。
5.2 下一个写者
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:950-962
// chunkAttnOutFp32 is unused until V-tile processing. When it can hold a
// full Q chunk, reuse chunkVFp32 as FP16 staging and cache Q*scale once for
// the attention QK dot products.
__aicore__ inline void PrepareAttnQueryCache(uint64_t qkHead, int32_t t_start, uint32_t chunkLen) {
if (!CanCacheAttnQuery(chunkLen)) {
return;
}
LocalTensor<inType> queryLocal = chunkVFp32.template ReinterpretCast<inType>();
uint64_t qkOff = (static_cast<uint64_t>(t_start) * NK_ + qkHead) * realK_;
LoadPaddedRows(chunkAttnOutFp32, queryLocal, queryGm_, qkOff, chunkLen, NK_ * realK_);
Muls(chunkAttnOutFp32, chunkAttnOutFp32, scale_, chunkLen * alignK_);
PipeBarrier<PIPE_V>();
}chunkAttnOutFp32 在 :959 被 LoadPaddedRows 整块覆写为 Q 缓存。 :950-952 是作者自己写下的不变式注释("chunkAttnOutFp32 is unused until V-tile processing" 在 :950)—— 即在本函数(更早)到 PrepareAttnQueryCache 之间,它不承担语义。 当 CanCacheAttnQuery 为假、:959 不执行时,下一个写者退到 LoadVBeta(:1345 的 valueLocal = chunkAttnOutFp32.ReinterpretCast<inType>()),它在 ProcessOneVTile 第一步,同样晚于 :820。
5.3 上一个读者(为什么旧内容已经死了)
chunkAttnOutFp32 在 chunk 里的语义生命周期由 ProcessOneVTile 收尾。它是输出板, 末次消费者是 WriteAttnTileToGm:
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:653-661
__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>();:660 的 Cast 把 chunkAttnOutFp32 读完,:661 的 PipeBarrier<PIPE_V>() 保证读已落地, 之后 outLocal 才入队交 MTE3 搬 GM。函数返回时 chunkAttnOutFp32 内容已无持有者。 而 WriteAttnTileToGm 在 ProcessOneVTile 里位置靠后(:1327, 在 ComputeOutputCubeDispatch/AccumOutput 之后、UpdateAndWriteState(:1328)之前), UpdateAndWriteState(:2293 起)不再触碰 chunkAttnOutFp32 —— grep -n 'chunkAttnOutFp32' 在 88d8b965 共 38 处命中,函数体内最大行号 :2174;:2174 之后只剩 :2433 一条成员声明 (成员区从 :2411 的 private: 起),而 UpdateAndWriteState 的函数体是 :2293-2409,即该函数体内零命中。
多 V-tile 时的跨 tile 活性也不构成障碍:host/kernel 的 tmpBuff 注释明确写着 "multiple tiles: separate (scores must remain live across every tile)"(:150-152), 跨 tile 必须长命的是 chunkScoresFp32/stateInFp32;chunkAttnOutFp32 在每个 tile 的 ProcessOneVTile 第一步(:1312 调用 LoadVBeta,其内 :1345 把它重铸成 fp16 板)就被重新覆写,是 per-tile 暂存。
5.4 夹住的行区间与容量恒等
锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:170、185、765-767、815、820-833、2430-2433
- 借用起点:
LoadChunkCoefficients内 :820(把chunkAttnOutFp32重铸成 fp16 板;:824 的DataCopy即以它为写入目的)。 - 借用终点::832 的
Gather读完之后(PipeBarrier<PIPE_V>()在 :833)。fp16 beta 板此后死亡。 - 原主人下次覆写:
PrepareAttnQueryCache:959。 - 夹住的代码区间:88d8b965:820-833 ⊂ (上一 chunk 的 :660 末次读, 本 chunk 的 :959 下次写)。
容量恒等(这是"为什么是它"的第二半理由)。锚点:88d8b965/op_kernel/chunk_gated_delta_rule.h:170-185 (:171-184 之间夹着 chunkScoresFp32/stateInFp32 的分配与一个 if/else 分支,下面只取首尾两行、中间以省略号略过):
chunkVFp32 = tmpBuff.GetWithOffset<float>(cs * avStepAligned, off);
...
chunkAttnOutFp32 = tmpBuff.GetWithOffset<float>(cs * avStepAligned, off);两者元素数完全相同,成员声明处的注释也相同(:2430 // cs * avFp32 (v_beta, then v_new)、 :2433 // cs * avFp32 (output))。而护栏 slabElements <= slabCapacity 的含义是 2·slabElements 字节 ≤ 4·slabCapacity 字节,对 fp16 slab 恰好是对任一块 cs·avStepAligned fp32 缓冲的正确容量判定。 所以换缓冲后 :817 那行护栏不需要动一个字符仍然精确成立 —— 这是选择 chunkAttnOutFp32 而非"另找一块"或"把源目的错位"的决定性工程理由。
顺带排除另一候选:chunkVFp32 的兄弟块 kCumdecayFp32(:164)此刻正被 LoadChunkKey 的输出占用 (chunkKFp32→kCumdecayFp32 链在 ComputeKBeta 之后才活),decayMaskFp32(:167)同理, dotProductFp32(:196)在本函数 :815 已被借成 coefficientOffsets。同尺寸且此刻空闲的只有 chunkAttnOutFp32。
6. !1158 新增的测试:快照无法复核,只能引用
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:817、834-838
实查结论:src/88d8b965/ 与其余快照一样只有 7 个文件,没有任何 test_*.py。 因此"87 行测试"这一说法在本工作区不可复核,下面全部按二手引用处理,出处标明:
- 用户/PLAN 记录:
git diff 88d8b965^1 88d8b965 -- '*test*'可见 87 行新增(docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:86)。 - 测试文件路径(二手):
mindspore-lite/lite_boost/test/ops/test_chunk_gated_delta_rule.py(docs/PR-1158-88d8b965/02-实验复现.md:32)。 - 数量佐证(二手,可自洽核对):d0906c3e 为 12 passed / 1 failed / 13 skipped(共 13 条非 skip), 048d0427 为 15 passed / 0 failed / 13 skipped(共 15 条非 skip) —— 出处 PLAN.md:93、:112 与 docs/PR-1174-048d0427/02-实验复现.md:36、:76。 非 skip 用例数 13 → 15,即新增 2 条用例,与"87 行参数化/形状矩阵代码"量级吻合。
这批测试把什么变成了测试变量:按 §3.4 的推演,损坏集在 (t, h) 平面上有结构, 所以有效的回归网必须把 head 序号 h(以及 hv/hqk 的 GQA 比例) 变成显式被测维 —— 即"同一 shape 下扫不同 head 列、并加入 hv > hqk 的宽 GQA 组", 而不是只把 T/dk/dv 加大。docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:86 的 表述是"系统性加入宽 Hv、多种 GQA 比例的形状,把 head 列位置变成显式测试变量",与本文的算术结论方向一致 (但按 §3.4,光扫 head 列还不够,t ≥ 1 行同样需要覆盖)。
修复与回归网同 PR 合入是 !1158 的流程价值:坏的输入模式一旦发现就该当场钉死在测试里, 否则下一个人重构这块缓冲时会重新踩。
7. 借缓冲三条铁律(重述)
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:765-767、813-833;docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:95-102
以下三条从 docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:95-102 吸收,用本文的机制语言重述, 并逐条标明它各自防的是哪一种覆盖。
铁律一:同缓冲上的原地类型宽度变换一律禁止。 防的是"自己覆盖自己的未消费输入"——dst 与 src 同基址、元素宽度不等时, 加宽方向写窗口比读窗口宽、写前沿必然越过读前沿;收窄方向反之,读窗口比写窗口宽、 后段源未消费就被写覆盖。两个方向都不可行,所以这条不需要逐例推演,直接当硬规则用。 合法的"原地"只在同宽度时存在(例如 fp16 视图搬 fp16 视图,本文件 LoadChunkKey 的 :765-767 就是安全先例:keyLocal 取 chunkVFp32 的 fp16 视图,DMA 后直接 LoadPaddedRows 写 chunkKFp32,全程无 Cast)。
铁律二:借用之前,把该时刻的完整生命周期表重写一遍。 防的是"借了别人的活缓冲"——错误类型不是算术而是"谁此刻持有这块内存", 表现为写覆盖了仍在被读的活数据(WAR)。本例的正确答案不是"这块通常空着", 而是能写出 §5 那张从 ProcessChunkVTile 调用序 → PrepareAttnQueryCache:959 → WriteAttnTileToGm:660 的占用链,把借用区间夹进两次主人使用之间。 反面教训:LoadChunkKey 借 chunkVFp32 成功在先,!1135 在 LoadChunkCoefficients 里照抄同一手法, 却没照抄"那次借用是同宽度、无 Cast"这个前提。不要抄上一处的答案。
铁律三:ReinterpretCast 之后,以字节为单位重画一遍内存图。 防的是"变量名暗示的布局与实际字节布局不一致"——换视图后"一行"的宽度变了: chunkVFp32 的一行是 avStepAligned 个 fp32 = 2·avStepAligned 个 fp16,行号与元素号的对应关系整体漂移。 本 bug 的代码里 DataCopy(betaLocal, ..., slabElements) 与 Cast(chunkVFp32, betaLocal, ..., slabElements)两个调用各自参数都正确,是它们的地址别名组合才错 —— 单看任一行都挑不出毛病,这正是这类 bug 难审的原因。 可执行的检查动作:把每条指令的读写区间写成 [base + 2·i, base + 2·(i+n)) 与 [base + 4·i, base + 4·(i+n)) 这样的闭区间,做一次相交判定,而不是靠读变量名。
8. 与既有文档的差异(以实查为准)
锚点:src/88d8b965/op_kernel/chunk_gated_delta_rule.h:816-820;docs/PR-1158-88d8b965/01-缓冲重叠Bug深度剖析.md:38-47
| # | 既有说法(出处) | 实查结果 | 判定 |
|---|---|---|---|
| 1 | "受影响范围:head_i ≥ 8 的 beta 全程损坏;NV ≤ 8 的窄 shape 不受影响"(docs/PR-1158-88d8b965/01:44-47) | 按 §3.4,损坏集是 {t·NV_ + h ≥ 8}。NV_ = 8, chunkLen = 64 时 t ≥ 1 整片损坏(504/512 个元素)。窄 shape 同样受影响 | 不成立(说法过窄) |
| 2 | "head_i < 8 且时间步 < 8 的 beta 幸存"(同文件 :45) | 同一文档 :38 自己写"实际幸存的只有前 8 个系数"。head<8 且 t<8 是 64 个元素,与 8 矛盾;真实幸存集是 t·NV_+h < 8 | 自相矛盾,本文以 8 个平铺元素为准 |
| 3 | "修复后(src/88d8b965/, :816-817)"(同文件 :54、:12 引用修复前 :816-832) | 实查:bug 行在 d0906c3e 是 :819,修复后两行在 88d8b965 是 :819-820;:816 是 canUseBetaDma 那一行 | 行号偏差 3 行 |
| 4 | §1 的代码块(同文件 :11-22)用省略号与 // ... V_MTE2 / MTE2_V 事件 ... 压缩,且 DataCopy/Cast/Gather 参数被简化(如省略 static_cast、RoundMode) | 与快照逐字对照,除简化外无语义偏离;但不是逐字节引用 | 可接受的示意,本文改用 §2 的完整逐字段 |
| 5 | 未提及 !1158 的影响面问题 | 实查 diff -rq 全树:改动仅 kernel.h 一处,op_host / cpp / tiling_data.h 零改动 | 本文补充 |
| 6 | "87 行测试" | src/ 快照内无测试文件,无法复核;间接证据是非 skip 用例 13 → 15 | 二手,需蓝区 git diff 才能确认 |
| 7 | d0906c3e 那条 test_accuracy_recurrent_reference[fp16] 的归因:docs/PR-1158/01:81-82 说"beta 覆盖 bug 没被任何现有用例抓到";PLAN.md:93 说该 fail "正是 !1158/!1174 修复的精度问题";docs/PR-1135-d0906c3e/00:57 说它是"FP16 分裂的残差在长序列累积" | 三种互斥归因并存,快照层面无法裁决(无测试文件、无运行结果) | 未决,本文不选边 |
| 8 | §3.5 的读/写串行假设 | 无法从源码验证 V 管预取深度;只影响幸存前缀是 8 还是 8 的小倍数,不影响"常数级前缀"结论与修复正确性 | 标注为未证 |
另有一处值得记录的推论张力(不指认为任何一方的错):§2.3 + §3 合起来意味着 凡是走 beta DMA 快速路径的 shape 都会损坏 500+ 个 beta 元素, 而 PLAN.md:93 记录的 d0906c3e 实测是 12 passed / 1 failed。 两者不能同时成立,缺口只能由 §3.5 的硬件流水假设或 §4 的路径绕过解释 —— 这正是 docs/PR-1158-88d8b965/02-实验复现.md §4 那个 revert-重编-对比实验的价值所在: 它是唯一能把"字节模型"与"真机行为"对齐的一步,且成本只有约 4 分钟编译。