我第一次接触 ONNX Runtime,是在一个模型训练完却怎么也跑不快的小项目里。那会儿我用 PyTorch 训练了一个图像分类模型,训练脚本跑得挺顺,一到部署环节就犯难——总不能把整个训练框架塞进生产环境吧。后来同事丢给我一个 .onnx 文件,说试试这个推理引擎,我才算真正开始认识它。这几年用它做过 CV、NLP、也跑过一些轻量化的端侧模型,回过头来看,ONNX Runtime 解决的问题其实很朴素:把模型从训练框架里解放出来,用一种统一的格式在各种各样的硬件上高效执行。
这一章我想从整体视角聊聊它到底是个什么东西、内部怎么运转、能落到哪些硬件上,以及在选型的时候该怎么权衡。有些细节在后面的章节里会单独展开,这里先把骨架搭起来。
1.1 ONNX Runtime 的定位、生态与适用场景
ONNX Runtime 本质上是一个跨平台的推理引擎。它最核心的价值在于"中间层"这个角色——上游接的是各种训练框架导出的 ONNX 模型,下游对接的是 CPU、GPU、NPU 这些形态各异的计算硬件。对开发者来说,这意味着你只需要关心模型怎么导出、数据怎么喂进去,至于底层是 Intel 的 CPU 还是 NVIDIA 的显卡,它帮你做了一层抽象。
我特别喜欢它的一点是生态的开放性。ONNX 这个格式本身是微软和 Facebook 等几家推动起来的,现在已经是模型交换事实上的标准。你从 PyTorch 导出、从 TensorFlow 导出、从 PaddlePaddle 导出,出来的都是同一个格式的模型,ONNX Runtime 都能接。这个特性在混合技术栈的团队里太有用了。之前有个项目,算法同事用 PyTorch,工程同事习惯 TensorFlow Serving,两边吵得不可开交,最后靠 ONNX 做了一层桥接,大家各写各的模型,统一导出到 ONNX,部署侧只维护一套推理代码。
适用场景这块我自己的体会是,它特别适合那些对延迟敏感、又不希望被单一框架绑死的项目。云端服务、边缘设备、移动端都能用,Windows、Linux、macOS 甚至浏览器里都有对应的运行时。当然它也不是万能的,后面讲到边界的时候我会细说。如果你的模型里有大量自定义算子、或者需要训练和推理共用一套代码,它可能不是最省心的选择——这点得提前有心理准备。
我遇到过一位做医疗影像的朋友,他们团队最初把所有推理逻辑都写死在 Python 服务里,模型一多,内存直接爆掉。换成 ONNX Runtime 之后,用 C++ 接口做了个统一的推理服务,同样的机器能同时挂十几个模型,延迟还降了三成。这类"模型多、换代快、硬件杂"的场景,基本就是它的主场。
1.2 运行时架构组成与一次推理的执行流程
我把 ONNX Runtime 的内部粗略拆成几块来理解:模型加载层、图优化层、执行调度层、还有具体的算子实现层。你拿一个 ONNX 模型进来,它先做解析和校验,把计算图读进内存;接着做一轮图优化,比如常量折叠、算子融合这些;再往后根据你配置的 Execution Provider 把图切分到不同的硬件后端上,最后调度执行。
说到一次推理的执行流程,我习惯用"准备"和"执行"两个阶段来划分。准备阶段发生在创建 InferenceSession 的时候,这时候它完成了模型加载、图优化、内存规划这些事情,是个偏重的操作。执行阶段就是每次调用 Run 的时候,输入数据进来,按照已经排好的执行计划跑一遍,输出结果。这个设计的好处是准备一次、执行多次,线上服务里 session 通常做成常驻的,千万别每次请求都新建,那个开销能把性能拖垮。
有一个我踩过的坑值得提一下:图优化的程度和你传入的 Session 配置有关系。默认的优化级别是 ORT_ENABLE_ALL,它会做比较激进的融合,大部分情况是好事。但有一次我调试一个带动态 shape 的模型,发现某些算子被融合之后 shape 推断出了问题,查了半天才发现是优化级别的事,调成 ORT_ENABLE_BASIC 就正常了。这种问题不常见,但排查起来挺费劲,早一点了解执行流程会少走弯路。
执行流程里还有个容易被忽略的环节是内存分配。ONNX Runtime 有自己的内存池机制,中间张量的分配和释放都走它自己的管理,这样能减少频繁 malloc 带来的抖动。我第一次读它的源码结构时还在想,为什么不直接用系统分配器,后来看了它在多线程、多 session 场景下的表现才明白,这层抽象是性能稳定的关键之一。
1.3 Execution Provider 与硬件后端支持
Execution Provider 是我认为 ONNX Runtime 设计里最聪明的部分之一。你可以把它理解成"硬件驱动适配层"——每一种硬件后端对应一个 EP,比如 CPU 上有默认的 CPU EP,NVIDIA 显卡有 CUDA EP 和 TensorRT EP,Intel 有 OpenVINO EP,还有针对 ARM 的 ACL EP、针对移动端的 NNAPI 和 CoreML EP 等等。你想用哪种硬件,就注册对应的 EP。
这个机制实际的运作方式是按优先级"认领"算子。你注册多个 EP 的时候,每个算子会按顺序问下去:"你能跑吗?"能跑就分给它,跑不了就往后传。这个设计让模型可以在不同硬件之间自动切分,一部分算子跑在 GPU 上,剩下的回退到 CPU。我做过一个实验,故意注册了 TensorRT EP 和 CPU EP,模型里有些 TRT 不支持的算子确实自动落到了 CPU 上,整条链路不需要你手工干预。
不过这种自动回退是有代价的。算子在不同硬件之间来回切,数据得在显存和内存之间倒腾,那个拷贝开销有时候比计算本身还大。我见过一个模型,大部分算子在 GPU 上,中间夹着俩不支持的小算子,结果整体延迟比全跑 CPU 还高。所以实际部署的时候,最好看一眼 EP 的算子分配情况,ONNX Runtime 提供了日志可以打印每个算子被哪个 EP 接走了,这个信息非常值得花时间看。
端侧的场景又是另一套玩法。移动端上 NNAPI EP 和 CoreML EP 能调用手机的 NPU 或者 GPU,功耗和延迟表现会比纯 CPU 好很多。但端侧硬件的碎片化程度很高,同一个 EP 在不同手机上行为可能不一致,这块需要做比较充分的机型覆盖测试。我个人的经验是,端侧优先保证兼容性和稳定性,性能优化放在后面,别为了榨那一点延迟把可用性搭进去。
1.4 与其他推理框架的选型对比与边界说明
聊选型就绕不开几个常被拿来对比的对手:TensorRT、OpenVINO、TVM,还有各个训练框架自带的推理能力。我不太喜欢那种"谁更强"的简单结论,因为每个工具的设计目标不一样。
TensorRT 在 NVIDIA 显卡上的性能确实猛,它做了大量针对自家硬件的极致优化,量化、层融合、kernel 自动调优都做得很深。如果你的场景是清一色的 NVIDIA GPU、模型结构稳定、追求极致吞吐,TensorRT 很难被替代。ONNX Runtime 的 TensorRT EP 其实就是把 TensorRT 当作后端来用,算是借了它的力。区别在于 ONNX Runtime 给你留了回退的余地,TensorRT 不支持的算子可以回 CPU,纯 TensorRT 部署的话就得自己想办法。
OpenVINO 在 Intel 平台上表现很出色,CPU、集成显卡、VPU 都有对应优化。ONNX Runtime 同样提供了 OpenVINO EP。我觉得这俩更像是互补关系,而不是竞争关系——你用 ONNX Runtime 做统一入口,底下根据不同硬件挂不同的 EP,这套组合在实际项目里挺常见的。
TVM 的定位更偏"编译器",它能针对特定硬件做深度的算子代码生成,灵活度很高,学习曲线也陡。ONNX Runtime 更像"工程化的推理运行时",开箱即用的程度高,社区支持好,文档也相对齐全。团队里如果没有专门做编译优化的人,ONNX Runtime 的上手成本会低很多。
说到边界,我得老实讲几个 ONNX Runtime 不太擅长的地方。自定义算子的支持虽然有扩展机制,但跨 EP 的自定义算子会比较麻烦,尤其是你想让它在 GPU 上跑的时候。再一个是模型结构特别动态的场景,比如控制流很复杂的模型,不同 EP 的支持程度参差不齐,有时候得退回 CPU 才能跑通。还有就是它对训练的支持很有限,不要指望拿它做微调。
我自己的选型习惯是先看硬件分布:如果硬件种类多、又需要一个统一入口,ONNX Runtime 基本是首选;如果硬件单一且追求极限性能,可以考虑直接用厂商的专用方案;如果团队有编译优化的能力且模型结构特殊,TVM 值得一试。这个判断没有绝对标准,更多是权衡取舍。搞清楚每个工具擅长什么、不擅长什么,比记住一堆 benchmark 数字有用得多。 pip install onnxruntime==1.17.1 pip install onnxruntime-gpu==1.17.1
nvidia-smi
nvcc --version
so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
from fastapi import FastAPI, HTTPException from pydantic import BaseModel import numpy as np, ort, time, uuid
app = FastAPI() sess: ort.InferenceSession = None
class InferReq(BaseModel):
image_b64: str
request_id: str | None = None
@app.on_event("startup") def load_model():
global sess
so = ort.SessionOptions()
so.intra_op_num_threads = 1
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
sess = ort.InferenceSession("model.onnx", so, providers=["CUDAExecutionProvider"])
for _ in range(3):
sess.run(None, {"input": np.zeros((1,3,224,224), np.float32)})
@app.post("/infer") async def infer(req: InferReq):
rid = req.request_id or uuid.uuid4().hex
t0 = time.perf_counter()
try:
x = decode_and_preprocess(req.image_b64)
y = sess.run(None, {"input": x})[0]
out = postprocess(y)
except Exception as e:
raise HTTPException(500, detail=f"rid={rid} {e}")
return {"rid": rid, "result": out, "cost_ms": (time.perf_counter()-t0)*1000}
io = sess.io_binding() io.bind_input("images", "cuda", 0, np.float32, x.shape) x_gpu = ort.OrtValue.ortvalue_from_numpy(x, "cuda", 0) io.bind_ortvalue_input("images", x_gpu) io.bind_output("logits", "cuda", 0) sess.run_with_iobinding(io) out = io.get_outputs()[0].numpy()