AscendC DataCopyPad 写出溢出 Bug 详解

一句话总结

AscendC 的 DataCopyPad 从片上缓存(UB)写数据到显存(GM)时,搬运单位(burst)必须 32 字节对齐
当实际数据不够 32 字节的倍数时,DMA 引擎会多写几个字节(padding),覆盖掉相邻段的数据


1. 背景知识:AscendC 的内存层次

┌──────────────────────────────────────────────┐
│            GM (Global Memory / 显存)         │  ← 大容量(几十 GB),高延迟
│                                              │
│   存放输入/输出张量:feat, offsets, out 等    │
└────────────────────┬─────────────────────────┘
                     │ DMA 搬运(DataCopyPad)
                     ▼
┌──────────────────────────────────────────────┐
│       UB (Unified Buffer / 片上缓存)           │  ← 小容量(~192KB),低延迟
│                                               │
│   存放当前正在计算的一小块数据(累加器等)       │
└──────────────────────────────────────────────┘

类比:

  • GM = 仓库(大但远,拿东西慢)

  • UB = 工作台(小但近,拿东西快)

  • DMA = 搬运工(负责仓库和工作台之间搬东西)


2. Segment Reduce 算子做什么?

把多行特征按"段"累加:

输入 feat(8 行 × 4 列):          输出 out(4 段 × 4 列):

row 0:  [ 1,  2,  3,  4] ─┐
row 1:  [ 5,  6,  7,  8] ─┘ seg 0 →  [ 6,  8, 10, 12]    (row0 + row1)
row 2:  [ 9, 10, 11, 12] ─┐
row 3:  [13, 14, 15, 16]  │ seg 1 →  [39, 42, 45, 48]    (row2+3+4)
row 4:  [17, 18, 19, 20] ─┘
                                 seg 2 →  [ 0,  0,  0,  0]    (空段,无行)
row 5:  [21, 22, 23, 24] ─┐
row 6:  [25, 26, 27, 28]  │ seg 3 →  [75, 78, 81, 84]    (row5+6+7)
row 7:  [29, 30, 31, 32] ─┘

每个段的结果写回到 GM 中 out 数组对应的位置。


3. Bug 触发场景:feat_dim = 4

feat_dim = 4(每行 4 个 float)时:

一行数据大小 = 4 × 4 bytes = 16 bytes
DMA burst 对齐要求 = 32 bytes(最小搬运单位)

所以 DMA 实际搬运 32 字节,但有效数据只有 16 字节

3.1 正常情况:写出 seg 0 的结果

out[] 在 GM 中的布局(每格 = 4 bytes / 1 个 float):

     seg 0 (16B)      seg 1 (16B)      seg 2 (16B)      seg 3 (16B)
  ┌───────────┐    ┌───────────┐    ┌───────────┐    ┌───────────┐
  │ 6  8 10 12│    │ ?  ?  ?  ?│    │ ?  ?  ?  ?│    │ ?  ?  ?  ?│
  └───────────┘    └───────────┘    └───────────┘    └───────────┘
  ↑                                    ↑
  地址 0                               地址 48

DMA 写 seg 0 结果 [6, 8, 10, 12]:
  起始地址 = out + 0
  burst 长度 = 32 字节(包含 16 字节数据 + 16 字节 padding)

  写入后:
  ┌───────────┬───────────┐    ┌───────────┐    ┌───────────┐
  │ 6  8 10 12│ PADDING!! │    │ ?  ?  ?  ?│    │ ?  ?  ?  ?│
  └───────────┴───────────┘    └───────────┘    └───────────┘
               ↑
        溢出到 seg 1 的位置!

seg 0 的 padding 覆盖了 seg 1 的前 4 个字节!

3.2 但是 seg 1 有自己的数据会覆盖回来

seg 1 有 3 行数据需要累加,计算完后写出:

DMA 写 seg 1 结果 [39, 42, 45, 48]:
  起始地址 = out + 16
  burst 长度 = 32 字节

  写入后:
  ┌───────────┬───────────┬───────────┐    ┌───────────┐
  │ 6  8 10 12│ 39 42 45 48│ PADDING!! │    │ ?  ?  ?  ?│
  └───────────┴───────────┴───────────┘    └───────────┘
                         ↑
                  溢出到 seg 2 的位置!

3.3 关键!seg 2 是空段,不写出

seg 2 是空段(offsets[2] == offsets[3],没有行需要累加)
kernel 代码:if (count > 0) { DataCopyPad(...) }  // count=0,跳过!

所以 seg 2 的内存不会被 kernel 写入,它的值保持的是 seg 1 写出时溢出的 padding 垃圾值

最终 out[] 的内容:

  ┌───────────┬───────────┬───────────┬───────────┐
  │ 6  8 10 12│ 39 42 45 48│ 0.1 -0.1...│ 75 78 81 84│
  └───────────┴───────────┴───────────┴───────────┘
   ✅ seg 0       ✅ seg 1      ❌ seg 2      ✅ seg 3
   正确            正确          被污染!       正确

这就是为什么 seg 2 应该是全 0,但实际读回来是 0.1, 0.1, 0.0, -0.1 等垃圾值。


4. 用更形象的比喻

想象你在白纸上写字,每行只能写 4 个数字(= feat_dim=4),但你用的笔每划必须写 8 个数字(= 32B burst 对齐):

纸上的格子:

  第 1 行: [  ][  ][  ][  ] | [  ][  ][  ][  ]    ← 第 2 行的位置
  第 2 行: [  ][  ][  ][  ] | [  ][  ][  ][  ]    ← 第 3 行的位置
  第 3 行: [  ][  ][  ][  ] | [  ][  ][  ][  ]    ← 第 4 行的位置
  第 4 行: [  ][  ][  ][  ] |

你在第 1 行写 seg 0 的结果 [6, 8, 10, 12],
但笔画太粗,会多写出 [垃圾, 垃圾, 垃圾, 垃圾] 溢到第 2 行!

  第 1 行: [ 6][ 8][10][12] | [垃][圾][垃][圾]    ← 溢出了!
  第 2 行: [39][42][45][48] | [垃][圾][垃][圾]    ← 也溢出了!
  第 3 行: [垃][圾][垃][圾] |                     ← 没人写,残留上一个人的垃圾
  第 4 行: [75][78][81][84] |                     ← 写了自己的数据

第 3 行(seg 2)是空段,没人去写它 → 它的值就是第 2 行溢出的垃圾。


5. 为什么 feat_dim=8 不会触发?

feat_dim = 8 时:
  一行数据大小 = 8 × 4 = 32 bytes
  burst 大小 = 32 bytes
  → burst 大小 == 数据大小,没有 padding,不会溢出!
seg 0 (32B)           seg 1 (32B)           seg 2 (32B)
  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
  │ 6 8 10 12 14 16..│  │ 39 42 45 48 51..│  │ 0  0  0  0  0 ..│
  └─────────────────┘  └─────────────────┘  └─────────────────┘

  burst = 32B = 数据大小 → 完美对齐,无溢出 ✅

6. 修复方案

方案 A:约束 feat_dim 是 8 的倍数(我们采用的)

// segment_reduce_impl.cc 中的 fast path 条件
if (ascendc::HasAscendC() &&
    op == "sum" &&
    feat_dim % 8 == 0 &&       // ← 新增:保证 burst 对齐
    feat_dim <= 16384) {
    // 走 AscendC 快速路径
}
// 否则 fallback 到 host-mediated(D2H → CPU → H2D)

优点:简单、可靠,GNN 常用 feat_dim(16, 32, 64, 128, 256)都是 8 的倍数。
缺点:feat_dim=4 等小维度无法走快速路径(但走 fallback 仍然正确)。

方案 B:Host 端分配带 stride 的输出 buffer

让每段之间留 padding,这样溢出不会影响相邻段:

seg 0 (16B data)  (16B gap)  seg 1 (16B data)  (16B gap)
  ┌───────────┬──────────┐    ┌───────────┬──────────┐
  │ 6  8 10 12│ padding  │    │ 39 42 45 48│ padding  │
  └───────────┴──────────┘    └───────────┴──────────┘
                ↑ 不影响下一段

优点:支持任意 feat_dim。
缺点:host 端需要额外的内存布局转换,复杂度高。

方案 C:用多次小 burst 写出

把一次 32B burst 拆成两次 16B —— 但 AscendC 的 burst 最小就是 32B,不可行。


7. SpMM 也有同样的问题吗?

是的! SpMM kernel(spmm_kernel.asc)的写出代码完全一样:

// spmm_kernel.asc 第 322-323 行
DataCopyPad(outGm, accumLocal,
            {1, static_cast<uint16_t>(featBurstBytes_), 0, 0});

但 SpMM 的测试用例中 feat_dim 都是 8 的倍数(8, 16, 48, 64, 128),
所以没有触发这个 bug。我们已经在代码注释中标注了这个潜在风险。


8. 教训总结

要点 说明
AscendC DMA burst 最小 32B DataCopyPadlenBurst 参数必须是 32 的倍数
UB→GM 方向无 padding 控制 不像 GM→UB 有 DataCopyPadParams,UB→GM 只有 3 参数版本
溢出只影响空段 有数据的段会被后续写出覆盖回来,但空段不会被写 → 残留垃圾
feat_dim 最好是 8 的倍数 8 × 4B = 32B,完美对齐,从根本上避免溢出
先测小维度 如果我们只测 feat_dim=128,永远不会发现这个 bug

附录:实际调试日志

=== 修复前(feat_dim=4)===
Expected:
  seg 0: 6.0  8.0  10.0  12.0
  seg 1: 39.0 42.0 45.0  48.0
  seg 2: 0.0  0.0  0.0   0.0      ← 期望全 0
  seg 3: 75.0 78.0 81.0  84.0

Actual:
  seg 0: 6.0  8.0  10.0  12.0    ✅
  seg 1: 39.0 42.0 45.0  48.0    ✅
  seg 2: 0.1  0.1  0.0   -0.1    ❌  ← 空段被 padding 污染
  seg 3: 75.0 78.0 81.0  84.0    ✅

max_diff = 0.097168

=== 修复后(feat_dim=8)===
Results: 9/9 passed
ALL TESTS PASSED!
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐