本文以某自定义 NPU 后端为例,方法论适用于任何基于 PrivateUse1 机制实现的自定义 PyTorch 后端的精度调试。
在自定义 AI 加速器后端上调试模型精度:以 Qwen3.5-0.8B GatedDeltaNet 为例
适用范围:本文以某自定义 NPU 后端为例,方法论适用于任何基于
PrivateUse1机制实现的自定义 PyTorch 后端的精度调试。
背景
Qwen3.5 是一个混合架构模型:每4层一个 GQA 注意力,其余层是 GatedDeltaNet(线性注意力)。
后者在 prefill 阶段调用 chunk_gated_delta_rule 核函数,其中包含一段逐行修正的 for 循环。
当我们把模型从 CPU 移到 NPU 上运行时,最终 logit 的 cosine similarity 从 1.0 降到了约 0.999,
看起来不显眼,但对于精确复现语言模型输出来说是不可接受的。
本文记录从”发现精度下降”到”锁定根因、写出回归测试并修复”的完整过程,
重点介绍可复用的 tensor dump 注入方法。
第一步:建立可重复的对比基线
在深入任何细节之前,先确保两件事:
- 结果可复现:固定随机种子,使用
model.eval()+torch.no_grad(),两次运行结果完全一致。 - 量化指标:用 cosine similarity 作为精度度量——它归一化了量级差异,只看方向偏差。
1 | def _cosine(a, b): |
最终 logit 相似度是一个全局指标,发现”有问题”,但无法告诉你问题在哪。
接下来需要一个能在模型内部任意位置采样的工具。
第二步:设计轻量 Tensor Dump 基础设施
核心思想
在模型源码中插入”探针”,当 dump 开关打开时将张量快照到一个全局字典。
同一套代码分别在 CPU 和 NPU 上跑,然后逐键对比。
实现(约 20 行代码)
在模型文件的模块级别(import 之后,类定义之前)加入:
1 | # ===== Precision Dump Support ===== |
设计要点:
| 决策 | 原因 |
|---|---|
detach() |
不污染 autograd 图 |
.cpu().float() |
统一设备和精度,让 CPU/NPU 结果可直接相减 |
.clone() |
快照时刻的值,不受后续 in-place 操作影响 |
_dump_store.clear() 在 enable 时 |
防止多次 forward 的数据叠加 |
全局开关 _dump_enabled |
零开销关闭,不影响生产路径 |
在模型中插入检查点
找到关键的 forward 函数,在每个中间结果之后调用 _record。
键名采用 层号 + 子位置 的层次化命名方案:
1 | # Qwen3_5DecoderLayer.forward() 中 |
1 | # torch_chunk_gated_delta_rule() 中(子函数,需要通过全局变量传递层号) |
命名规范:
- 格式
L{层:02d}.{阶段}.{子步骤}_{描述}确保字典键可排序 - 数字补零(
02d)让字典序 = 执行序 - 子函数通过
_gdr_layer_idx全局变量感知层号,在调用子函数前设置它:
1 | # Qwen3_5GatedDeltaNet.forward() 中,调用前一行 |
第三步:编写对比脚本
1 | def run_step_comparison(model, inputs): |
陷阱:字典键的排序问题
Python 默认的字符串排序是字典序(lexicographic),导致两个经典 bug:
Bug 1:L00.1.10 < L00.1.9(因为 "10"[1] = "0" < "9")
Bug 2:"_" 的 ASCII 码(95)> "." 的 ASCII 码(46),
所以 L00.1_input_norm 会排在 L00.1.9_xxx 之后!
(解决方案:将子步骤也统一用点分隔,L00.1.0_input_norm)
修复:使用自然排序,把连续数字段解析为整数再比较:
1 | import re |
第四步:分层缩小——从层到算子
粗粒度:层间对比
首先只在每个 Decoder Layer 的输入/输出插入检查点,观察哪一层首次偏离:
1 | L00.0_input 1.000002 0.000e+00 |
结论:精度损失发生在第 0 层的 GatedDeltaNet 的 intra_attn_corrected 步骤。
细粒度:子函数内部
在 torch_chunk_gated_delta_rule 内部插入每个中间步骤的检查点:
1 | L00.1.11.7_intra_attn_raw 0.999993 ← 计算 attn 初值,正常 |
关键发现:sub = attn[..., :i, :i].clone() 在 i=4 时读出了错误值。
这说明 i=2、i=3 的写入操作 attn[..., i, :i] = ... 并没有真正生效。
精确复现:最小化验证脚本
一旦锁定了可疑操作,立刻写一个 10 行的独立脚本来验证假设:
1 | a = torch.zeros(1, 2, 1, 8, 8, dtype=torch.float32, device='npu') |
第五步:深挖根因——理解 aten::select 的 fallback 行为
现象
4D 及以上张量的 tensor[..., i, :i] = value 写入失败,原张量未被修改。
调用链分析
Python 的 a[..., i, :i] = val 在 PyTorch 内部会分解为:
1 | aten::select.int(a, dim=-2, index=i) → 得到 row_view |
select 本应返回原张量的一个 view(别名),使得 copy_ 直接写入原始内存。
NPU 后端的实现
查看后端 memory.cpp 中的 select_int:
1 | at::Tensor select_int(const at::Tensor &self, int64_t dim, c10::SymInt index) { |
view_result_is_contiguous 检查结果张量的 strides 是否满足 C-contiguous 规则:
1 | shape = (1, H, C, S, S) |
因此 cpu_fallback_tensor 被触发,它把 self 拷到 CPU 执行 select,
再把结果转回 NPU——返回的是一个独立的新张量,不是原张量的 view。
后续的 copy_ 写入这个临时拷贝,原始 attn 张量毫发无损,
63 次 for 循环迭代全部静默失效。
2D 为何正常?
select(dim=0)后 strides =(1,),是连续的,走make_view_tensor路径。
第六步:修复
Python 层 Workaround(立即可用)
在 for 循环前将张量临时移到 CPU 执行,循环后移回原设备:
1 | _attn_device = attn.device |
代价:一次 D2H(chunk_size × chunk_size 的 float32 矩阵,约 16 KB)
和一次 H2D,可接受。
C++ 层根本修复(长期方案)
在 select_int 中,当结果 view 不连续时,不再 fallback 到 CPU,
而是允许后端持有非连续张量并正确处理 copy_ 的写回语义。
这需要后端整体支持非连续 stride 布局,是更大的工程任务。
第七步:写回归测试
发现了 bug 就要固化为测试,防止将来的修复引入退化:
1 | # tests/integration/models/qwen/test_select_inplace_write.py |
运行结果(修复前):
1 | PASSED test_select_inplace_write_propagates[2D-fp32] |
精确复现了边界条件:2D 正常,4D+ 全部 broken。
方法论总结
1 | 全局 cosine 下降 |
可复用的最佳实践
- 先建全局指标,再往下钻。不要一开始就看 C++ 代码。
- 检查点键名要可排序:使用数字补零 + 统一分隔符,配合自然排序函数。
- 每个检查点快照四个属性:
.detach().cpu().float().clone(),缺一不可。 - 发现可疑操作后立刻最小化:把怀疑的操作提取为 10-20 行独立脚本验证。
- 对比 schema 和 C++ 实现:
- 用
torch.ops.aten.xxx._schema查 alias 注解 - 在后端
memory.cpp/fallback.cpp里确认实际行为
- 用
- 所有发现都写成 pytest:一个 bug,一个 test case,终身受益。
常见陷阱速查
| 现象 | 可能原因 |
|---|---|
| in-place 操作后原张量不变 | 操作触发了 fallback,返回的是临时拷贝而非 view |
| 仅高维张量出错,低维正常 | C++ 的连续性检查:高维 select/slice 结果 stride 不连续 |
| Float32 比 BF16 差异更大 | 后端可能不支持 Float32,走 CPU fallback,精度取决于 tensor copy 路径 |
| 某些迭代正确,某些迭代错误 | 读-改-写循环中”读”走了正确路径,”写”没有真正生效 |
| 多次 enable_tensor_dump 后数据混乱 | 忘记在 enable 时 clear(),或 disable 前没有保存副本 |