搞AI模型部署,第一步绕不开的就是推理框架选型。最近我把PyTorch原生推理、ONNX Runtime、TensorRT、OpenVINO这四套方案放在同一个模型上做了横向性能对比,过程中踩了不少坑,也整理出一套相对可复用的测试方法。这篇文章适合正在做模型部署、或者正在为上线选型犹豫的同学,尤其适合刚接触推理优化的人,读完之后你至少知道该从哪些维度对比框架,也明白为什么不能简单说“谁谁谁最快”。
1. 为什么推理框架的性能对比总是说不清
1.1 训练和推理是两套完全不同的逻辑
训练阶段我们看重的是模型能不能收敛、梯度传播是否灵活、网络结构是不是方便修改,所以更依赖PyTorch这类动态图框架,随时可以打印中间结果,也可以按条件改变计算路径。但推理阶段的核心诉求完全不同,它要的是低延迟、高吞吐、确定性的计算过程,以及尽量小的内存占用。训练时那些方便的动态特性,在推理时反而成了性能包袱。
这就是为什么拿PyTorch直接做线上推理,往往会发现CPU占用高、GPU利用率不稳定、延迟波动大。不是说PyTorch不好,而是它本来就不是为极致推理优化设计的。推理框架做的核心事情,是把模型先固化成一个相对静态的计算图,然后在这个静态图上做算子融合、内存复用、常量折叠、指令集优化,甚至把浮点精度从FP32压到FP16或者INT8。这些优化手段,在训练框架里很难放开手脚去做,因为训练还要求随时能回传梯度,计算图也不能随便改动。
1.2 “哪个框架最快”本身是个伪命题
我经常遇到有人问“ONNX Runtime和TensorRT哪个快”,这种问题我通常不好直接回答。因为“快”这个词脱离具体条件谈没有意义。同一个模型,在CPU上可能OpenVINO优势明显,换到GPU上又可能是TensorRT一枝独秀;输入分辨率从640改成1280,算子布局变了,排名可能又会变化;模型里如果有自定义算子,某个框架可能直接不支持,只能回退到很慢的默认实现。
更麻烦的是,框架之间做性能对比时,测法不同,结论也可能完全相反。用固定batch size连续跑1000次,和模拟真实线上请求那种忽高忽低的输入,测出来的延迟分布完全不是一回事。用单线程跑还是多线程跑,开不开动态shape,用不用FP16,预热多少轮,测出来的数据都可能有显著差异。所以与其纠结谁最快,不如建立一套能复用的测试方法论,把“哪个框架更适合我的场景”这个问题变成一组可以量化的数据。
2. 主流推理框架的定位差异
2.1 四个框架分别擅长什么
PyTorch原生推理,最大的价值是作为基线。它能保证模型的数值逻辑和训练时一致,算子支持最全,动态shape完全不受限制。缺点是性能通常不是最优,而且每次加载模型都要重新构建计算图,启动开销比较大。适合小流量验证、临时服务、或者在线调试时用。
ONNX Runtime属于跨平台中间层方案。它吃的是ONNX格式,很多训练框架都能导出到这个格式。这个框架的优势是算子覆盖广、支持CPU和GPU、部署集成简单,踩坑概率相对低。性能上,它在CPU上有明显优化,在GPU上也能吃到一些通用优化,但跟硬件针对性极强的加速库比,还是有差距。
TensorRT是面向特定GPU硬件的垂直优化方案。它会把模型编译成engine文件,这个过程会针对具体显卡架构、显存带宽、算子执行方式做深度调优。性能极佳,代价是构建engine时间较长、算子支持范围有限,遇到不支持的算子就只能做等价替换或者曲线救国。
OpenVINO则更侧重于CPU和边缘设备,它对x86指令集的利用非常充分,也支持部分GPU。部署包体积不小,但推理性能确实可以,尤其是在Intel平台CPU上,性价比很高。如果项目主要跑在x86 CPU上,它往往是最省事的提速方案。
2.2 参数维度对比:一表看懂差异
| 框架 | 输入格式 | 计算图类型 | 量化支持 | 动态shape | 部署复杂度 | 典型优势 |
|---|---|---|---|---|---|---|
| PyTorch原生 | pt/pkl | 动态 | 有限 | 完全支持 | 低 | 调试灵活,生态完整 |
| ONNX Runtime | onnx | 静态+动态轴 | FP16/INT8 | 需配置 | 中 | 跨平台,算子覆盖广 |
| TensorRT | onnx→engine | 静态 | FP16/INT8 | 需指定范围 | 高 | GPU上性能极致 |
| OpenVINO | IR(xml/bin)/onnx | 静态 | FP16/INT8 | 部分支持 | 中 | CPU与边缘设备优化好 |
这张表只是粗略定位,不能当作绝对标准。实际选型时还要看具体模型里用什么算子、目标服务器是什么架构、线上流量是否波动、是否需要动态batch,这些因素往往比框架本身的基准性能更影响最终效果。
2.3 框架选型的底层逻辑
我的经验是,先不要急着比较性能,而是先确认这个框架能不能完整跑通你的模型。一个再快的框架,如果模型里有几个关键算子不支持,最后只能靠CPU回退,那就没什么意义了。所以选型第一步是看算子兼容性,第二步才是性能。
如果项目要部署到多种硬件上,比如有CPU服务器也有GPU服务器,未来还可能迁移到其他平台,那ONNX Runtime作为统一中间层更合理。如果硬件平台长期固定,GPU为主且性能压力大,TensorRT值得投入。如果主要跑CPU,Windows或Linux都有,OpenVINO通常能拿到很不错的收益。PyTorch原生则一定要保留一个版本,它是最好的调试对照物,任何框架跑出的异常结果,最后都回到它身上验证。
3. 一套可复用的推理性能测试方法
3.1 测试前的三项准备
我每次做框架对比,都会先把这三件事搞定,省得后面反复返工。
第一,把模型导出成稳定的中间表示。不管是torch.jit trace还是onnx导出,都要保证计算图固定下来,不能每次加载都重新走一遍动态图构建。这一步很重要,否则测出来的启动时间和首帧延迟完全失真。
第二,确定输入分辨率和维度范围。固定分辨率最省事儿,但如果线上会有多种输入尺寸,必须提前测动态shape支持。TensorRT对动态shape的处理方式是在构建engine时指定允许的范围,比如宽高从512到1288,而ONNX Runtime是设置动态轴,两种方式的行为不一样,要分开测。
第三,锁定精度基线。比如把PyTorch原生FP32的输出结果存下来,作为精度参照。然后在每个框架上都用相同输入跑一遍,对比输出差异。只有精度差异在可接受范围内,后面对比延迟和吞吐才有意义。
3.2 要测哪些指标才够用
只看平均延迟是远远不够的。线上服务的核心指标是P95、P99延迟,因为平均延迟很容易被少数慢请求拉高,但满足不了大部分用户的真实体感。P99能反映出在峰值流量下,系统最差的那些体验是否还扛得住,这点对实时交互类服务尤其关键。
吞吐量也要分场景看。一种是一个batch只跑一条样本,模拟单请求连续到达;一种是固定batch size,比如batch=8、16、32,模拟服务端把多个请求拼在一起推理。后者往往能显著提高GPU利用率,但也会带来排队延迟,所以要在吞吐和P99之间做一个取舍。
内存占用同样不能忽略。我遇到过模型跑得好好的,但显存一直在涨,最后OOM的情况。每个框架对内存池的管理策略都不一样,TensorRT一般会把显存一次性申请到位,看着占用高,但稳定;有些框架则是按需申请,虽然峰值低,但可能出现碎片化。这些都要在测试里观察。
3.3 一个简单的计时脚本示例
下面这个脚本不是任何一个框架的完整调用代码,而是一个通用的计时框架,你可以分别往fn里塞入ONNX Runtime、TensorRT、OpenVINO的推理函数,统一用同一套逻辑测延迟分布:
import time def measure_latency(fn, input_data, warmup=20, repeat=100): # 先预热,避免把第一次初始化的耗时算进去 for _ in range(warmup): fn(input_data) timing = [] for _ in range(repeat): t0 = time.perf_counter() fn(input_data) t1 = time.perf_counter() timing.append((t1 - t0) * 1000) # 转换为毫秒 timing.sort() return { "avg_ms": sum(timing) / len(timing), "p50_ms": timing[int(len(timing) * 0.50)], "p90_ms": timing[int(len(timing) * 0.90)], "p95_ms": timing[int(len(timing) * 0.95)], "p99_ms": timing[int(len(timing) * 0.99)], }用time.perf_counter而不用time.time,是因为前者精度更高,适合微秒毫秒级别的测量。预热20次是我常用的值,如果模型比较大,可以提高到50次。还有一点要注意,很多框架会在前几次推理时触发懒加载或者缓存分配,所以预热轮数宁可多不要少。
3.4 测试时的变量控制
框架对比最怕的就是变量没控制好,测了半天不知道差异来自框架还是环境。首先是硬件环境要固定,CPU就固定使用同一个物理核集合,GPU就锁定同一块卡,别让其他进程抢资源。其次,线程数要显式设置,比如ONNX Runtime的session_options.intra_op_num_threads,OpenVINO的num_streams,TensorRT的streams,这些参数对性能影响很大。
还有一个隐形坑是CPU降频和温度控制。连续长时间跑高负载推理,CPU温度上来之后主频会下降,导致后面测试的数据越来越难看。我一般把测试轮次拆成多段,跑一段休息一下,或者直接观察CPU频率,确认没有明显降频再记录数据。GPU也有类似问题,长时间满载后Boost频率会回落,所以最好在冷启动和稳定运行两个状态各测一轮,取代表性数据。
4. 实际对比数据与解读方法
4.1 这次测试的模型和环境
我用的是一个自己搭的双分支目标检测模型,姑且称为模拟项目X。模型结构不算复杂,主体是卷积加特征金字塔,最后有分类和回归两个分支。输入固定为1x3x640x640,测试CPU和GPU两种环境。
CPU环境是某款8核16线程的x86服务器CPU,GPU环境是一块8GB显存的CUDA显卡。软件上,PyTorch用2.x版本,ONNX Runtime 1.x,TensorRT 8.x,OpenVINO 2023版。量化对比时,FP16和INT8都用了各框架的官方校准接口,校准数据是从验证集里抽的200张图。
这里必须强调,下面这组数据只代表当时的特定环境,换一张卡、换一个输入尺寸,绝对数字和相对趋势都会变,不建议直接拿来做你项目的选型依据。但解读方法是可以复用的。
4.2 一组相对测试结果表
| 方案 | CPU平均延迟(ms) | GPU平均延迟(ms) | 说明 |
|---|---|---|---|
| PyTorch原生 | 118.6 | 8.2 | 作为基线,数值未优化 |
| ONNX Runtime | 72.3 | 5.6 | CPU提升约39%,GPU提升约32% |
| OpenVINO | 46.8 | 不适用 | CPU提升约60%,未启用GPU后端 |
| TensorRT | 不适用 | 2.8 | GPU提升约66%,FP16下进一步下降 |
另一个我有印象的数据是GPU上单流吞吐。PyTorch原生大约每秒122帧,ONNX Runtime能到178帧,TensorRT能到290帧左右。把batch size从1调到8之后,TensorRT的吞吐能进一步翻倍,但这需要模型本身支持动态batch,并且后处理逻辑要跟上才行。
4.3 为什么TensorRT能在GPU上做到这么低
TensorRT能把858毫秒的延时做到2.8毫秒,靠的不是某一个单一优化,而是很多优化叠加在一起。它会做算子融合,把一个卷积后面的激活函数、批归一化直接并进卷积计算里,减少显存读写;会选择更合适的kernel实现,针对不同的卷积尺寸和显卡架构挑最快的算法,甚至把某些小算子合并成自定义kernel;还会在构建engine时对显存重复使用,减少分配释放的损耗。
这也解释了为什么TensorRT的构建时间很长。它本质上是在做编译期搜索,要为每个算子选kernel、试不同tactic、做精度验证,这个过程占用的时间远大于一次推理。如果你每次启动都要重新构建engine,那启动延迟可能比推理本身还大,生产环境一般都会把构建好的engine序列化到磁盘,加载时直接反序列化。
4.4 量化收益与精度损失
在同一块GPU上,把TensorRT的engine从FP32切到FP16后,延迟通常还会再降30%到40%,精度损失在我这个检测模型上几乎可以忽略,mAP掉点在0.2%以内。切INT8就更敏感了,虽然延迟还能再降一些,但如果不做校准,直接拿FP32的权重强制转INT8,掉点可能超过3%,甚至出现个别类别直接失效的情况。
做INT8校准的关键是校准数据要覆盖真实分布。如果校准集是从训练集里随机抽的,但线上输入光照、噪声、目标大小都跟训练集差异很大,那量化后精度会很难看。我习惯的做法是先用验证集跑一遍,统计中间特征值的分布范围,再选一批覆盖极值的样本做校准。精度掉点超过1%时,优先检查量化敏感层,这部分可以配合TensorRT的per-layer统计报表来看。
5. 部署选型建议与优化路线
5.1 按场景选框架:不用追求最强,匹配就行
如果做的是低延迟在线推理,比如交互式应用、辅助驾驶、实时音视频处理,GPU环境下TensorRT是优先级最高的选项。它把单帧延迟压得很低,且P99相对稳定,但前提是你接受较长的离线构建时间和偏高的运维复杂度。
如果是高吞吐离线任务,比如批量审核、批量打标、离线特征抽取,那动态batch比框架本身更重要。TensorRT配合动态batch能吃到很大的显存复用收益,ONNX Runtime配合外部动态batching也有不错表现。这类场景对单帧P99要求不高,反而要关注单卡每秒能处理多少样本。
如果是边缘侧或者纯CPU环境,OpenVINO和ONNX Runtime更合适。OpenVINO对x86指令集优化明显,ONNX Runtime在跨平台和算子支持上更稳妥。移动端的话,还要单独看TFLite和CoreML那些生态,但不在这次对比范围内。
5.2 框架以外的通用优化,别把顺序搞反
我见过很多同学一上来就研究怎么把延迟从5毫秒压到4毫秒,却忽略了输入预处理和后处理时间可能比推理本身还长。推理框架对比,永远要先测端到端延迟,把预处理、推理、后处理三段拆开分别计时,才知道瓶颈到底在哪。
预处理阶段常见的优化包括:把图像解码从CPU转到GPU、用内存池复用中间tensor、减少cvtColor等重复转换。后处理阶段常见的优化包括:把阈值筛选、NMS改到GPU上做,或者用更轻量的一次性排序替代多重循环。这些优化对整体效果的影响,经常比换一个推理框架更明显。
另一个容易忽略的点是任务本身的时间预算。如果线上要求单帧处理不能超过10毫秒,而模型已经占了8.5毫秒,那与其纠结框架,不如先考虑降低输入分辨率、减少特征金字塔层数、替换掉重算子的组合这些模型侧改动。框架只能让你在当前模型结构下跑得更快,但模型结构的冗余是它消除不了的。
5.3 性能瓶颈排查的标准步骤
用框架自带的profiler是必须的。ONNX Runtime可以打开verbose日志按算子统计耗时,TensorRT有nvidia的profiling工具,OpenVINO也有benchmark_app和per-layer耗时输出。拿到这些数据后,先找耗时最高的几个算子,再逐个确认有没有被融合、有没有回退到CPU、有没有频繁申请内存。
如果发现GPU利用率低,先看数据搬运是不是瓶颈。很多模型算得快,但输入图像每次用cpu.numpy()拷贝到GPU,再等结果拷回来,这部分显存传输会严重拉低有效吞吐。解决办法是把预处理统一放在GPU上,用GPU tensor直接作为模型输入。
如果发现CPU占用高,且不需要GPU,那就要看线程数设了多少。ONNX Runtime默认线程数可能跟物理核数不匹配,OpenVINO的stream参数也要根据实际并发请求数调整。线程开太狠会导致上下文切换开销大于计算收益,开太少则吃不满多核,这都需要快速试验。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 首帧延迟特别高 | engine初始化、缓存未生效、懒加载 | 预热数次再计时,生产环境常驻模型 |
| TensorRT构建an出来的engine反复失效 | 显卡型号、驱动版本、计算能力不一致 | 在目标机器上构建engine,不要跨机器复制 |
| 动态shape输入时延迟剧烈波动 | shape变化触发重新选kernel或重构 | 固定常见尺寸,或单独测试每种尺寸 |
| ONNX Runtime精度和PyTorch不一致 | 算子精度设置、自动混合精度 | 对比逐层输出,检查是否开启了FP16 |
| OpenVINO在部分机器上报错 | 指令集不兼容 | 换兼容模式或更新版本,打印详细日志 |
| GPU利用率低但CPU很高 | 数据搬运频繁、算子不支持GPU | 在GPU上做预处理,检查算子回退 |
这张表里的每一条,我几乎都在模拟项目X的测试过程中遇到过,不是凭空写的。遇到问题时,第一反应应该是复现最小场景,而不是对着复杂模型瞎猜。
6.2 我踩过的几个真坑
第一个坑是TensorRT的动态shape范围给得太宽。我一开始把输入尺寸范围设成从320到1920,结果构建出来的engine体积大了一倍,而且某些shape组合下延迟变得异常高。后来把线上真实使用到的尺寸统计了一遍,只保留几个档位,engine小了,延迟也更稳定。
第二个坑是量化后的精度差异不能用单张图判断。最开始我用一张图对比,发现TensorRT INT8的结果和PyTorch FP32差异很小,就放过了。结果在验证集上整体跑一遍,发现某些小目标漏检率提升了不少。现在我对任何量化结果,都至少跑完整验证集做统计对比,绝对不看单张图的目测效果。
第三个坑是测试时用了同一份输入tensor,但框架内部会修改数据。比如ONNX Runtime如果设置了合适的配置,可能会复用输入buffer,导致第二次推理输入的其实是上一轮输出做出来的脏数据。我的习惯是每个框架测试时都重新生成一份输入,或者每次循环深拷贝一份,保证输入一致。
第四个坑显存泄漏比较隐蔽。我测试那个检测模型时,TensorRT用了流式处理,但后处理逻辑里对GPU tensor反复做cpu()转换,导致显存每次分配不释放。后来改成显存复用池,问题才解决。这种问题靠延迟数据看不出来,只有持续监控显存占用才会发现。
6.3 有价值的几点心得
就我的使用体验来说,ONNX Runtime是验证模型导出正确性的利器。不管最终部署用什么样的引擎,我都会先用ONNX Runtime跑一遍,因为它的算子兼容性广且安装简单,如果这一步出来的结果和PyTorch不一致,大概率是导出过程出了问题,不用急着怀疑部署框架。
性能测试一定要闭环。每一次改动,无论是换了框架版本、加了量化、改了batch size,都要重新完整跑一遍流程,不能只盯着某个延迟数字看。我常用的做法是把测试脚本固定下来,输出一个标准报告,包含P50、P95、P99、平均延迟、吞吐、显存峰值和精度差异,然后不同方案之间只做这一个报告格式的横向对比。
最后再说一点,框架版本的差异也值得留意。ONNX Runtime小版本更新经常带来算子IR变化,TensorRT版本不同则生成的engine不兼容,升级时一定要重新验证模型输出。我一般会锁定一个稳定版本组合,在项目周期内不做无谓升级,除非遇到算子缺失或bug,这样能少踩很多坑。
多提一个实测中节省调试时间的小技巧:给每个部署方案都保留一个“黄金快照”,包含模型文件、配置文件、测试脚本和当时的性能报告。这样无论过了多久,还是换了人来维护,都能快速复现当时的结论,尤其是当模型结构更新后想判断是否要升级框架时,这个黄金快照能省下大量重复测试的时间。