17. 图转换与算子适配
16. 模型压缩与体量门槛 把模型压到了设备肯收的体量,但体量过了不等于图能跑。训练框架导出的 ONNX 图里,有一部分算子是设备侧的算子库不实现的;还有一部分实现了,但对数据类型或形状有额外要求。这一篇讲这些算子怎样等价替换,以及更重要的一件事——替换之后凭什么说没改错。
贯穿的还是那条线:同一个流式编码器,加上一个更小、更容易看清因果的解码器。
四步流水线,每一步后面挂一道关卡
四个步骤本身不复杂:上游的 FP32 图固定形状并重写算子,做后量化,再离线编译成设备模型。真正决定这件事成不成的是后面三道关卡,以及它们各自看不见什么。
关卡一是算子集预检,离线编译器提供一个只检查不编译的模式,输出每个节点是否支持。编码器最终的预检结果是 3766 个节点全部通过。这一关很快,适合在重写之后立刻跑。
关卡二是宿主侧的等价性验证,在主机上用同一批输入分别喂重写前和重写后的图,逐路比对输出。编码器有 117 路输出,必须全比,不能只看最后一路。
关卡三是设备准入,把编译好的模型推到真机,先做一次兼容性查询,返回 0 才算收,再看绑定上的张量路数和构建耗时对不对。
三道关卡串起来仍然会漏,后面会专门说漏在哪。
设备拒收的算子怎么换
重写的原则是语义不变,不是把名字换成一个看起来相近的算子。下面三类是实际遇到的。
降维算子被适配层拒收
解码器末尾有一个去掉长度为 1 的维度的算子,适配层直接报 squeeze op input output size invalid。这一类最简单:换成等价的形状重排,把输出显式写成目标形状。张量里的字节完全一致,只是描述方式变了。
掩码不能直接删
解码器为了处理"历史不足"的情况,用负数作为占位符填充上下文,再用一组比较和裁剪算子把占位位置的贡献掩掉。这里的整数比较算子走的是 64 位整数,设备侧不收。
第一反应是把掩码整个去掉——反正正常情况下历史都是够的。这个想法是错的:上游用 −1 表示"没有历史",去掉掩码之后,−1 会被当成一个真实的索引去查表,取到的是表尾的那一行,输出悄悄变成了错的,而且只在句子开头几帧出现,很难在抽查里发现。
正确的替换是把"比较加裁剪"换成一次查表:把嵌入表前面补一行零,索引整体加 1,于是 −1 自然落到那一行零上,占位位置的贡献天然为零,整个过程走 32 位整数。语义完全一致,而且少了一组算子。
这一处重写之后,解码器在 NPU 上跑通了:每次 0.69 ms,CPU 是 2.87 ms。
整数位宽
编码器的转换在一个加法节点上失败,报 Input[0] DataType INT64 is wrong——设备侧的加法不收 64 位整数。涉及的是记录已处理帧数的几个张量和一个常量,改成 32 位整数即可,数值范围远远用不满。
另外还有一组按索引取值的算子和一个比较算子不被支持,前者按轴展开成等价写法,后者换成"大于"加"取反"。整张编码器图一共重写了 20 个节点。
偏差到多少算过
这是经验里最值钱的一条,也是最容易糊弄过去的一条。
逐位一致是最强的判据。 解码器的两处重写在 2506 组输入上做到了输出逐位相同,这种情况下可以直接宣布等价,不需要讨论阈值。能做到逐位一致的重写(形状重排、查表替换掩码、整数位宽变更)应该优先选。
浮点重排的量级要事先说清楚。 编码器在固定批次时做过常量折叠,节点从 10891 合并到 3766,加减顺序变了,所以不可能逐位一致。实测 117 路输出的最大绝对偏差是 9.59e-4,出现在一路卷积缓存上。这个量级属于浮点重排,可以接受——但判断标准必须在跑之前就定好,不能看到数字再决定它算不算合格。
最关键的一条:等价性验证不能替代准确率评测。 它只回答"图有没有改错",不回答"换了精度之后业务还能不能用"。量化带来的精度损失、热词和专有名词上的表现、低信噪比场景的退化,都要用真实音频和参考文本单独评,口径见 21. 质量评估与排错。把等价性验证通过当成"精度没问题",是这一步最常见的误判。
三道关卡各自漏掉什么
关卡设计得再整齐,也要知道每一道的盲区在哪,否则会形成一种"流程跑完了所以应该没问题"的错觉。
关卡一通过,不代表图是自洽的。 做整数位宽改写时,我漏改了一处膨胀算子的输出。宿主运行时立刻报类型不符,但离线编译器照样接受了这张图并产出了模型文件。也就是说,预检全绿和编译成功,都不能证明图的类型是一致的。从这件事起,等价性验证从"做了更好"变成了"必须做"——它是唯一能发现这类问题的关卡。
关卡二通过,不代表设备会收。 后来为了填满批次做了一版 16 路并行的图,宿主上 117 路输出全部对得上,推到设备却在构建阶段被拒,原因是注意力里的某个算子在那种形态下不被支持。宿主运行时和设备算子库是两套实现,前者通过只说明图没改错。
关卡三通过,不代表值得用。 模型最终被设备收下了,117 路对上,构建 3.67 s——然后每拍比 CPU 慢一倍多。这是 19. 为什么没有变快 要回答的问题。
另一个方向的教训:被转换器改坏的图
重写之外,还有一种更隐蔽的情况——转换工具自己把图改错了。
解码器在走在线通路时,转换器的一个激活函数优化会把裁剪算子合并掉,产出的图在 CPU 上数值就是错的,偏差从 25% 到 364% 不等。这件事意味着上一轮基于这张图得出的"解码器在 NPU 上表现如何"的结论,全部是在一张坏图上测的,没有参考价值。
发现它只靠一件事:转换之后,在宿主上拿同一批输入把转换前后的输出比一遍。如果只测"能不能加载、跑多快",这个问题可以潜伏很久——它不崩溃、不报错、速度也正常,只是答案不对。
所以验证的位置要卡在转换之后、性能测试之前。性能数字再漂亮,也证明不了数值是对的。
设备侧的两个工程约束
这两条和算子无关,但不先解决就没法在真机上验证任何东西。
商用系统上不能推一个可执行文件上去跑。 自己编译的 ELF,即便签过名、即便换一种方式加载,都会被系统的安全策略拒绝。所有设备侧探针必须做成应用内的能力,由应用进程发起。这改变了整个验证方式的设计:探针要能被参数化启动、把结构化结果写进日志,再从主机侧捞回来。
离线模型所依赖的基础库必须在进程内保持加载。 这个设备上,运行时要先把一个基础库装进进程,才会把离线执行设备列进可用列表。加载之后立刻卸载,设备就从列表里消失——排查时很容易误判成"这台机器不支持"。这个库只存在于一套系统镜像里,所以用动态加载而不是链接。
总结
算子适配不是把不支持的名字换成支持的名字,而是在保持语义的前提下改写图,再用独立的验证证明没改错。替换时优先选能做到逐位一致的写法;做不到时,偏差的可接受量级要在跑之前定好。流水线上的三道关卡——算子集预检、宿主等价性验证、设备准入——各有盲区:编译成功不代表图自洽,宿主通过不代表设备收,设备收下不代表值得用。转换工具自己改坏图这种情况确实存在,所以验证必须卡在转换之后、性能测试之前。
图改完了、设备收下了,接下来要回答的是那些性能数字到底怎么来的。
下一篇:18. 时延、功耗与内存怎么测。
