02 · 硬件执行模型:310P 上的 AscendC 心智模型
读这个 kernel 之前,必须先建立正确的硬件图景。AscendC 代码看起来像普通 C++,但每个函数调用背后的执行单元、每处 PipeBarrier/TQueSync 的含义,都来自下面这些事实。
1. 300I Duo 的计算资源
Ascend 310P(300I Duo 用的同代架构)每个 AI Core 内有:
| 单元 | 干什么 | kernel 中的对应 API |
|---|---|---|
| Scalar 单元 (S) | 标量运算、循环控制、逐元素读写内存 | GetValue()、SetValue()、float 运算 |
| Vector 单元 (V) | SIMD 向量运算(fp16/fp32 的乘加/指数/归约) | Mul Add Muls Axpy Exp Cast Duplicate WholeReduceSum |
| MTE2 | DMA 搬运:Global Memory → UB | DataCopy(入方向) |
| MTE3 | DMA 搬运:UB → Global Memory | DataCopy(出方向) |
| Cube 单元 | 矩阵乘专用 | 本 kernel 未使用(KERNEL_TYPE_AIV_ONLY) |
这个 PR 的实现选择了 KERNEL_TYPE_AIV_ONLY(chunk_gated_delta_rule.cpp:28),即纯 vector 核实现,完全绕开 Cube。所以:
本 kernel 中所有"矩阵乘法"都是用向量指令循环拼出来的软件模拟。 这是理解整个实现的核心前提,也是后续 #423/#435 优化最可能的切入点。
2. 存储层级与数据通路
Global Memory (GM, 显存级)
↕ MTE2 (DMA入) / Scalar GetValue —— 慢通道 vs 极慢通道
UB (Unified Buffer, 片上,每核 ~几百KB) ← 所有向量计算只能在这里进行
↕ MTE3 (DMA出) / Scalar SetValue
Global Memory关键约束:
- 向量指令的源和目只能在 UB 里(
LocalTensor); - GM 只能通过 MTE2/MTE3 DMA(快,但要求对齐)或标量
GetValue/SetValue(逐元素,慢几十倍)访问; - UB 容量有限 —— 这就是 host 侧 tiling 要算
vStep(V 维切多大)的根本原因(见 03 篇)。
kernel 的所有"计算"都是这样的循环:
GM --(MTE2 DMA 或 标量 GetValue)--> UB LocalTensor --(Vector 指令计算)--> UB --(MTE3 DMA 或 SetValue)--> GM3. 四条流水线是异步的!
这是 AscendC 最反直觉的一点:S、V、MTE2、MTE3 是四个物理上独立的执行单元,代码顺序执行并不代表数据就绪。
chunkKFp32.SetValue(i * alignK_ + j, keyGm_.GetValue(...)); // Scalar 在写 UB
...
Muls(kCumdecayFp32[...], chunkKFp32[...], beta, realK_); // Vector 读 UB —— 它发出时 Scalar 可能还没写完!如果什么都不做,上面这段就有数据竞争。所以 kernel 里到处是三种同步原语:
3.1 PipeBarrier<PIPE_X>() — 同一条流水线内的顺序保障
Muls(chunkVFp32[i * avFp32], chunkVFp32[i * avFp32], betaVal, curV);
PipeBarrier<PIPE_V>();
Cast(...); // 确保在 Muls 完成后执行只约束同一条管道内指令的完成顺序,不跨管道。
3.2 TQueSync<A, B> — 跨管道方向性栅栏(本 kernel 用得最多)
TQueSync<PIPE_S, PIPE_V> sync;
sync.SetFlag(0); // Scalar 侧:我的写入完成了,立 flag
sync.WaitFlag(0); // Vector 侧:等 flag 到了再继续(读数据)模板参数是方向:<PIPE_S, PIPE_V> = "S 写 → V 读"。kernel 里的惯用模式是三种:
| 写法 | 出现场景 |
|---|---|
TQueSync<PIPE_S, PIPE_V> | 标量算好系数/数据 → 交给向量指令用(如 ComputeKBeta 里 beta 值、ComputeVNew 里 kcd 系数) |
TQueSync<PIPE_V, PIPE_S> | 向量算完的结果 → 标量读(如 DotFp32 里归约结果收尾、PrepareDecayAndExp 里读 gi) |
| 成对出现包裹一段代码 | 确保边界处两边的视图一致 |
注意方向性是必须的:只立 <S,V> 不立 <V,S>,S 后续写新数据可能被流水乱序提前。
3.3 SetFlag/WaitFlag<HardEvent> — 事件 ID 栅栏(MTE 参与时)
MTE2/MTE3 与 S/V 之间的同步用事件 ID 机制:
event_t scalarToMte2 = pipe_->FetchEventID(HardEvent::S_MTE2);
SetFlag<HardEvent::S_MTE2>(scalarToMte2); // 我(S/V 侧)准备好了
WaitFlag<HardEvent::S_MTE2>(scalarToMte2);
DataCopy(keyLocal, keyGm_[qkOff], keyParams); // MTE2 开始搬
event_t mte2ToVector = pipe_->FetchEventID(HardEvent::MTE2_V);
SetFlag<HardEvent::MTE2_V>(mte2ToVector);
WaitFlag<HardEvent::MTE2_V>(mte2ToVector); // 搬完通知 Vector 可读标准三段式:搬之前先 S→MTE2、V→MTE2 双向确认(UB 里旧数据不再被读),搬之后 MTE2→V 确认(新数据已就位)。LoadChunkKey、PrepareAttnQueryCache、LoadVBeta 的 DMA 快路径全部是这个模式。
LoadStateTile/UpdateAndWriteState 里还有 PipeBarrier<PIPE_ALL>()(全管道栅栏)+ MTE3_MTE2(上一块的写回和下一块的读入共享 GM,防止读写竞争)。
4. TPipe 与两种缓冲
Init 时通过 TPipe 在 UB 上申请两块(InitLocalBuffers,chunk_gated_delta_rule.h:127):
4.1 stateOutQueue_ —— 标准 TQue
TQue<QuePosition::VECOUT, 1> stateOutQueue_; // BUFFER_NUM = 1
pipe_->InitBuffer(stateOutQueue_, BUFFER_NUM, outQueueBytes);用法是标准队列三件套:AllocTensor → 写 → EnQue → DeQue → 用 → FreeTensor。BUFFER_NUM=1 意味着没有 double buffer,搬运和计算完全串行——这是明显的优化空间(#446 的候选方向)。
它只服务两类出向数据:写出的注意力输出 tile(WriteAttnTileToGm)和最终 state(UpdateAndWriteState 最后一块),两者共用同一块内存(取 max 大小)。
4.2 tmpBuff —— 手工偏移切分的大缓冲
TBuf<TPosition::VECCALC> tmpBuff;
pipe_->InitBuffer(tmpBuff, restUbSize_); // host 算好的"剩余全部 UB"
chunkKFp32 = tmpBuff.GetWithOffset<float>(cs * ak, off); off += cs * ak * 4;
kCumdecayFp32 = tmpBuff.GetWithOffset<float>(cs * ak, off); off += cs * ak * 4;
...所有中间张量(11 个 LocalTensor<float> 成员)都是这同一块大缓冲里的偏移切片,切分方案由 InitLocalBuffers 硬编码,总大小由 host 算好写进 tilingData->ubRestBytes(host/device 必须用完全相同的记账,见 03 篇)。
手工切分的好处是显式复用。kernel 里至少有四处"借别人的地盘":
| 借用者 | 被借缓冲 | 时机 |
|---|---|---|
| K/Q 的 FP16 DMA 暂存 | chunkVFp32(ReinterpretCast 成 fp16) | V 还没加载,这块是死的 |
| V 的 FP16 DMA 暂存 | chunkAttnOutFp32 | Q 缓存已消费、输出未开始写 |
| Q·scale 的缓存 | chunkAttnOutFp32 | V-tile 处理前这块还没启用 |
chunkScoresFp32 与 stateInFp32 overlap | 两者取 max | 每个 core 只处理一个 V-tile 时,scores 在 state 加载前已死 |
每次借用都有生命周期注释,读代码时要问自己:此刻这块缓冲的原主人死了吗?
5. 对齐与块(block)概念
MTE DMA 要求 32 字节对齐。kernel 定义了两个对齐常数:
FP16_NUM_PER_BLOCK = 16 // 32B / 2B:一行 fp16 至少 16 个元素才能整块搬
FP32_NUM_PER_BLOCK = 8 // 32B / 4B:一行 fp32 至少 8 个元素由此派生三个"对齐后宽度"(构造函数 chunk_gated_delta_rule.h:88-90):
| 成员 | 公式 | 用途 |
|---|---|---|
alignK_ | ceil(dk/16)*16 | K/Q 相关缓冲的行宽(fp16 对齐) |
stateStrideK_ | ceil(dk/8)*8 | state tile 的 k 维行宽(fp32 对齐) |
vStepAligned_ | ceil(vStep/8)*8 | V-tile 行宽(fp32 对齐) |
行宽≠真实维度时,行尾有 padding,DMA 快路径要求"真实宽度正好 32B 整除"(alignK_ == realK_),否则退化为标量逐元素搬运。这就是每个搬运函数里那一长串 canUseDma 判定的来源。