Skip to content

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
MTE2DMA 搬运:Global Memory → UBDataCopy(入方向)
MTE3DMA 搬运:UB → Global MemoryDataCopy(出方向)
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)--> GM

3. 四条流水线是异步的! ​

这是 AscendC 最反直觉的一点:S、V、MTE2、MTE3 是四个物理上独立的执行单元,代码顺序执行并不代表数据就绪。

cpp
chunkKFp32.SetValue(i * alignK_ + j, keyGm_.GetValue(...));  // Scalar 在写 UB
...
Muls(kCumdecayFp32[...], chunkKFp32[...], beta, realK_);     // Vector 读 UB —— 它发出时 Scalar 可能还没写完!

如果什么都不做,上面这段就有数据竞争。所以 kernel 里到处是三种同步原语:

3.1 PipeBarrier<PIPE_X>() — 同一条流水线内的顺序保障 ​

cpp
Muls(chunkVFp32[i * avFp32], chunkVFp32[i * avFp32], betaVal, curV);
PipeBarrier<PIPE_V>();
Cast(...);   // 确保在 Muls 完成后执行

只约束同一条管道内指令的完成顺序,不跨管道。

3.2 TQueSync<A, B> — 跨管道方向性栅栏(本 kernel 用得最多) ​

cpp
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 机制:

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

cpp
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 —— 手工偏移切分的大缓冲 ​

cpp
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 暂存chunkAttnOutFp32Q 缓存已消费、输出未开始写
Q·scale 的缓存chunkAttnOutFp32V-tile 处理前这块还没启用
chunkScoresFp32 与 stateInFp32 overlap两者取 max每个 core 只处理一个 V-tile 时,scores 在 state 加载前已死

每次借用都有生命周期注释,读代码时要问自己:此刻这块缓冲的原主人死了吗?

5. 对齐与块(block)概念 ​

MTE DMA 要求 32 字节对齐。kernel 定义了两个对齐常数:

cpp
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)*16K/Q 相关缓冲的行宽(fp16 对齐)
stateStrideK_ceil(dk/8)*8state tile 的 k 维行宽(fp32 对齐)
vStepAligned_ceil(vStep/8)*8V-tile 行宽(fp32 对齐)

行宽≠真实维度时,行尾有 padding,DMA 快路径要求"真实宽度正好 32B 整除"(alignK_ == realK_),否则退化为标量逐元素搬运。这就是每个搬运函数里那一长串 canUseDma 判定的来源。