AscendC 910B GM 标量/MTE 双向缓存不一致 Bug 详解
一句话总结
在 910B (DAV_2201) 芯片上,同一块 GM 显存地址,标量赋值(
gmPtr[i] = v)和 DMA 搬运(DataCopy)之间没有硬件缓存一致性协议 。两个方向都可能写丢或读错,精度误差会膨胀 10~100 倍。
1. 背景:910B 的两条"内存通道"
AscendC 的 AICore 访问 GM(显存)时,其实有 两条独立的通路:
Plaintext
┌──────────────────────────────────────────────────────────┐
│ AICore │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 标量通路 (DataCache) │ │ MTE 通路 (DMA) │ │
│ │ │ │ │ │
│ │ gmPtr[i] = val │ │ DataCopy / │ │
│ │ gmPtr[i] += val │ │ DataCopyPad │ │
│ │ gmPtr[i] │ │ │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
└──────────────┼─────────────────────────────┼──────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ GM (Global Memory) │
└─────────────────────────────────────────┘
▲ ▲
│ │
没有一致性协议!一个人写的东西,另一个人不一定看得到
CPU 上 有 MESI 等缓存一致性协议,硬件帮你"通知"对端刷缓存。
910B 上 没有 ------ 这两条路互相 不可见。
2. 什么是"标量访问" vs "MTE 搬运"?
| 维度 | 标量访问 | MTE 搬运 |
|---|---|---|
| 写法 | gmPtr[i] = 1.0f; |
DataCopy(dst, src, len); |
| 通路 | 走 DataCache | 走 DMA 引擎 |
| 粒度 | 单个元素(float/int) | 一整块连续内存 |
| 适合场景 | 少量、零散操作 | 大块、批量搬运 |
| 是否进 cache | 是(DataCache) | 否(直通 DRAM) |
简单说:
- 标量写 = 你往一个小信箱(DataCache)里塞纸条,等攒够了一批才统一寄出去
- MTE 搬运 = 叫搬运工(DMA)一次性把一车货从仓库搬到工作台
问题来了:小信箱和搬运工之间没有对讲机。 你塞进去的纸条,搬运工不一定知道;搬运工刚搬走的东西,你的小信箱里可能还留着旧纸条。
3. 生活中的比喻
想象你和小李合租一个仓库:
- 你 = 标量通路,写完只在自己的小本子(DataCache)上记一笔
- 小李 = MTE 搬运工,只看仓库门牌(DRAM),从不翻你的小本子
场景 A:你先记,小李后搬
Plaintext
1. 你把"还剩 5 个箱子"写在自己的小本子上 ← DataCache 里有新值
2. 小李按门牌去仓库搬货 ← MTE 读 DRAM,看到的是旧值 3
3. 你的小本子和仓库不一致了
场景 B:小李先搬,你后记
Plaintext
1. 小李刚把仓库里"3 个箱子"改成"0 个" ← MTE 写 DRAM(异步,还在路上)
2. 你在仓库门牌上写"还剩 8 个" ← 标量写,覆盖了 DRAM
3. 几秒后小李的 DMA 到了,把你的"8"盖成"0" ← 你的写丢了!
两个方向都会出错
4. Bug 的两个具体方向
方向 1:标量写 → MTE 读
cpp
// 标量写:把 computed_value 写到 GM 缓冲区
__gm__ float *dSF32 = /* GM scratch */;
for (uint32_t i = 0; i < N; i++) {
dSF32[i] = computed_value; // 进了 DataCache,不一定到 DRAM
}
// MTE 读:把同一块 GM 搬到 UB
DataCopy(ubBuf, dSF32, N); // DMA 直读 DRAM,看不到 DataCache → 读到旧值!
方向 2:MTE 写 → 标量写
cpp
// MTE 写:把工作台上的 zeroBuf 搬到 GM(异步!)
DataCopy(dWacc, zeroBuf, V * H); // DMA 还在路上
// 标量写:在同一地址上累加
for (uint32_t i = 0; i < V * H; i++) {
dWacc[i] += partial_sum; // 你的写可能被迟到的 DMA 盖掉!
}
症状 :精度误差在 1e-3 ~ 2e-2 级别(FP16 正常误差约 1e-4),没有任何编译/运行报错,只是结果不对。
5. 简单复现代码(host 端模拟)
下面这段独立可编译的 C++ 代码模拟了 910B 的"两条不互通通路",在标准 CPU 上也能看到类似现象。
它不是 AscendC 代码,但用最少的代码把"双向不一致"这件事演示清楚:
cpp
// simulate_910b_incoherence.cpp
// 编译:g++ -std=c++17 -O2 simulate_910b_incoherence.cpp -o sim && ./sim
//
// 模拟 910B 上"标量通路"和"DMA 通路"共享同一块 GM,
// 但两边没有缓存一致性协议。
#include <cstdio>
#include <cstring>
#include <vector>
// 模拟"标量通路"的小本子(DataCache)
static float g_scalar_notebook[16] = {0};
// 模拟"GM 仓库"(DRAM),刚开始是 0
static float g_gm[16] = {0};
// 模拟"MTE 搬运工"看到的 DRAM 视图
static float g_dma_view[16] = {0};
// 模拟标量通路:把值写进小本子,但不一定立刻同步到 GM
void scalar_write(int i, float v) {
g_scalar_notebook[i] = v;
// 910B 上这一步只是写 DataCache,DRAM 还没收到
g_gm[i] = v; // 模拟"已同步到 DRAM" ------ 但实际硬件不保证
}
// 模拟 MTE 搬运工:直接读 DRAM(完全不知道小本子的存在)
void mte_read_all() {
memcpy(g_dma_view, g_gm, sizeof(g_gm));
}
// 模拟 MTE 写:搬运工直接把一车"零"倒进 GM
void mte_write_zeros() {
// 标量通路可能不知道搬运工正在路上
memset(g_gm, 0, sizeof(g_gm));
// 910B 上:这是异步 DMA,标量通路的小本子里仍是旧值
g_scalar_notebook[0] = 42.0f; // 标量写:把自己小本子改了
// 如果 DMA 比这个标量写晚到,标量写就被覆盖
}
int main() {
// ===== 方向 1:标量写 → MTE 读 =====
printf("=== 方向 1:标量写 -> MTE 读 ===\n");
for (int i = 0; i < 8; i++) scalar_write(i, (float)(i + 1));
// 假设标量通路忘了刷回 DataCache,MTE 只看到旧值
// (我们手动把"未同步"状态模拟出来:让 g_gm 保持为 0)
memset(g_gm, 0, sizeof(g_gm)); // 模拟 DRAM 实际还是旧值
mte_read_all();
printf("标量写的期望值: 1 2 3 4 5 6 7 8\n");
printf("MTE 读到的实际: ");
for (int i = 0; i < 8; i++) printf("%.0f ", g_dma_view[i]);
printf(" ← 全是旧值!\n\n");
// ===== 方向 2:MTE 写 → 标量写 =====
printf("=== 方向 2:MTE 写 -> 标量写 ===\n");
mte_write_zeros(); // 搬运工把 GM 清零
// 标量通路以为自己在 g_gm[0] 上写了 42,但迟到的 DMA 可能盖掉
// 我们模拟"搬运工迟到":把 g_gm[0] 改回 0
g_gm[0] = 0.0f; // 模拟迟到的 DMA 写到达
printf("标量写期望 g_gm[0] = 42\n");
printf("实际 g_gm[0] = %.0f ← 被 DMA 覆盖了!\n", g_gm[0]);
return 0;
}
运行结果(标准 Linux 上即可复现这个"两个方向都不一致"的演示):
plaintext
=== 方向 1:标量写 -> MTE 读 ===
标量写的期望值: 1 2 3 4 5 6 7 8
MTE 读到的实际: 0 0 0 0 0 0 0 0 ← 全是旧值!
=== 方向 2:MTE 写 -> 标量写 ===
标量写期望 g_gm[0] = 42
实际 g_gm[0] = 0 ← 被 DMA 覆盖了!
真实 910B 上是硬件帮你"复制粘贴"了这段故事:DataCache 和 DMA 通路对同一地址的写入时序是不确定的,谁最后到 DRAM 谁就赢。
6. 真实 AscendC 代码长什么样?
❌ 错误写法(触发 bug)
cpp
// kernel 内:在 GM scratch 上做中间累加
__gm__ float *dSF32 = /* GM scratch */;
// 方向 1:标量写 GM
for (uint32_t i = 0; i < N; i++) {
dSF32[i] = computed_value; // ← 写 DataCache
}
// 方向 1 后续:MTE 读同一块 GM
DataCopy(ubBuf, dSF32, N); // ← DMA 看不到 DataCache 的新值
// ------ 或者 ------
// 方向 2:MTE 写 GM
DataCopy(dWacc, zeroBuf, V * H); // ← 异步 DMA
// 方向 2 后续:标量写同一地址
for (uint32_t i = 0; i < V * H; i++) {
dWacc[i] += partial_sum; // ← 可能被迟到的 DMA 覆盖
}
✅ 正确写法(三种策略任选一种)
策略 1(推荐):在 UB 里完成所有中间计算,根本不碰 GM
cpp
TPipe ep;
TBuf<TPosition::VECIN> eb;
ep.InitBuffer(eb, ubSize);
LocalTensor<float> ubBuf = eb.Get<float>(N);
// 全程在 UB 中计算
for (uint32_t i = 0; i < N; i++) {
ubBuf.SetValue(i, computed_value);
}
// 最后一次性 DataCopy 到 GM
DataCopy(gmOut, ubBuf, N);
策略 2:全程用标量访问,不混 MTE
cpp
// 清零:标量写
for (uint32_t i = 0; i < V * H; i++) {
dWacc[i] = 0.0f;
}
// 累加:也是标量写(同一通路 → 一致)
for (uint32_t i = 0; i < V * H; i++) {
dWacc[i] += partial;
}
策略 3:标量写后显式刷 DataCache
cpp
GlobalTensor<DT> gScratch;
gScratch.SetGlobalBuffer((__gm__ DT*)scratch);
// 标量写
for (uint32_t i = 0; i < N; i++) {
gScratch.SetValue(i, (DT)computed_value);
}
// 显式刷回 DRAM
DataCacheCleanAndInvalid<DT, CacheLine::ENTIRE_DATA_CACHE>(gScratch);
// 现在 MTE 能读到一致的值
DataCopy(ubBuf, gScratch, alignedN);
7. 修复效果
| 验证项 | 修复前 | 修复后 | 改善 |
|---|---|---|---|
| Mode B grad_input 误差(标量→MTE) | 2.80e-3 | 1.53e-5 | 183x |
| BT edge tile grad_input 误差(MTE→标量) | 1.65e-2 | 2.44e-4 | 68x |
| Mode A 精度 | 不受影响 | 不受影响 | 回归 OK |
误差从 1e-2 级别压到 1e-4~1e-5,回到 FP16 的正常精度。
8. 教训总结
| 要点 | 说明 |
|---|---|
| 同一块 GM 只能走一种通路 | 要么全程标量 gmPtr[i]=v,要么全程 DataCopy |
| UB-only 中间计算是最优解 | 既避免一致性陷阱,又省 GM 带宽 |
| DataCacheCleanAndInvalid 是兜底 | 实在要在 GM 上混用,必须显式刷 |
| 910B ≠ CPU | CPU 有 MESI 自动帮你同步,910B 没有 |
| 症状很迷惑 | 编译能过、运行不报错,只是精度莫名变差 10~100 倍 |
| 小 shape 更容易暴露 | BT=4、V=8 这种小规模反而最常触发 |
附录:什么时候应该怀疑这个 bug?
如果你看到以下 任意一条,先停下来检查代码里有没有 GM 上的标量/MTE 混用:
- 精度误差在 1e-3 ~ 1e-2(FP16 正常 ~1e-4)
- 同样的代码逻辑在 910A / 950 上没问题,只在 910B 上飘
- 消除 GM 中间缓冲后精度恢复正常
-
gmPtr[i] = v和DataCopy(..., gmPtr, ...)出现在同一地址 - 没有编译错误、没有运行错误,只是结果不对
满足其中 2~3 条,基本就是这个问题。改成 UB-only 中间计算,立竿见影。