征程6 上 PTQ 掉点先别急着 QAT,我把问题拆成了 4 段

60 阅读10分钟

摘要: 模型在 GPU 上精度正常,到了 J6 做 INT8 量化却开始掉点,很多人的第一反应是换校准数据、调量化参数,甚至直接上 QAT。但实际工程中,问题可能早在 ONNX、图优化、量化转换或编译部署阶段就已经出现。本文结合 J6 工具链,把 PyTorch 模型到 J6 HBM 的精度变化明确拆成 4 段,通过逐阶段验证定位第一处误差,再决定到底该修模型、调 PTQ,还是进入 QAT。

1. PTQ 掉点了,真的是 PTQ 的锅吗?

做端侧量化的同学几乎都见过这个开场白:

PyTorch FP32    正常
ONNX FP32       正常
PTQ INT8        开始掉点

很多人第一反应是继续调 Calibration,或者干脆进入 QAT。但这种方法多半会陷入误区,因为模型从训练环境到真正运行在 征程6 上,中间远不止一次“FP32 → INT8”。

征程6 工具链把这件事拆成了多个独立检查点:模型检查、性能验证、量化转换、编译、板端部署。每相邻两段之间都可能产生误差。"PTQ 掉点"这个描述其实太粗了,它到底掉在哪一段,才决定下一步该做什么。

图 1:征程6 PTQ 推荐使用流程。图中模型检查、性能上限验证、配置生成和精度调优本身就是多个独立检查点。

所以现在碰到掉点,先把问题拆成四段:

阶段核心问题
第一段:PyTorch → ONNX模型还没进入 征程6 工具链之前是否已经产生偏差
第二段:ONNX → Optimized Float图优化或模型转换是否改变浮点模型行为
第三段:Float → INT8真正的量化误差究竟在哪里
第四段:Quantized BC → HBM → J6编译、输入和板端运行是否保持一致

这四段里,哪一段第一次明显出问题,就优先查哪一段。

这也是整篇文章唯一需要记住的原则。

第一段:PyTorch → ONNX

还没进入 征程6,误差可能已经出现了

这一段其实和 征程6 本身没有直接关系,却同样关键。

训练模型导出:

model.pth
   ↓
model.onnx

然后执行一次:

session.run(...)

模型能跑、Shape 对、结果看着也正常,于是默认:

ONNX 没问题。

实际上,“ONNX 能执行”和“ONNX 与 PyTorch 一致”完全是两个概念。

真实项目里最容易出现的问题往往也并不复杂,例如:

RGB / BGR
Resize
Normalize
0~255 / 0~1
HWC / CHW
Sigmoid
Decode
NMS

尤其是 PyTorch 验证程序和 ONNX 推理程序分别维护一套 preprocess() 时,很容易出现一种比较讨厌的 Bug:

程序完全正常,模型也有输出,但结果已经悄悄变了。

因此尽量让 PyTorch 和 ONNX 使用同一份已经完成前处理的数据,先比较原始模型 Tensor,再跑完整验证集。

例如最简单的输出检查:

diff = np.abs(torch_output - onnx_output)

print("max diff :", diff.max())
print("mean diff:", diff.mean())

单张图片主要用于 Debug,真正判断模型有没有发生变化,还是要重新计算 Accuracy、Precision、Recall、mAP 等完整指标。

检测模型还有一个额外的坑

检测模型比分类模型更容易把这种差异藏起来。

假设:

PyTorch confidence = 0.281
ONNX confidence    = 0.267

检测阈值:

conf = 0.25

两个模型最终都会画出这个框。

肉眼看:

PyTorch:√
ONNX:   √

但数值实际上已经开始发生变化。

如果后面的 INT8 再产生一点误差:

INT8 confidence = 0.243

最终立刻变成:

模型最终结果
PyTorchTP
ONNXTP
INT8FN

最后看到的是:

INT8 漏检。

但真正的问题并不是从 INT8 才突然发生。

所以第一段的结论很简单:

PyTorch 和 ONNX 没对齐之前,不要急着讨论 PTQ。

第二段:ONNX → Optimized Float

“只是图优化”也应该验证一次

如果 PyTorch 和 ONNX 已经基本一致,下一步才进入 征程6 工具链。

这一阶段我以前比较容易忽略:

Original ONNX
      ↓
Optimized Float

因为直觉上会认为:

图优化都是等价变换,不应该影响结果。

大多数时候确实如此。

但工程上需要区分两个概念:

“理论上应该一样”

和:

“实际验证过一样”

征程6 PTQ 转换过程中会产生不同阶段的模型产物,例如原始浮点模型、优化后的浮点模型以及后续量化模型。这恰好给了我们逐级验证的机会。

如果出现:

Original ONNX       正常
Optimized Float     异常

那就暂时不用继续研究 Calibration。

因为此时:

INT8
QAT
Calibration Data
Quantization Scale

都还不是第一嫌疑人。

征程6 官方的一致性分析同样采用这种“先定位阶段”的思路。QAT 链路中会明确区分 qat.pt、qat_export.pt、qat.bc、quantized.bc、nv12_quantized.bc 和最终 HBM,而不是只比较训练模型和板端模型。

*图 2:征程6 QAT/定点链路中,不同阶段模型本身就是一致性定位的重要依据。

虽然这张图来自 QAT 一致性教程,但它体现的排错方法同样适用于 PTQ:

A → B → C → D

不要只检查:

A ↔ D

而应该找到:

B 从什么时候开始不像 A?
C 从什么时候开始不像 B?

这会让问题空间小很多。

第三段:Float → INT8

到这里,才真正轮到“量化问题”

前两段确认正常以后,现在终于可以比较:

Float
  ↓
INT8

如果明显掉点发生在这里,那么“PTQ 精度问题”这个判断才真正成立。

这时不要看“Calibration 再加多少张图?”,而是先看三个方向。

3.1 Calibration 数据有没有代表性

校准集最大的价值不是“数量足够多”,而是能不能覆盖真实输入分布。

如果模型实际运行场景和校准数据差异很大,那么量化得到的 Activation Range 本身就可能不合理。

所以:

1000 张随机数据

不一定天然优于:

200 张分布覆盖更完整的数据

3.2 Activation Range 有没有被异常值拉开

假设某层绝大部分激活都集中在:

[-2, 3]

但极少量数据跑到了:

[-40, 35]

量化范围被扩大以后,有限的 INT8 表示区间需要覆盖更宽的数值范围,正常数据区域的量化精度自然会受到影响。

这也是实际项目中比较值得观察数据分布的原因。

3.3 是整个网络敏感,还是只有几个节点敏感?

这一点尤其重要。

如果模型大部分节点量化都很稳定,只有少数节点出现明显误差,那么它已经从:

“这个模型不适合 INT8”

变成了:

“这几个节点需要处理。”

征程6 QAT 调优指南同样非常强调敏感度分析和混合精度,而不是全局简单地从 INT8 切换到高精度。征程6E/M 主要采用 INT8+INT16 混合精度思路,而 征程6P/H 可以进一步利用 FP16 等配置处理难量化结构。

*图 3:J6E/M QAT 精度调优思路中,会分别从 Float、Calibration、QAT 等阶段继续缩小量化问题范围,而不是直接把整个模型切到更高精度。

官方 PTQ Debug 工具的设计也很类似:通过量化误差、敏感度和数据分布继续判断问题来自 Weight 还是 Activation,并进一步决定是否使用高精度、调整网络或进入 QAT。

*图 4:PTQ 精度 Debug 的定位流程。真正有价值的并不是“模型掉了多少点”,而是继续判断误差来源及敏感节点。

到这里,如果确定:

PyTorch → ONNX        正常
ONNX → Float          正常
Float → INT8          明显下降

那么才值得认真讨论:

Calibration
混合精度
敏感节点
QAT

第四段:Quantized BC → HBM → 征程6

模型没坏,也可能“部署坏了”

第四段是特别容易被忽略的一段。

假设:

quantized.bc       正常
HBM / J6           异常

这时候如果继续回去调整 QAT,很可能又找错方向。

征程6 的模型从定点 HBIR 到实际板端部署,还需要经过 compile,并且图像输入场景还可能涉及 NV12 前处理节点、Stride、Padding 等问题。官方 征程6 一致性教程专门把 convert、NV12 节点插入以及 compile 分成独立阶段排查。

其中非常实用的一个工具就是 hb_verifier 。征程6 的 hb_verifier 支持 ONNX、HBIR、HBM 之间进行一致性比较,可以用来快速判断问题究竟发生在模型转换的哪一段。

图 5:征程6 compile 阶段的一致性定位。quantized.bc* 正常而 HBM 异常时,可以先通过 hb_verifier 判断 compile 前后是否已经产生偏差。

这一阶段如果模型本身已经对齐,排查重点就应该转向:

Input Data
NV12 / RGB
Stride
Padding
Preprocess
Postprocess
Runtime

而不是:

Learning Rate
FakeQuant
Observer
QAT Epoch

也就是说:

板端精度异常,不一定意味着模型量化异常。

这是第四段最重要的结论。

5. 四段全部检查完,什么时候才值得上 QAT?

经过前面四段以后,QAT 的位置其实已经很清楚了。

当前状态是否优先进入 QAT
PTQ 已达到业务精度没必要
PyTorch → ONNX 已经掉点否
Optimized Float 出现明显偏差否
BC / HBM 一致性异常否
板端输入或后处理不一致否
Float → INT8 存在明确量化损失可以
Calibration 调优后仍明显掉点可以
少数量化敏感节点影响整体精度更值得尝试

征程6 官方 QAT 调优本身也不是“打开 QAT 再训练几轮”这么简单。针对不同 征程6 平台,需要根据硬件能力选择 INT8、INT16、FP16 等混合精度策略,并通过模型检查、Calibration、敏感度和 Debug 工具不断缩小问题。

所以我现在更愿意把 QAT 定义成:

确认模型确实不适应当前量化误差以后,再让模型通过训练主动适应这种误差。

而不是:

PTQ 掉点
   ↓
QAT

6. 最终目标:把“四段排查”变成自动回归

如果只部署一个模型,人工检查四段问题还可以接受。

但真实项目往往是:

v1 → v2 → v3 → v4 → ...

模型每迭代一次,都重新手动检查 PyTorch、ONNX、BC、HBM,很快就会变成重复工作。

所以我更希望最后把这套流程固定下来:

PyTorch Validation
        ↓
Export ONNX
        ↓
ONNX Regression
        ↓
Optimized Float Check
        ↓
PTQ / QAT
        ↓
Quantized BC Validation
        ↓
hb_verifier
        ↓
HBM / J6 Regression
        ↓
Report

最终先看一个简单的结果:

CheckResult
PyTorch BaselinePASS
PyTorch ↔ ONNXPASS
ONNX ↔ Optimized FloatPASS
Float ↔ INT8FAIL
BC ↔ HBM-
J6 Regression-

这样看到的第一件事情就是:

第三段 FAIL。

而不是打开几十个 Log,再凭经验猜到底哪里出了问题。

7. 写在最后

现在再遇到 PTQ 掉点,不要第一时间问:

“QAT 怎么调?”

而是把整个过程拆成四段:

第一段
PyTorch → ONNX
        ↓
第二段
ONNX → Optimized Float
        ↓
第三段
Float → INT8
        ↓
第四段
Quantized BC → HBM → J6

然后只做一件事情:

找到第一处明显发生变化的位置。

第一段错了,就查导出和前后处理;第二段错了,就查 Float 图转换;第三段错了,才真正研究 Calibration、敏感层和 QAT;第四段错了,则重点检查 compile、输入格式和板端 Runtime。

把问题这样拆开以后,很多原本看起来很玄学的“量化掉点”,其实都会变成一个范围非常有限的工程问题。

模型部署最怕的不是掉点,而是把所有掉点都叫作量化误差。