AscendC 910B GM 标量/MTE 双向缓存不一致 Bug 详解

一句话总结

在 910B (DAV_2201) 芯片上,同一块 GM 显存地址,标量赋值(gmPtr[i] = v)和 DMA 搬运(DataCopy)之间没有硬件缓存一致性协议。两个方向都可能写丢或读错,精度误差会膨胀 10~100 倍


1. 背景:910B 的两条"内存通道"

AscendC 的 AICore 访问 GM(显存)时,其实有 两条独立的通路

┌──────────────────────────────────────────────────────────┐
│                       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:你先记,小李后搬

1. 你把"还剩 5 个箱子"写在自己的小本子上     ← DataCache 里有新值
2. 小李按门牌去仓库搬货                     ← MTE 读 DRAM,看到的是旧值 3
3. 你的小本子和仓库不一致了

场景 B:小李先搬,你后记

1. 小李刚把仓库里"3 个箱子"改成"0 个"        ← MTE 写 DRAM(异步,还在路上)
2. 你在仓库门牌上写"还剩 8 个"                ← 标量写,覆盖了 DRAM
3. 几秒后小李的 DMA 到了,把你的"8"盖成"0"   ← 你的写丢了!

两个方向都会出错


4. Bug 的两个具体方向

方向 1:标量写 → MTE 读

// 标量写:把 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 写 → 标量写

// 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 代码,但用最少的代码把"双向不一致"这件事演示清楚:

// 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 上即可复现这个"两个方向都不一致"的演示):

=== 方向 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)

// 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

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

// 清零:标量写
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

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] = vDataCopy(..., gmPtr, ...) 出现在同一地址
  • 没有编译错误、没有运行错误,只是结果不对

满足其中 2~3 条,基本就是这个问题。改成 UB-only 中间计算,立竿见影。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐