# 海光 DCU 实测:BF16 逐位一致为什么仍可能出现文本漂移
## 摘要
在海光 DCU gfx936 上优化大模型算子时,“误差很小”并不是一个足够明确的正确性结论。即使某个形状下输出逐位一致,也不代表其他真实形状、完整计算图或自回归生成过程一定保持一致。
本文使用 Qwen3.5-27B BF16 推理的本地实测数据,分析一个典型现象:融合 kernel 在 T=4096 的 full-graph 对照中完全逐位一致,并获得 1.92159x 的局部加速;但在完整服务中,10 个请求有 4 个生成文本发生变化,输出 token 总数从 1786 变为 1776。
这不是“BF16 天生不可靠”,而是说明算子正确性需要沿着真实 shape、计算图边界和生成链路逐层验证。
---
## 1. BF16 的正确性不是一个布尔值
对一个 BF16 kernel,可以设置很多层次的正确性门槛:
1. 输出没有 NaN 或 Inf;
2. 最大绝对误差在容差内;
3. 相对 L2 误差足够小;
4. 输出 BF16 bitwise equal;
5. CUDAGraph 重放结果稳定;
6. 所有生产 shape 都满足相同结论;
7. 完整服务输出长度和文本保持一致。
前几项属于算子层,后几项属于系统层。
问题在于,大模型是自回归系统。某一步 logits 的极小差异,如果恰好出现在两个候选 token 的分数边界附近,就可能改变下一枚 token。下一步的输入随之变化,后续状态和输出轨迹也会完全分叉。
因此:
> 单个张量的误差很小,不等于生成行为一定不变;单个 shape 逐位一致,也不等于所有 shape 都逐位一致。
---
## 2. 为什么归约顺序会改变 BF16 输出
RMSNorm、矩阵乘法和 Attention 都包含归约。以 RMSNorm 为例,核心项可以简化为:
~~~text
variance = sum(x[i] * x[i]) / hidden_size
output = x * rsqrt(variance + eps) * weight
~~~
数学上,加法满足结合律;有限精度浮点数并不严格满足。
~~~text
(a + b) + c 可能不等于 a + (b + c)
~~~
当 kernel 改变 row block、wave 分工或图融合边界时,归约树也可能变化。即使最终仍然写回 BF16,中间 FP32 累加的最低几位也可能不同。
这种差异通常很小,但“很小”和“逐位相同”是两个不同标准。
---
## 3. 一个看起来已经完全正确的 standalone 结果
本地测试过一条 Q/K RMSNorm、MRoPE 和 gate 提取的融合路径。对 T=4096 的完整链路,显式 full-graph 基线与融合实现结果如下:
| 项目 | 基线 | 融合实现 |
|---|---:|---:|
| 耗时 | 1.235199 ms | 0.642799 ms |
| 加速比 | 1.00000x | 1.92159x |
| Q mismatch | 0 | 0 |
| K mismatch | 0 | 0 |
| gate mismatch | 0 | 0 |
| 重复运行 | 稳定 | 稳定 |
如果只看到这张表,很容易得出“性能和正确性都已经通过”的结论。
但这里严格证明的其实只有:
~~~text
在 T=4096
在这一份 full-graph 基线下
Q/K/gate 输出逐位一致
~~~
它并没有证明所有尾部 token 长度都采用相同归约布局。
---
## 4. Shape sweep 找到了隐藏的正确性空洞
继续对真实 token 数进行检查后,结果并不是简单的“全部正确”或“全部错误”。
逐位一致的形状包括:
~~~text
T = 1 / 17 / 2004 / 2231 / 2992
3712 / 3713 / 4087 / 4095 / 4096
~~~
出现小量不一致的形状包括:
~~~text
T = 65 / 1024 / 1502 / 1674 / 1777
~~~
在 T=1502 上继续扫描 rows-per-block 为 1、2、4、8、16,仍没有找到逐位一致的布局。
这组结果说明,不一致并不是由某一个简单的 block 参数决定。编译器可能针对不同 token 数选择不同 reduction、tile 或融合计划。一个对 T=4096 完全正确的 kernel,不能自动外推到所有 T>1。
---
## 5. 完整服务给出了最终答案
该实现随后完成了一次固定 10 请求的本地服务测试:
| 指标 | 参考配置 | 融合配置 |
|---|---:|---:|
| output throughput | 11.8926923 | 12.0612512 |
| 吞吐变化 | - | +1.4173% |
| p99 TPOT | 44.9788 ms | 44.6850 ms |
| 输出 token 总数 | 1786 | 1776 |
| 文本不一致 | 0/10 | 4/10 |
| failed | 0 | 0 |
局部性能信号确实存在,p99 TPOT 也有所下降;但输出 token 数和 4 个请求的文本发生变化,因此不能把这次测试描述成“等价加速”。
这里最关键的不是 1.4173%,而是 standalone 与完整服务的判断发生了分歧:
~~~text
T=4096 full-graph:逐位一致
真实混合 shape:部分不一致
完整生成:4/10 文本漂移
~~~
---
## 6. 一个真正穿过全部门槛的对照案例
另一条 BF16 M=1 GEMM 调整提供了正向对照。它只改变 rows per block,不改变每一行的累加顺序。
三种真实权重形状中,它相对参考实现的最大差异均为 0,重复运行也保持一致。进入完整服务后:
~~~text
4K-8K: 10/10 文本 byte-identical
8K-16K: 10/10 文本 byte-identical
16K-32K: 10/10 文本 byte-identical
输出长度:三档全部一致
failed: 0
~~~
三档合计 30 个请求都通过,局部加速也稳定传递到端点。
这个对照说明,严格正确并不意味着不能提速;真正的要求是优化不能只在一个“方便测试的 shape”上正确。
---
## 7. 一套更可靠的 BF16 验证顺序
### 第一层:算术边界
- finite 检查;
- max abs、mean abs、relative L2;
- BF16 mismatch count;
- 重复运行确定性。
### 第二层:shape 边界
至少覆盖:
- T=1 Decode;
- 主要 Prefill chunk;
- 非整齐尾块;
- block 和 tile 的边界值;
- 实际请求中出现过的 token 数。
### 第三层:图边界
分别比较:
- eager 参考;
- 单独编译的 full graph;
- 原始整模型图中的实现;
- CUDAGraph capture 与 replay。
同一段数学在不同图边界中,可能被编译成不同归约树。不能拿 eager 的结果替代整模型图,也不能拿独立 full graph 替代原始 AOT 图。
### 第四层:生成边界
完整服务至少记录:
- 每个请求的输出 token 数;
- 生成文本;
- failed 数量;
- TPOT 和 TTFT;
- 重复运行是否保持同一方向。
---
## 8. 这对海光 DCU 开发者意味着什么
这组结果不应被理解成“海光 DCU 的 BF16 有特殊问题”。它真正说明的是:在 gfx936 目标环境里,kernel、DTK 编译链、Inductor/AOT 图边界和 CUDAGraph 共同决定了最终浮点执行顺序。
对海光 DCU 上游算子移植和本地优化,建议额外坚持三点:
1. 在真实 gfx936 设备上覆盖生产 shape,而不是只凭其他 GPU 的结果推断;
2. 同时保留 standalone 与原始整模型图的数值对照;
3. 把生成文本和输出长度作为 DCU 推理链路的最终验收层。
海光 DCU 论坛里讨论“算子是否正确”时,也应明确它通过的是哪一级:单 kernel、CUDAGraph,还是完整 vLLM 服务。
## 9. 结论
海光 DCU 上的 BF16 优化需要区分三件事:
1. 数学公式是否相同;
2. 浮点归约顺序是否相同;
3. 自回归生成行为是否相同。
本地数据表明,一个 T=4096 下 1.92159x、Q/K/gate 全部逐位一致的融合实现,仍可能在其他 token 形状上出现差异,并最终造成 4/10 文本漂移。
因此,算子正确性的最终定义不应停留在单个张量:
> 从真实 shape 到原始计算图,再到完整生成,整条链路都通过,才算真正正确。
---
文中数据来自同一套本地 DCU 实验记录。不同模型、编译器版本、图边界和 kernel 实现可能得到不同结果,本文结论不应外推为所有 BF16 算子的统一误差规律。