第 20 篇 · 第四部分 · 压缩与工程落地
端侧部署实战:ONNX Runtime 推理优化全流程
前面十九篇讲的是原理,这一篇把它们串联起来,落到"怎么让模型在真实设备上运行"。
一、先定目标:延迟、吞吐、内存三个指标
优化之前先问一个问题:你到底在优化什么?没有目标就动手,改动往往只是让某个数字略有变化,说不清是不是真的更好。端侧推理有三个指标最常被关注:
- 延迟(Latency):单个请求从进到出要多久,单位毫秒。实时性场景(需要即时返回结果)最看重它。
- 吞吐(Throughput):一秒钟能处理多少个请求,QPS 或 items/s。批处理场景看重它。
- 内存(Memory):峰值 RSS 或显存占用,决定模型能不能装进那台设备。
这三个指标常常互相制约。增大 batch,吞吐提高,但单条延迟也随之升高,内存同样增加。需要先想清楚哪个是硬约束——手机要的是低延迟和省内存,服务端离线批处理要的是高吞吐。目标定下来,后面每一步改动才有参照物。
还有个隐形的约束是设备本身。同一份模型,放在服务器 CPU、笔记本核显、手机 NPU 上,最优配置可能截然不同。目标设备是什么,往往比参数选择更能决定成败。
二、完整链路总览
后面几节走的是同一条链路,先在地图上看清楚位置:
训练 → 导出 ONNX → 量化 → 会话配置 → 图优化 → 压测
这条链路并非走一遍就能定稿。每改一个环节,都要回到压测那一步重新测量,否则你无法判断刚才的改动究竟是改善还是恶化。优化最忌讳只凭主观感觉;改完不测量,等于盲目修改。
三、导出 ONNX 时的几个关键开关
第一步还是导出,几个参数决定了后面的流程是否顺畅。
dynamic_axes 管形状是否可变。要么全动态换取更高的通用性,要么固定形状换取更好的优化,取决于你的部署流量特征。生产里常见的折中是让 batch 维动态,序列长度固定。
opset_version 要和目标运行时对得上,上一篇讲过——选运行时支持的最大值,不要超出它的支持范围。
do_constant_folding 会打开常量折叠,把能提前算好的部分在导出阶段就预先计算完成。多数情况开着有利,但少数模型上它也遇到过导出时计算错误的情况,导出后记得比对一遍输出。
torch.onnx.export(
model.eval(), dummy, "model.onnx",
input_names=["input"], output_names=["output"],
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
opset_version=14, do_constant_folding=True,
)
导出后用 Netron 打开检查,确认图结构正常,没有异常的孤立节点或断链。
导出之前还有一件小事不能忽视:模型里如果有随机的成分,比如 Dropout,得先进 eval 模式把它关掉,否则导出的图每次推理都带随机性,压测数据也会随之波动。
四、会话配置怎么调(线程数、执行提供器、图优化级别)
ONNX Runtime 的入口是一个 InferenceSession,它的构造参数基本决定了性能基调。
执行提供器(EP):告诉它用什么硬件运行。CPU 上使用默认提供器,NVIDIA GPU 上使用 CUDAExecutionProvider,Intel 平台上使用 OpenVINO EP。可以按优先级列出多个,运行时选择第一个可用的。注意不要配置错误;配置错误时它会静默回退到 CPU,看起来在用 GPU,实际仍在 CPU 上运行。
线程数:intra_op_num_threads 控制单个算子内部用几个线程并行,inter_op_num_threads 控制不同算子之间怎么并行。两个都设置为 CPU 核数并不一定最快——线程太多时,上下文切换反而带来开销,通常需要多试几轮。
图优化级别:graph_optimization_level 有 DISABLE、BASIC、EXTENDED、ALL 四档。越往上融合越激进,速度往往越好,但个别模型可能被错误优化,需要通过回归测试把关。绝大多数情况先试用 ALL,出现问题再调低级别。
import onnxruntime as ort
opts = ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.intra_op_num_threads = 4
sess = ort.InferenceSession(
"model.onnx",
sess_options=opts,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
五、量化实操:动态量化、静态量化与校准数据
量化是端侧优化里收益最直接的一步:fp32 降到 int8,体积和内存都大致变成四分之一,速度也常常随之提升。ONNX Runtime 提供三种方式。
- 动态量化:权重离线量化为 int8,激活值在运行时按需计算后再确定标度。改一个参数就能完成,连校准数据都不用准备,最为便捷。适合算力受限、又不愿额外投入的场景,常见于 Transformer 类模型。
- 静态量化:权重和激活值都提前定标度,需要一批校准数据执行一遍采集数值分布。它在 CPU 上往往比动态量化更快,但数据代表性不足时,精度容易下降。
- 量化感知训练(QAT):在训练里就模拟量化误差,让模型自己去适应。精度最好,成本也最高,得重新训练。
校准数据有一点需要注意:它不应随意选取,应贴近真实推理分布。用一百张灰度图像去校准一个处理彩色图像的模型,得到的标度整体偏离,精度自然会明显下降。
三种方式如何选择?可以先采用动态量化,几十秒就能得到一版粗略的收益;若精度下降得无法接受、又需要在 CPU 上运行,再考虑静态量化并配校准集;若还需要进一步优化精度,才值得采用需要重新训练的 QAT。
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
model_input="model.onnx",
model_output="model.int8.onnx",
weight_type=QuantType.QInt8,
)
六、图优化与算子融合
原理前面讲过,这里落到"怎么看效果"。ORT 的图优化默认开启,如有需要可以将其关闭再运行一次,对比两者的差异——这样能判断融合实际贡献了多少。
融合最典型的例子是 Conv + BatchNorm + Relu 融合为一个 Conv。对内存带宽紧张的端侧来说,省下的两次读写比省下的乘加计算更有价值。此外还有常量折叠、冗余节点清除、布局重排这些,都是运行时读取图时顺便完成的。
这里有个提醒:不是融合越激进越好。过度激进的优化偶尔会遇到运行时并不支持的组合,或者在某些硬件上生成了不如手写内核快的实现。所以优化级别这件事,最好当成一个可调项,而不是固定不变的常量。
举个例子:一个 50 层、每层都带 Norm 和激活的网络,融合之后节点数能减少三成左右,端侧的内存带宽压力顿时减轻。
七、压测与剖析:QPS、P50/P99 与内存曲线
改完必须测量。压测不是运行一遍就能宣称更快,有几点需要看清。
先进行预热。 首次推理会带上内存分配、缓存填充等一次性开销,数字往往明显偏差,不能代表稳态。先空运行十几二十轮,再开始计时。
看 P50 和 P99,别只看平均。 平均延迟理想,可能只是因为大部分请求很快,而真正决定用户体验的是 P99 那一小部分最慢的请求。P99 是平均的两三倍很常见。
关注内存曲线。 峰值内存比均值更关键,它决定会不会 OOM。批处理时特别要看内存是否随 batch 线性增长。
import time, numpy as np
feed = {sess.get_inputs()[0].name: x} # x: 一条真实样本
for _ in range(20): # 预热
sess.run(None, feed)
lat = []
for _ in range(200):
t0 = time.perf_counter()
sess.run(None, feed)
lat.append((time.perf_counter() - t0) * 1000)
print("P50=%.2fms P99=%.2fms" % (
np.percentile(lat, 50), np.percentile(lat, 99)))
想剖析哪个算子最耗时,可以打开 ORT 自带的 profiling 开关,导出一份 Chrome trace,用浏览器的 chrome://tracing 或 Perfetto 打开查看时间轴,比主观猜测可靠得多。
八、移动端与浏览器端
ONNX Runtime 不止有桌面版,还有为端侧准备的两个版本。
onnxruntime-mobile 是给 Android / iOS 用的移动版,移除了不必要的功能,体积更小,支持 NNAPI、CoreML 这些平台加速后端——把计算交给手机自带的 NPU / GPU,既省电又快速。
onnxruntime-web 直接在浏览器中运行,基于 WebAssembly,还能启用 WebGPU 后端加速。好处是模型推理可以完全留在用户设备上,数据不出本地,隐私和延迟都很理想。代价是首次加载 WASM 和模型文件有开销,需要权衡。
注意:浏览器端受限于线程和内存上限,通常只能运行中小模型,不要指望用它运行几十亿参数的大型模型。
选择哪一边取决于数据的位置。要离线、要隐私、数据不能出设备,就选择 onnxruntime-web;要追求更高性能、能接受按平台分别打包,onnxruntime-mobile 配合平台加速后端更合适。
九、一份可直接参照的清单,作个收尾
把整条链路收拢成一串动作,依次执行即可:
- 先定硬指标,写下来:目标 P99 延迟多少 ms、内存上限多少 MB。
- 导出 ONNX,opset 卡目标运行时的上限,想清形状要不要动态。
- Netron 检查一遍图结构,确认无误。
- 先做一遍量化,从动态量化起步,速度仍不满足要求再采用静态量化加校准。
- 会话配置:EP 配置正确、线程数多试几轮、图优化级别设置为 ALL。
- 压测:预热、看 P50/P99、看峰值内存。
- 用真实数据比对一遍输出,确认量化没有让精度下降太多。
- 把上面每一步的数字记下来,回归时才有参照。
回头看看这一板块的四篇:剪枝削减参数、蒸馏生成小模型、ONNX 统一格式、格式选择文件——它们各自解决一部分问题,真正在设备上运行起来,靠的是把它们串联成这条链路。模型在实验室中表现良好,和在你这台设备上表现良好,是两回事。端侧部署这项工作,本质上就是把"能运行"逐步优化到"运行得好"。
参考资料
- ONNX Runtime 官方文档. onnxruntime.ai/docs(会话配置、执行提供器与 API)
- ONNX Runtime 量化文档. onnxruntime.ai/docs/performance/model-optimizations/quantization.html(动态 / 静态量化实操)
- ONNX Runtime Web 教程. onnxruntime.ai/docs/tutorials/web(浏览器端部署)
- PyTorch 官方文档, torch.onnx. pytorch.org/docs/stable/onnx.html(导出参数说明)
- Netron 模型可视化工具. github.com/lutzroeder/netron(查看图结构与算子)