1. 从一次线上频繁超时说起:为什么同一个人要测推理框架
做 AI 模型推理框架性能测试这件事,起因是我在某线上视觉识别服务里遇到了一个很现实的卡点:同一个训练好的模型,从 A 框架切到 B 框架,单次推理延迟从 120ms 掉到了 40ms,我以为捡到宝了,于是顺手在另外一台机器上复测,结果数字几乎反过来。这种“换个环境结论就翻车”的经历,让我意识到 AI 模型推理框架对比绝不能拿一两个跑分点就说事。真正值得测的,不是谁快谁慢,而是框架在不同硬件、不同模型结构、不同业务负载下到底怎么表现,以及它背后的优化策略是不是适合你的场景。这篇文章就是我自己做了一轮相对完整的推理框架性能测试后整理的对比思路、实测数据和踩坑记录,适合正在做模型部署、算法工程化,或者被线上推理延迟和吞吐折磨的开发者参考。
所谓“推理框架”,一般指把训练好的模型落地到 CPU、GPU 或边缘设备时使用的那一层运行时,常见的有 ONNX Runtime、OpenVINO、TensorRT、TFLite、NCNN 等。它们都声称能加速推理,但加速的来源完全不同。有的是做算子融合和常量折叠,有的是利用底层硬件指令集,有的是针对特定 GPU 架构做内核级调优。不同框架吃透了不同硬件的能力边界,而你自己的模型和部署环境是不是正好落在那条优势区间里,决定了最后的收益是 60% 还是 0%。
1.1 为什么同样的模型在不同框架下会“变形”
我早期有个误解,以为推理框架只是把模型加载进来,按图执行而已。实际上框架在加载模型后,并不会老老实实按原始图的顺序跑算子。它会先把计算图做一遍优化:把 Conv + BatchNorm + ReLU 这种相邻算子合并成一个算子,把多个小张量操作重新排序,甚至改变中间结果的内存排布方式。
这就导致了“变形”。同一个 ResNet50 结构,在 ONNX Runtime 里可能被拆解成几十个融合后的算子节点,在 TensorRT 里则可能被重构成完全不同的执行序列,在 NCNN 里又有一套基于轻量化内存池的规划。这些优化并非在所有模型上都等效。比如模型里大量存在动态 shape 的地方,框架不容易做静态内存规划,优化空间就会缩小;模型里如果有太多小众算子,框架甚至需要退回低效的通用实现。
我做测试时发现,一个模型被不同框架加载后,首层推理的耗时甚至和后续稳定后的耗时差出几倍。原因就是图优化、内存分配和内核选择都在第一次调用时才真正生效。这也是为什么衡量框架性能不能只取第一次运行的结果,后面在测试口径里会详细说。
1.2 对比前必须先统一的测试口径
框架本身只是引擎,真正影响结果的是外部条件。我在开始对比前,花了不少时间确认测试口径。否则后面所有数据都没有意义。
我定了三条硬规则。第一,预热轮数必须一致,我采用先跑 20 次预热再接 100 次正式统计的方法,取的是稳态均值;第二,线程数和并发度必须固定,CPU 场景下框架对线程池的控制逻辑差别很大,如果不约束,ONNX Runtime 可能自动把线程开满,OpenVINO 可能有自己的调度方式,这会让比较失真;第三,每轮测试之间必须重建相同的进程环境,因为我发现连续多次测试同一个框架时,内存页缓存和 GPU 显存池会导致后面的结果越来越好,这种累积效应必须隔离。
讨论到这儿,可以明确这套对比的适用人群了:如果你只是调一个 Demo,用哪个框架都无所谓;但如果你在做正式部署,需要预估容量、设延迟水位、压最大吞吐,那这份测试思路和结果应该能帮你少走很多弯路。
2. 我自己搭的整套对比方案:环境、模型与测量方式
2.1 测试环境配置与公平性保障
硬件环境我用了两类平台,一类是双路 CPU 服务器,主要跑 CPU 推理;另一类是单张 GPU 显卡的机器,主要跑 GPU 推理。为了不让结果被硬件差异干扰,我把不同框架的同一场景全都跑在同一台机器上,并且固定 CPU 频率策略为 performance 模式,避免省电策略在测试过程中动态降频。
内存方面,CPU 机器配置了足够大的内存,GPU 机器也预留了充足的显存,确保测试过程不会因为内存交换或者显存溢出导致结果抖动。操作系统层面,我关闭了会影响调度的高精度定时器相关的电源配置,并对测试进程绑定了固定 CPU 核心。这些细节看起来琐碎,但实际测出来,不绑核和绑核之间可能有 20% 以上的差距,尤其在多路 CPU 环境下,NUMA 访问造成的跨节点内存访问会严重影响时间。
另一个容易被忽略的是依赖版本。深度学习框架的版本更新非常频繁,ONNX Runtime 从 1.14 升到 1.16,某些 CPU 算子的实现就换过好几轮。我专门把每个框架的版本号固定下来,并且记录了 CUDA、cuDNN 的版本组合。如果后续要复现这个测试,版本组合必须完全一致,否则复现出来对不上是很正常的。
2.2 模型选择:为什么要覆盖这些类型
我没有只挑一个网红模型来测,而是选了三个有代表性的模型结构:ResNet50 作为 CNN 图像分类的代表,YOLOv5 系列作为目标检测的代表,BERT-base 作为 NLP 文本理解的代表。这三类负载的算子结构差异很大。ResNet50 是典型的计算密集型 CNN,包含大量卷积和 BatchNorm 操作;YOLOv5 则混合了卷积、上采样、拼接等多种算子,并且有比较多的中间结果流动;BERT-base 的自注意力结构依赖大量矩阵乘法和 gather 类算子,对内存带宽要求很高。
选择这三者的意义在于,它们能分别暴露框架在不同优化层次的能力。如果你的业务模型恰好也是由这些算子堆叠出来的,那测试结果可迁移性就会强很多。如果业务模型非常冷门,全是自定义算子,那你更需要关心框架对未知算子的兜底能力和扩展接口是否好用,跑分类模型的分数反而不必太当回事。
2.3 延迟、吞吐、内存占用三个指标的获取方式
延迟指标我取的是单次请求从输入进框架到输出出来的耗时,用毫秒记录;吞吐指标则区分两种情况,一种是在固定并发下测每秒能处理多少张图或多少条文本,另一种是单线程连续推理时测最大吞吐,两者在工程上的含义不同。内存占用指标包括进程的常驻内存和推理过程中峰值内存,CPU 场景还要额外看线程数对内存的影响。
测量工具我没有依赖框架自带的 benchmark 脚本,而是写了一个统一的 C++ 测试程序,通过框架提供的高级 API 加载模型,再对相同的输入数据做多次推理。统一测试程序的好处是,所有框架都走“加载模型 + 准备输入 + 执行推理 + 取输出”这条相同逻辑链,避免各自示例代码里针对自身特性额外加戏,比如有些示例会把第一次推理当成预热,但不会统计预热耗时,这些细节不一致会导致数据很混乱。
吞吐量的计算我采用连续压测法:先启动多个线程同时发送推理请求,每个线程内部循环执行推理,运行固定时长后统计总推理次数,再除以总时间。这样测出来的数据代表一种接近真实服务调用的饱和状态,而不是单次延迟的简单倒数。
3. 四类框架的实测数据与各自脾性
3.1 CPU 侧:ONNX Runtime 与 OpenVINO 的正面交锋
CPU 场景测试里,我选择了 ONNX Runtime 和 OpenVINO 两个框架作为主要对比对象。测试模型是 ResNet50 和 BERT-base,单 batch 输入。
从稳态平均延迟来看,OpenVINO 在 ResNet50 上的优势比较明显,大约能做到 ONNX Runtime 的八成耗时,但是在 BERT-base 上两者的差距缩小了很多,ONNX Runtime 甚至在某些输入长度下反超。这个结果说明 OpenVINO 的优化重点偏向视觉模型里的卷积算子,它利用了 CPU 上的特定向量指令集和自研的算子库;而 BERT 这类矩阵乘法密集的模型,更多依赖通用矩阵乘库的性能,OpenVINO 相对来说没有把全部功力用在这上面。
下面这组数据是我个人的实测值,仅代表当时固定软硬件版本下的结果:
| 模型 | 框架 | 平均延迟(ms) | 吞吐量(req/s) | 峰值内存(MB) |
|---|---|---|---|---|
| ResNet50 | ONNX Runtime | 12.8 | 78.1 | 210 |
| ResNet50 | OpenVINO | 10.3 | 97.0 | 235 |
| BERT-base | ONNX Runtime | 27.5 | 36.2 | 680 |
| BERT-base | OpenVINO | 29.1 | 34.1 | 710 |
从这个结果里能看出一个规律:OpenVINO 的图优化很激进,它会尝试重构整个子图,但重构后的执行计划如果包含太多跨内存区域的数据搬移,实际收益就会被吃掉。BERT 这类模型中间张量多,内存访问开销占比高,所以 OpenVINO 的优势就不明显。反过来,ONNX Runtime 的表现虽然四平八稳,但在 CPU 多线程调度上会更保守,资源占用也比较平稳,这在某些稳定性要求高的服务里反而是优点。
3.2 GPU 侧:TensorRT 为什么总在延迟排行榜拿第一
GPU 场景我测了 ONNX Runtime 的 CUDA 执行提供程序和 TensorRT。模型同样是上面三组。TensorRT 的优势在 YOLOv5 上体现得最充分,延迟比 ONNX Runtime 低 35% 左右,吞吐量高出近一倍。原因是 TensorRT 针对 GPU 的算子融合粒度更粗,能把整个检测头里的多个操作合并成量身定制的内核,同时它还会在构建引擎时做层间存储复用,显存占用也会更紧凑。
但 TensorRT 也不是没有代价。它构建推理引擎的过程明显更耗时,首次构建一次 YOLOv5 引擎可能需要几十秒甚至几分钟,而且它对模型输入 shape 非常敏感。如果输入尺寸固定,引擎可以做到极致优化;如果后续请求的图片宽高不一样,要么维护多个引擎,要么退回到动态 shape 模式,而动态模式下的性能会明显打折。
实测中 ONNX Runtime 的 CUDA 执行提供程序反而在动态 shape 场景下表现更舒坦,因为它不需要提前做这么重的构建优化,每次输入变化时只是重新推导部分内存布局,开销可控。所以 GPU 场景的选型结论很清晰:如果业务上线后输入 shape 是稳定的,TensorRT 基本是最优选择;如果业务根本没法固定输入尺寸,TensorRT 的动态 shape 能力虽然能用,但复杂度明显上升,这时候用 ONNX Runtime 保底反而省心。
3.3 边缘场景:TFLite 和 NCNN 的取舍
边缘场景我测试了 TFLite 和 NCNN,主要跑在 ARM CPU 开发板上。两者都是移动端常见的推理框架,但设计哲学不太一样。TFLite 更讲究跨端一致性,从移动设备到嵌入式设备都有一整套运行时;NCNN 则专注于极致性能和轻量化,对 ARM 指令集、内存对齐优化得很细。
从实测数据看,在纯 CPU 的 ARM 设备上,NCNN 的卷积算子延迟普遍比 TFLite 低 15% 到 25%,但这是在模型结构相对规整的前提下。如果模型里有比较多的变形操作,比如动态 reshape 或者大量拼接,NCNN 的优化效果就会缩水。TFLite 则提供了更灵活的 delegate 机制,后续如果想接入 GPU、NPU 之类的加速硬件,TFLite 的扩展路径更平滑。边缘侧选型不能光看峰值性能,还要考虑你团队对底层框架的掌握程度和后期硬件升级计划。
4. 隐藏在数字背后的框架行为差异
4.1 预热机制、多线程调度对结果的影响
我开始做对比时,发现同一个框架连续两次测试结果能差出百分之二三十,一开始以为是系统噪声,后来排查下来发现是预热机制在捣鬼。很多框架在加载模型时并不会把算子内核全部初始化,而是等第一次推理遇到某个算子时才去查询算子实现并分配临时缓冲区。这就导致第一次推理的耗时特别高,如果把这个时间计入平均值,整个结果就会被抬高。
为了消除这个影响,我在正式测速之前加了足够次数的预热,同时记录预热过程中的延迟变化曲线。有些框架预热 10 次就稳定了,有些框架预热 50 次还在波动,这本身也是框架质量的一个参考维度。一个在实际服务中长期运行的模型,首次请求后热度已经上来,后续延迟才是有意义的;但如果你做一个冷启动服务,第一个请求被哪个框架拖慢,用户体感是非常明显的。
多线程调度的差异我单独做了一组实验:固定输入 batch 等于 1,然后把 CPU 推理线程从 1 个调到 16 个,观察延迟变化。ONNX Runtime 在线程数增加时,延迟收益在 4 线程之后就明显递减;OpenVINO 则可以吃到更多线程带来的收益,但内存占用也会跟着涨。这说明如果不清楚线上服务的并发模型,盲目设定线程数可能让结果失真。
4.2 动态形状与静态 shape 带来的延迟波动
三种模型里,BERT-base 的动态 shape 问题最突出。因为文本长度不固定,输入 token 数会变,模型中间张量的形状也跟着变。我对同一句话跑了 100 次推理,发现在 NCNN 上每次耗时波动非常大,标准差能到平均值的 20% 以上;TensorRT 如果使用固定 shape 引擎,波动很小,但一旦输入长度超出引擎构建时设定的范围,就直接报错。
这个现象背后是内存分配策略的差异。静态 shape 的引擎可以提前把所有层的临时缓冲区按最大尺寸一次性分配好,推理过程中只需要复用;动态 shape 框架则需要在运行时根据实际形状重新分配内存,有时还伴随张量搬移。对于在线服务来说,如果业务方没办法统一输入尺寸,用动态 shape 的成本比表面上看到的要高。
我在实际项目里最终采用了一种折中方案:对图片类模型先做 resize 到固定尺寸,对文本类模型做 padding 到固定长度,这样既能用上静态 shape 优化,又不会让预处理开销失控。这个方案在后面对比框架时,也让不同框架之间的数据可比性提高了。
4.3 量化方案直接决定了落地精度
除了推理速度和吞吐,我还关注了量化策略对精度的影响。同一模型在 FP32 精度下,框架之间差距很小;切换到 INT8 之后差异就出来了。TensorRT 的 INT8 量化需要一组校准数据来统计激活值分布,校准集选得好,精度损失可能控制在 0.5% 以内;选得不好,明显掉点。
ONNX Runtime 的动态量化在某些算子上的支持还不够全面,所以模型落盘后可能出现一部分算子跑 INT8、另一部分退到 FP32 的情况,最终速度和预期差一截。TFLite 的量化工具则更偏向训练后全整型量化,流程清晰,但对小模型的效果不如大模型稳定。量化的选择往往比框架选择更影响最终体验,因此在对比框架时,我建议把“该框架下最容易实现的量化方案”也作为指标之一,而不是只测浮点模型性能。
5. 落地选型时的几条实用经验
5.1 按场景画一条更实际的选型路径
通过这轮测试,我整理了一条自己的选型路径。先问第一步:你的模型要跑在什么硬件上。如果是纯 CPU 数据中心服务器,优先考虑 ONNX Runtime 和 OpenVINO,OpenVINO 对视觉模型和 Intel CPU 更友好,ONNX Runtime 则胜在通用性和长线维护;如果是 GPU 推理,固定输入 shape、追求极致吞吐的走 TensorRT,输入形状变化多、或者团队不想维护复杂的构建流程,ONNX Runtime 是不错的保险选项。
第二步是看团队对底层技术的掌控力。TensorRT 的上限高,但排查问题的难度也高,一个算子融合失败会导致性能回退,报错信息却不直接指向根因;ONNX Runtime 社区资料多,遇到问题更容易找到方向。边缘场景则要根据硬件是否支持异构加速来定:如果以后要接 NPU,TFLite 的 delegate 机制会省很多事;如果只想把 CPU 性能压榨到极致,NCNN 更合适。
第三步是看业务对延迟的敏感程度。线上服务如果有一个比较硬的 P99 延迟要求,那么仅仅对比平均延迟是不够的,还要看框架在低并发和高并发下的尾部延迟表现。我的测试里,有些框架平均延迟好看,但长尾请求比例偏高,对核心体验伤害很大。
5.2 算子融合、量化、图优化的收益排序
很多人在部署模型时一上来就调框架参数,但其实优化的发力点是有清晰优先级顺序的。我做了几轮消融实验,简单来说,收益最大的操作是算子融合和去除冗余计算,这两种属于“从结构上减负”;其次是按精度需求做量化,把一个 FP32 算子换成 INT8,直接砍一半甚至更多的计算量;最次才是调线程数、内存池这类运行时参数。
框架里已经内置的图优化,基本都能自动完成常见算子融合,但模型里如果有些自定义算子,或者模型从另一个训练框架导出的结构比较冗余,这层优化效果就会下降。这时候我一般会在导出模型前先做一轮 torch 层面的优化,比如把 BatchNorm 折叠进卷积,再导出到 ONNX,这样到了推理框架那边,优化空间会更大。
关于量化,我一直强调一个原则:不是所有层都需要 INT8。敏感层比如检测头的输出层,保持 FP16 或者 FP32,中间计算量大的卷积层改成 INT8,往往能在精度和速度之间找到更舒服的平衡点。不同框架对混合精度支持的灵活度不一样,这也是选型时值得留意的。
5.3 保留一个基准版本做回归测试
做框架性能测试最容易犯的错,就是测完选出一个框架,然后永远不再回看。我经历过框架升级之后延迟恶化的案例,原因是用新版本重新构建了推理引擎,结果某个算子在新版本里匹配到了一个效率更低的实现。
因此现在我都会保留一个“基准版本”,把当时测试所用框架版本、模型文件、权重文件、推理代码、结果日志完整存档。以后任何环境变动,包括系统依赖升级、Python 版本更换、CUDA 库更新,都先跑一遍基准版本,再跑一遍新版本,两边的延迟和吞吐比对一下,就能快速发现性能回退。这很土但特别有效。
6. 测试中踩过的坑与结论为什么会反复改
6.1 一次 C++ 并发压测导致的误导性数据
有一次我把同一批测试程序改成压测模式,用 8 个线程同时打框架,结果 ONNX Runtime 的吞吐数据低到离谱。我花了一个多小时排查,最后才发现问题不在框架,而在测试程序本身:我用的并发循环里,多个线程共享了同一个输入张量的内存地址,导致框架在内部做并行处理时频繁触发锁竞争。
这个案例给我的教训是,框架对比时输入数据要每个线程独立准备,不能图省事共用同一份内存。否则测出来的性能差距,很可能只是线程冲突被不同框架放大的程度不一样,而不是框架本身的水平差异。类似的坑还有:动态内存分配器在测试期间被系统其他进程影响,导致偶发高延迟;测试程序里打印日志的 IO 操作占用了 CPU 时间片,间接拖慢了推理线程。
6.2 版本变动带来的巨大差异
另一个让我很头疼的坑是版本对齐。有一轮测试,我换了一台机器重新安装环境,结果 TensorRT 的构建结果和之前完全不一样,延迟变慢了 20%。排查后发现,新环境里的 CUDA 版本比原来高了一个小版本,TensorRT 在构建引擎时针对不同 CUDA 版本选择了不同的内核代码路径。
从那次以后,我把所有环境的依赖版本都写进了一份清单,并且把构建引擎时打印的版本信息直接写到文件名里。以前我习惯把引擎文件命名为“model.engine”,现在改成“model_fp16_batch1_tensorrt860_cuda118.engine”。这样后续任何修改都会在文件层面暴露出来,不会再出现“换了版本却不自知”的情况。
6.3 最终建议:用一段时间维度来看待对比结果
做完这一轮对比,我最大的感受是:推理框架性能测试不是一锤子买卖。框架的优化策略会随版本迭代调整,硬件驱动和底层算子库也在不断更新。今天我测出来的结论,半年后可能就不再成立,所以更合理的做法是把这套测试流程固化下来,每隔一段时间重新跑一次,把结果记录成一份持续变化的数据表。
对我个人而言,跑完这些对比后,我已经不会轻易相信某个框架“一定比另一个快”的说法。快多少、在哪个型号上快、用什么精度快、在什么并发下快,这些限定条件必须一起说出来才是有意义的。如果以后你看到一个评测数据特别漂亮,建议先回头看一眼它的测试口径和版本组合,再决定要不要往自己的项目里引。