首页 / 第四部分 · 压缩与工程落地 / 第 20 篇

第 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 配合平台加速后端更合适。

九、一份可直接参照的清单,作个收尾

把整条链路收拢成一串动作,依次执行即可:

  1. 先定硬指标,写下来:目标 P99 延迟多少 ms、内存上限多少 MB。
  2. 导出 ONNX,opset 卡目标运行时的上限,想清形状要不要动态。
  3. Netron 检查一遍图结构,确认无误。
  4. 先做一遍量化,从动态量化起步,速度仍不满足要求再采用静态量化加校准。
  5. 会话配置:EP 配置正确、线程数多试几轮、图优化级别设置为 ALL。
  6. 压测:预热、看 P50/P99、看峰值内存。
  7. 用真实数据比对一遍输出,确认量化没有让精度下降太多。
  8. 把上面每一步的数字记下来,回归时才有参照。

回头看看这一板块的四篇:剪枝削减参数、蒸馏生成小模型、ONNX 统一格式、格式选择文件——它们各自解决一部分问题,真正在设备上运行起来,靠的是把它们串联成这条链路。模型在实验室中表现良好,和在你这台设备上表现良好,是两回事。端侧部署这项工作,本质上就是把"能运行"逐步优化到"运行得好"。

参考资料

  1. ONNX Runtime 官方文档. onnxruntime.ai/docs(会话配置、执行提供器与 API)
  2. ONNX Runtime 量化文档. onnxruntime.ai/docs/performance/model-optimizations/quantization.html(动态 / 静态量化实操)
  3. ONNX Runtime Web 教程. onnxruntime.ai/docs/tutorials/web(浏览器端部署)
  4. PyTorch 官方文档, torch.onnx. pytorch.org/docs/stable/onnx.html(导出参数说明)
  5. Netron 模型可视化工具. github.com/lutzroeder/netron(查看图结构与算子)