我第一次接触OpenVINO的时候,其实有点被它的工具组合整懵了。官网上一堆名字摆在眼前,Runtime、Model Optimizer、benchmark_app、Pot、NNCF……每个看起来都跟推理有关,可到底谁负责什么、什么时候该用哪个,一开始真说不清楚。后来在实际项目里折腾了几轮,才慢慢摸出这套工具链的分工逻辑。我打算把自己理解的这套全景讲一讲,算是给刚上手的朋友一点地图,少走点弯路。
1.1 OpenVINO工具链定位:Runtime、模型转换工具、基准测试工具与辅助组件
我习惯把OpenVINO拆成四块来看。核心那块是OpenVINO Runtime,也就是真正在设备上跑推理的引擎,你写的Python或C++代码调用的都是它。它负责加载模型、分配设备、执行推理,整套API围绕着Core、CompiledModel、InferRequest这几个对象转。没有它,模型再好也只是躺在硬盘上的文件。
另一块是模型转换工具。早些年叫Model Optimizer,缩写MO,现在主推的是ovc这个命令行入口,Python里也有对应的convert_model接口。它的任务是把ONNX、PaddlePaddle、TensorFlow这些来源的模型翻译成OpenVINO自己的IR格式,中间顺带做图优化和精度处理。我自己感受最深的一点是:转换这一步做得好不好,直接决定了后面推理能不能跑、跑得快不快。
第三块是基准测试工具,benchmark_app是代表。它不参与业务逻辑,就是拿来压测的。你想知道某个模型在CPU上能跑多少FPS、延迟大概多少、不同流数量的差别有多大,用它几分钟就能出一组数据。我经常在正式写推理代码之前先用它探个底,心里有个性能预期。
剩下的就是辅助组件了。NNCF负责量化压缩,Pot以前做精度校准,Open Model Zoo提供一堆现成的预训练模型和示例代码,还有模型下载器omz_downloader这类小工具。这些不算推理主干,可一旦你要做精度优化或者快速拿个模型试手,它们就派上用场了。我对这套工具链的理解就是:Runtime是发动机,转换工具是变速箱,benchmark_app是仪表盘,辅助组件是随车工具箱。
1.2 推理工作流拆解:模型获取、转换优化、加载推理、结果后处理
实际跑一个模型,我脑子里的流程大概是四步。第一步是拿到模型。来源可能是PyTorch训练完导出成ONNX,也可能是直接下载Open Model Zoo里的现成模型,或者从PaddlePaddle、TensorFlow那边转过来。这一步看着简单,坑却不少,比如算子版本对不上、输入名字改了、动态维度没标清楚。
第二步是转换优化。把模型喂给ovc生成IR文件,得到.xml和.bin一对。这一步可以顺手做点事情,比如指定输入形状、设置精度、开启动态形状支持。我一般会把转换日志仔细看一遍,里面常会提示哪些算子被保留、哪些被融合,对后面调性能很有参考价值。
第三步是加载推理。用Runtime把IR读进来,编译到目标设备上,准备输入张量,发请求,拿输出。同步还是异步、单请求还是多请求,这里的选择会影响整体吞吐。最后一步是结果后处理,分类模型要取argmax,检测模型要解NMS,分割模型要把mask还原到原图尺寸。后处理不算OpenVINO的活儿,可它跟推理贴得很近,写代码时最好一起设计,别让数据在内存里来回倒腾。
1.3 支持模型格式与硬件设备概览:CPU、GPU、NPU及多设备协同
格式这块,OpenVINO现在的胃口挺宽。ONNX基本是第一公民,PyTorch导出走ONNX最省心。PaddlePaddle和TensorFlow的前端也一直维护着,转过去问题不大。原生IR当然最直接,拿过来就能加载。我见过有人纠结要不要先转ONNX再转IR,我的经验是只要前端支持,一步到位更干净,中间格式多了反而容易丢信息。
设备支持上,CPU是基本盘,几乎什么机器都能跑,Intel的CPU上还会自动用上AVX-512、AMX这些指令集做加速。集成GPU和独立GPU也都能挂,适合做并行吞吐。近两年NPU越来越受关注,低功耗、推理能效比高,在边缘盒子和AI PC上很吃香。让我觉得方便的是多设备协同,比如AUTO插件会自动挑设备,MULTI能把一个模型铺到CPU和GPU上一起跑,HETERO还能把不同层分给不同设备。这些插件名字听起来花哨,用起来其实就是换个device字符串的事。
1.4 常见应用场景与工具选型建议:视觉推理、生成式AI、边缘部署
视觉推理是我见得最多的场景,分类、检测、分割、OCR这类任务在OpenVINO上跑得都很成熟。这类模型通常结构规整、输入形状固定,转换和优化都比较顺。我一般建议先用Open Model Zoo里的同类模型跑通流程,再换成自己的模型,能省掉很多环境层面的折腾。
生成式AI这块这两年变化很快。LLM、扩散模型也能用OpenVINO跑,官方有GenAI这套上层封装,帮我们处理了tokenizer、KV cache、采样这些细节。我的建议是如果只是验证可行性,直接拿GenAI的示例改;要做深度定制,再往下钻Runtime的细节。别一上来就手写全部推理逻辑,那样调试成本太高。
边缘部署的考量和前面不太一样。设备算力有限、功耗敏感、模型体积要小,选型时我会优先看NPU或者低功耗CPU,模型侧考虑INT8量化。部署形态也常是C++程序嵌进设备固件里,跟桌面端的Python脚本差别挺大。我给自己的经验是:桌面验证阶段用Python和benchmark_app快速试,确认精度和性能达标后再往C++和边缘设备上迁,别两头同时改,出了问题不好定位。
模型转换这一步,我踩过的坑大概比后面所有环节加起来都多。刚开始我以为转模型就是一行命令的事,后来发现光是搞清楚模型从哪来、用什么格式、输入输出长什么样,就够折腾好几天。这一章我想把这段折腾过程摊开讲讲,重点放在转换前你要准备什么、转换时怎么调参数、转完怎么验证。我的体会是:准备工作做扎实,后面推理阶段能少一半的麻烦。
2.1 模型来源与格式梳理:ONNX、PaddlePaddle、TensorFlow、PyTorch导出模型
我拿到的模型无非几种来路。PyTorch训练完导成ONNX是最常见的,也最省心。PaddlePaddle的模型现在也支持得不错,尤其是国内团队训的检测、OCR模型,很多是Paddle格式。TensorFlow那边老项目多,SavedModel、 frozen graph 我都遇到过。原生IR file我很少自己从头造,多数是下载Open Model Zoo里的现成货,或者别人转好交给我的。
PyTorch导出ONNX这一步值得单独说几句。我习惯在torch.onnx.export里显式指定opset_version,通常用13到17之间,太老会缺算子,太新OpenVINO前端有时还不认。dynamic_axes这个参数特别重要,你要是打算喂变长输入或者动态batch,导出时就得标清楚,否则ONNX里全是固定维度,后面想改就得重导。还有一个小细节,导出前记得把模型切到eval()模式,不然BatchNorm和Dropout的行为会让推理结果对不上。
PaddlePaddle的模型我一般先用paddle2onnx过一道,或者直接用OpenVINO的Paddle前端。直接用前端能少一层转换,信息损失小一些。TensorFlow的SavedModel要留意变量有没有冻结,有些导出的包里还带着训练时的影子变量,转换工具会报一堆莫名其妙的错。我的习惯是先拿Netron看一眼结构,心里有数了再动手转。
2.2 使用模型转换工具生成IR:ovc/mo命令参数与转换流程
现在官方主推ovc这个入口,以前是mo,两个命令的能力基本对齐。我最常用的写法是ovc model.onnx --output_dir ir/,一行就能出.xml和.bin。想压体积就加--compress_to_fp16,大多数视觉模型转成FP16之后精度几乎不掉,文件能小一半,边缘设备上很划算。不过有些对数值敏感的模型,比如某些检测头的回归分支,FP16可能会让框的位置抖一抖,这种就得自己跑一轮验证。
参数里我花时间最多的是输入形状和预处理相关的几个。--input_shape能指定静态形状,比如[1,3,640,640],适合固定分辨率的场景。--mean_values和--scale_values可以把手动做的归一化直接嵌进IR里,推理时喂原始像素就行,省掉一层预处理代码。--reverse_input_channels用于BGR转RGB,很多OpenCV读出来的图是BGR,模型却按RGB训的,这个参数一加就不用自己在代码里倒腾通道了。
转换流程说起来简单,实际操作里有几步我会刻意慢下来。转之前先确认工作目录干净,别让旧的IR文件混进来,我曾经因为读了个同名旧文件,调了半天才发现模型根本没更新。转的时候把完整日志存下来,里面有算子融合的信息,还有“某层被保留”的提示,这些对后面排查精度问题很有用。转完别急着走,先ls看一眼文件大小对不对,一个几MB的模型转出来只有几百KB,多半是哪里出岔子了。
2.3 输入输出信息检查与动态形状配置
我拿到任何一个IR,第一件事就是用benchmark_app -m model.xml -i dummy跑一下,它会顺带把输入输出的名字、形状、类型打出来。也可以在Python里用Core().read_model()然后读model.inputs和model.outputs,信息更全。Netron看ONNX很直观,但它读IR的时候偶尔会显示得不太准,我一般以运行时读到的为准。
输入名字这事儿特别容易出问题。ONNX里输入可能叫input.1,转成IR之后名字可能变成input,也可能是x,取决于原模型怎么定义的。我的代码里一般都先打印出实际的输入名,再拿名字去索引,硬编码字符串的做法让我吃过好几次亏。输出也一样,多输出模型里顺序不一定跟你想的一致,靠名字拿最稳。
动态形状的配置我一般两种做法。一种是在转换时用--input_shape配合-1占位,比如[1,3,-1,-1],让H和W可变,再用--dynamic_shapes的语法更精细地控制哪些维度可以变。另一种是转成静态IR,推理时用reshape接口改形状。前者适合输入尺寸每次都不同的场景,后者适合形状变化有限、想锁定性能的情况。我个人的经验是能静态就静态,动态形状会限制一部分图优化,性能上往往要打个折扣。
2.4 模型精度与预处理适配:均值方差、颜色通道、布局转换
预处理这块最容易出“推理能跑但结果全错”的问题。模型训练时的归一化方式,你得原样搬到推理侧。常见的是减均值除方差,ImageNet那套mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225]我背得滚瓜烂熟。重点在于这些值是在0到1的尺度上算的,如果你的图还是0到255,得先除255。这个尺度搞错,输出会离谱到让你怀疑模型坏了。
颜色通道和布局是另一对常客。OpenCV的imread默认给BGR,还带着HWC的排布,模型要的却是RGB加NCHW。转换时加--reverse_input_channels处理颜色,布局转换交给Runtime或者自己在喂数据前transpose。我倾向于把颜色通道的翻转放进IR,布局转换留在代码里,这样IR本身跟训练框架的预处理逻辑更接近,迁移到别的语言时也好对照。
用--mean_values和--scale_values把预处理嵌进IR有个好处:推理代码变干净了,输入就是原始像素或者简单的缩放结果。坏处是灵活性差一点,想临时换种归一化方式就得重转模型。我在做实验阶段一般先在代码里做预处理,确认整条链路结果对了,再考虑要不要固化进IR,这样调参的时候不用反复转模型。
2.5 转换结果验证与常见报错排查
转完的IR我一定会做两步验证。第一步是拿同一张输入图,分别跑原始框架和IR,比对输出数值。分类模型看top-5类别和分数是否接近,检测模型看框的数量和坐标偏差。允许有一点浮点误差,FP16的话误差会比FP32大一些,但类别不能变、框不能丢。第二步是用benchmark_app跑一下性能,顺便确认模型能在目标设备上正常编译,这一步能提前暴露设备插件层面的问题。
报错里我见得最多的是“Op not supported”。ONNX里某个算子OpenVINO前端还没实现,日志会告诉你是哪个op、在第几层。遇到这种,我的处理顺序是先升级OpenVINO版本,很多新算子过几个版本就支持了;升不了级就找找有没有等价的算子替换,或者用ONNX的优化工具把这一小段图改掉。还有一类是动态形状解析失败,提示某维度推断不出来,这种一般得回到ONNX那边把dynamic_axes标全,或者转换时显式给--input_shape。
“Cannot reshape”和精度对不上也是高频问题。前者多半是输入形状跟IR里记录的不一致,回头检查转换时定的形状和推理时喂的张量尺寸。后者往往是预处理没对齐,通道顺序、归一化尺度、甚至图像resize的插值方式都可能造成偏差。我的排查习惯是拿一张图,把预处理每一步的中间结果都打印出来,跟训练侧的代码逐行对,通常对到第三步就能找到差异在哪。验证通过之后,这个IR才算真正可用,可以放心进入推理代码的编写。
上一章把模型转成了IR,我以为接下来就是顺水推舟跑推理了。真正动手写代码才发现,从装环境到把第一个推理结果拿到手,这中间还有一段路要走。这一章我把这段路一步步走一遍,把我在代码里反复改过的写法、踩过的坑都摊出来。你跟着走一遍,应该能省掉不少来回翻文档的时间。
3.1 环境安装与版本确认:Python/C++开发环境与设备插件
装OpenVINO现在比前几年省心多了,pip install openvino基本就够用。我习惯先建个干净的venv,别跟系统环境里一堆乱七八糟的包搅在一起。装完第一件事是python -c "import openvino; print(openvino.__version__)"确认版本号。这个动作看起来多余,我有一次调试了半天的API报错,最后发现是机器上装的是两年前的老版本,接口早就换了。
Python这边装完,设备插件一般跟着就走了。想确认CPU、GPU这些插件是不是都在,我会跑一段小脚本,用Core().available_devices把能用的设备列出来。输出里常见的是CPU、GPU.0这类名字,有NPU的机器还能看到NPU。名字带数字后缀这种事我吃过亏,早期代码里硬编码GPU,某些机器上就报找不到设备,后来改成先读列表再选,稳当多了。
C++环境就麻烦一些,得配OpenVINO Runtime的库和头文件。我一般用官方提供的安装包或者从源码构建,CMake里把OpenVINO_DIR指对。编译能过是第一关,第二关是运行时找得到插件动态库,设好LD_LIBRARY_PATH或者Windows下的PATH。C++这边出错信息有时候比Python抽象,第一次配环境出问题,我建议先用Python把整条链路跑通,确认模型和设备都没问题,再切去折腾C++,能少排查一层。
3.2 使用OpenVINO Runtime API加载模型与创建推理请求
加载模型的核心就两个对象:Core和CompiledModel。我一般写core = Core(),然后model = core.read_model("model.xml"),再compiled = core.compile_model(model, "CPU")。这几行里compile_model是最费时间的,模型越大越明显,我第一次跑一个大一点的检测模型,等了好几秒才返回,还以为卡死了。
compile_model里第二个参数选设备,我之前提过,用available_devices里读到的名字最保险。第三个参数是个配置字典,后面性能优化那一章会细说,入门阶段可以先不传。编译完的CompiledModel才是真正拿来推理的,它上面能拿到输入输出的数量和形状,我习惯加载后先打印compiled.inputs和compiled.outputs,把名字、形状、类型都对一遍,跟上一章转模型时记录的信息核对。
创建推理请求这一步,Python里可以用compiled.create_infer_request()。早期教程里经常看到infer_request = compiled.create_infer_request()然后手动set_input_tensor、infer、get_output_tensor那一套。现在其实有更省事的写法,直接compiled([input_data])一行就能出结果,内部帮你把request建好、数据塞好、跑完取结果。入门阶段我推荐先用这种最简写法把链路跑通,理解了再回过头去看手动创建请求的细节。
3.3 同步推理与异步推理的代码流程对比
同步推理就是喂数据、等结果、再处理,一条直线。results = compiled([x])这行执行完,结果就在手里了,代码读起来直观,调试也方便。我做单张图验证、精度比对的时候都用同步,出错的时候栈信息也清楚。缺点是它会把当前线程卡住,你要是边推理边做预处理或者界面刷新,同步就会拖慢整条流程。
异步推理的写法我第一次看觉得有点绕,用起来之后才明白它的价值。核心是infer_request.start_async()发起之后立刻返回,主线程可以继续干别的,等需要结果的时候再infer_request.wait()。我一般会配一个回调,在回调里标记完成、触发后处理。它适合视频流、多路摄像头这种场景,一路在推理的时候另一路可以做预处理。
两种写法我经常混着用。批处理离线跑数据集,同步就够,代码简单不容易出错。实时流水线我会用异步,配合队列和线程池,把预处理、推理、后处理拆成三段重叠起来跑。入门阶段先把同步跑顺,等你能稳定拿到正确结果了,再花时间理解异步的时序关系。我自己是同步跑了大概两周,把各种IO格式、多输出解析都摸熟了,才切到异步,切换的时候少交了不少学费。
3.4 输入数据准备、推理执行与输出结果解析
喂给推理的数据形状一定要跟模型输入对齐。我上一章提过,用compiled.inputs[0].shape读实际形状,别凭记忆。图像数据一般是NCHW,很多库读出来是HWC,transpose这一步别漏。数据类型也要对,模型要FP32你塞了uint8,要么报错要么结果离谱。我习惯在预处理函数最后打印一下array.shape和array.dtype,这两个信息对了,大部分输入错误都能提前拦下来。
推理执行本身反而是最短的一步。拿到结果之后,results这个东西在Python里用起来像字典,可以按输出端口索引,也可以按名字索引。多输出模型里我一般先打印compiled.outputs看顺序和名字,再用名字取,免得顺序跟想象的不一致。取出来的还是原始张量,分类模型可能要softmax,检测模型要解码框和分数,这些后处理逻辑得自己写,OpenVINO不管这一层。
后处理这块我建议单独写成函数,跟推理代码解耦。调试的时候可以只跑后处理,拿固定的张量喂进去验证。我做过一个检测模型,推理输出解析错了,框的位置一直偏,查了半天发现是坐标归一化的参考系搞混了。后处理跟推理分开之后,这种问题定位起来快很多,改后处理也不用担心把推理那段弄坏。
3.5 使用benchmark_app进行快速推理验证与参数说明
benchmark_app是我拿到新模型后第一个跑的工具。用法很简单,benchmark_app -m model.xml,它自己会生成假数据或者用一个真实输入跑起来,然后把吞吐量、时延、每层耗时都打出来。我最看重的其实是它能不能跑通,能跑通说明模型能在目标设备上正常编译加载,这一步就过滤掉了大量设备兼容性问题。
常用参数里,-d指定设备,-niter控制迭代次数,-api可以选sync还是async。-b改批大小,-nstreams和-nthreads调并发配置,这几个在做性能对比的时候经常动。它跑完会输出一个很长的报告,前半段是配置信息,中间是时延分布,后面是吞吐量。我习惯先看最后那行吞吐量数字,作为第一版基线,后面优化就拿这个数做参照。
这个工具还有个我常用来验证模型正确性的用法:-i指定一张真实的图,跑完之后配-report_type或者直接看它有没有报输入格式错误。假数据跑得快,但真实数据能暴露预处理或通道顺序上的问题。我有时候会用benchmark跑两种输入对比,虽然它不打印推理结果,但时延突变或者直接报错,往往能提示输入配置有毛病。
3.6 多设备与多模型推理的入门示例
多设备这块入门可以分两步理解。一步是一个模型跑在多个设备上分摊负载,比如-d MULTI:CPU,GPU这种写法,benchmark里直接用,代码里就是compile_model(model, "MULTI:CPU,GPU")。另一步是多个模型分别绑不同设备,我有个场景是检测模型放GPU、分类模型放CPU,两个CompiledModel各自独立,推理请求也分开建,互不干扰。
多模型同时跑的代码结构,我一般做成一个小的管理器,每个模型对应一个字典,存编译好的model和它的输入输出信息。预处理按模型分别走,结果按模型名字取。这个结构看起来比单模型复杂一点,好处是加模型、换设备的时候改动很小。我第一次写多模型的时候把输入输出搞混了,A模型的输入塞给了B模型,报错信息还不明显,后来加了一层断言校验形状和类型,问题就少很多。
自己写代码之前,可以先用benchmark_app把多设备配置试一遍。同一个模型,-d CPU、-d GPU、-d MULTI:CPU,GPU各跑一次,对比吞吐和时延,你心里就有数了哪个配置适合这个模型。多模型场景更复杂一点,资源会互相抢,我建议先保证每个模型单独跑都正常,再合到一起,出了性能问题也容易拆开分析。入门阶段不必追求最优配置,先把整条链路搭稳,后面再一步步调。
第三章结束时我已经能稳定跑出正确结果了,但那个吞吐量数字一直让我心里不太舒服。同一个模型,别人测出来的速度快我一大截,我这边慢悠悠地出结果。这一章我就把自己调性能的过程整理出来,从怎么看指标开始,到具体动了哪些配置,再到哪些操作看着像优化实际是白忙活。性能这件事没法一次到位,我到现在也还在边测边改。
4.1 性能分析入口:benchmark_app指标、吞吐量与时延评估
上一章提过 benchmark_app 能跑通就说明模型没问题,到了这一章它变成了我的主要测量工具。我一般会固定一套参数反复跑,比如 -niter 200 -api async -d CPU,保证每次对比的基准一致。跑完那份长报告里,我最早只看最后一行的吞吐量,后来发现前面的时延分布信息量更大。P50 和 P90 的差距能告诉我这个模型的延迟稳不稳,如果 P90 比 P50 高出一大截,说明有偶发的长尾延迟,光看平均值会被骗过去。
吞吐量和时延这两个指标在我的场景里经常打架。批处理能把吞吐拉上去,单次请求的时延反而变长。我做过一个视频分析的项目,最开始一味追吞吐,批大小一路加到 8,结果单帧延迟高到画面明显卡顿。后来我把两个指标分开看,离线跑数据集只看吞吐,在线服务盯 P90 时延,两套配置分开维护,就不纠结了。
benchmark_app 报告里还有个每层耗时,我用来找瓶颈层。有一次一个检测模型在 CPU 上跑得特别慢,报告里某一层的时间占比高得离谱,顺藤摸瓜发现是某个算子在那个设备上没有优化实现。这种信息在改代码之前就能拿到,比盲目试参数省时间得多。
4.2 设备与流配置优化:设备选择、线程数、流数量与批处理
设备选择是第一道关,也是最容易看到差别的一步。同一个模型,-d CPU 和 -d GPU 各跑一次,数字摆在那里,选哪个不用猜。我手上有台带核显的机器,小模型 GPU 上反而没比 CPU 快多少,大模型才拉开差距。设备不是越贵越好,得看模型规模和形态,这个结论我是在两三台机器上分别测过才敢下的。
线程数和流数量这两个参数我调了很久才摸到点感觉。-nthreads 管的是计算线程,-nstreams 管的是并发的推理流数量。CPU 上我一般先让线程数跟物理核数对齐,然后动 stream 数看吞吐变化。stream 开多了会互相抢核,吞吐不升反降的情况我遇到过不止一次。有个经验是我自己总结的:先把 stream 设成 1 拿到单流基线,再逐步往上加,每次只改一个参数,曲线拐点就是比较合适的值。
批处理的效果最直接,也最需要小心。批大小从 1 加到 4,吞吐通常有明显提升,加到 8 或者 16 之后收益就递减了,内存占用和首帧延迟却在涨。我现在的习惯是画一张批大小对吞吐和时延的表,两列数字并排看,选那个吞吐够了、时延还能接受的点。这个点每个模型都不一样,抄别人的配置经常不灵。
4.3 异步推理与流水线并行:重叠预处理、推理与后处理
第三章里我对比过同步和异步的写法,那时候只是知道异步不卡线程。真正做性能优化才发现,异步的价值在于让三段工作重叠起来。单张图推理的时候,预处理、推理、后处理是串行的,时间加起来就是总耗时。流水线搭起来之后,第二张图在预处理的同时第一张图在推理,第三张图的结果在解析,整体时间就被压下来了。
我搭流水线的方式比较土,一个输入队列、一个推理线程、一个后处理队列,加上异步请求的回调做衔接。回调里我只做标识和投递,真正的后处理放到另一个线程,避免在回调里干重活把推理线程堵住。这个结构调试起来不算轻松,我第一版写出来结果乱序了,查了半天是队列的消费顺序和回调触发顺序没对上,后来给每个请求带上序号才理清楚。
流水线的收益跟模型本身有关。预处理特别重的场景,比如大分辨率图像的缩放和归一化,重叠带来的提升很明显。模型推理本身就很快、预处理几乎不占时间的场景,流水线搭起来收益有限,复杂度倒是涨了不少。我现在会先分别测三段各自的耗时,哪段占比大就优先优化哪段,不盲目上流水线。
4.4 模型压缩与低精度推理:INT8量化、权重压缩与精度校验
FP32 换成 FP16 是我试过的第一个低精度方案,改动小,很多设备上直接支持。我把模型转成 FP16 之后跑 benchmark,吞吐有提升,精度掉得不多,在分类任务上几乎看不出来。FP16 对我的吸引力在于不需要校准数据,转完就能用,适合先拿一个快速收益。
INT8 量化是收益更大的一步,也是需要更谨慎的一步。我用的是带校准数据的训练后量化流程,准备几百张有代表性的图喂进去统计激活范围。第一次量化完精度掉得厉害,排查下来是校准集选得偏了,全是简单样本,模型在难样本上的表现就崩了。后来我把校准集按场景分布重新挑了一遍,精度恢复了大半。
量化之后一定要做精度校验,这一步我从来不敢省。我的做法是拿一批标注数据,量化前后各跑一遍,对比关键指标,检测任务看 mAP,分类看 top-1。指标掉在可接受范围内才上线,掉超了就回去调校准集或者改成部分层量化。权重压缩这类手段我也试过,模型体积小了,加载更快,对边缘设备挺友好,推理速度本身提升有限,得看具体场景值不值。
4.5 图级与运行时优化:算子融合、动态形状、推理缓存与预处理融合
算子融合这块我基本没手动干预过,模型转换和编译的时候它自己会做。我能感知到的是转换日志里有时会提到某些层被合并,跑起来确实快一点。真要说我主动做的,是尽量让模型结构保持干净,别塞一堆可以合并的小算子进去,转换工具处理起来更顺。
动态形状是我踩坑比较多的一个点。为了兼容不同输入尺寸,我把模型设成了动态 shape,灵活性是有了,性能却掉了。动态形状会让某些优化没法提前做,每次推理都要重新规划。后来我改成固定几个常用尺寸,每个尺寸单独编译一个模型,按场景切换,速度回来了,内存占用也可控。这个取舍我觉得挺值。
推理缓存是我最近才用上的,第一次编译慢的问题被它解决了不少。把编译结果缓存到本地,下次加载直接读缓存,启动时间明显缩短。预处理融合指的是把归一化这类操作尽量放到模型图里或者用设备支持的算子做,减少在 CPU 上单独跑一遍的开销。这几项加起来不是那种立竿见影的大提升,累积起来对整体体验的改善挺明显。
4.6 性能优化实验设计与调优避坑清单
调性能最怕的是一次改好几个参数,跑出来快了也不知道是哪个起的作用。我现在的做法是列一张表,每行一个配置,只改一个变量,跑完记下吞吐和时延。这个流程笨是笨了点,结论可靠。有次我同时改了 stream 数和批大小,结果性能反而降了,回头拆开测才发现是两个参数互相干扰。
避坑清单里我排在最前面的几条都是自己交过学费的。测性能前先确认没有别的程序在抢资源,我有一次后台开着模型转换任务测了半天,数据全是错的。benchmark 的假数据和真实数据的表现可能不一样,正式结论要用真实输入复测。还有一条是别只看平均值,长尾延迟在线上服务里才是要命的那个。
最后我想说的是,优化要有停下来的标准。我早期容易陷入一直调一直测的循环,收益越来越小,时间花得越来越多。现在我给自己定个目标,比如吞吐达到某个数、P90 时延低于某个值,达到了就收手,把精力放回功能和稳定性上。性能是够用就好,不是越高越好。
第四章调完性能,我手里的模型总算跑得像个样子了。可真把它推到实际环境里,又是一堆新问题冒出来。benchmark 跑得再漂亮,业务代码一接、设备一换、数据集一杂,情况就不一样了。这一章我把自己做过的几个项目摊开来讲,有图像分类、目标检测、分割,也有生成式模型和边缘盒子上的部署经历,最后把踩过的坑和用得顺手的做法归拢一下。
5.1 典型实战案例:图像分类、目标检测与分割推理部署
我最早完整落地的项目是一个工业零件分类。拿 ResNet 系模型导出 ONNX,用 ovc 转成 IR,接进一个简单的 HTTP 服务。分类的部署链路相对干净,预处理固定 224 尺寸,均值方差照训练配置来,后处理就是 argmax 加置信度阈值。这个项目让我明白一件事,模型转换和业务代码之间那一层预处理适配是最容易出错的。我当时把 BGR 当成 RGB 喂进去,准确率掉了十几个点,排查了半天才发现是通道顺序的问题。
检测项目的复杂度明显上了一个台阶。我用的 YOLO 系列,模型输出是多个尺度的特征图,后处理要做解码、置信度过滤和 NMS,还要把坐标映射回原图尺寸。第一次部署的时候我把 NMS 阈值设得太高,重叠的检测框叠了一堆,画面全是重复框。调低之后好了,可小目标又被误删。后来我针对不同场景把阈值做成了可配置项,线上的效果好多了。
分割是我花时间最长的。模型输出的 mask 分辨率跟原图对不上,要插值回去,插值方式选不对边缘会糊。还有一类问题是颜色通道和类别映射,训练时类别索引到颜色的映射表丢了,推理结果可视化出来全是乱的。我现在养成的习惯是,每个模型在部署前都拿几张原图跑一遍,肉眼对一下结果,再去做批量评测。这个习惯帮我拦下过好几次低级的预处理错误。
三个项目跑下来,我发现部署能不能顺利,八成取决于模型之外的那些细节。输入布局、通道顺序、归一化参数、后处理逻辑、坐标映射,任何一处跟训练时对不上,结果就会怪。我现在会把这些参数写成一份显式的配置文件,跟模型文件放一起,不去依赖代码里的隐含约定。
5.2 生成式AI模型推理的部署要点与资源规划
生成式模型是我今年才开始正经碰的。跟前面的视觉模型比,它的资源账完全是另一个算法。我做的一个文本生成服务,模型本身转成 IR 之后体积不算大,可跑起来显存占用一上来就吃不消,主要吃在 KV cache 上。上下文长度从 2K 提到 4K,显存直接涨了一大截,这块我在规划的时候一开始没算进去,导致第一版部署一压测就 OOM。
资源规划我现在会按峰值来估,不去看平均值。并发数、上下文长度、批大小三个变量一乘,就是显存和内存的大头。我做过一版连续批处理,把多个请求拼起来跑,吞吐提升明显,代价是要维护请求调度和 KV cache 的复用。这个调度逻辑写起来不轻松,我第一版复用了错误的 cache 槽位,生成出来的内容前后串了,后来老老实实给每个序列做生命周期管理才稳定。
生成式模型的另外几个部署要点我记在笔记本上。流式输出要早点做,用户等首 token 的时间感知比等全部生成完强太多。采样参数要能在服务端控制,温度、top-p 这些不该硬编码在模型里。还有一条是超时和截断,输出长度没上限的话,碰上异常输入会一直生成下去,把资源占死。我现在所有生成服务都带 max_tokens 和超时保护。
5.3 边缘设备部署:模型体积、功耗、时延与稳定性权衡
边缘部署跟服务器上完全是两套思路。我在一个工控机上部署过检测模型,那台机器没有独立显卡,散热也一般。模型一开始用 FP32,跑起来风扇狂转,机身烫手,连续跑几个小时还会降频,时延越跑越长。换成 INT8 量化版本之后,耗电和发热都下来了,时延也稳住了。那次之后我再也不在边缘设备上直接上 FP32 模型。
模型体积在边缘场景是硬约束。我遇到过一个设备存储空间卡得很死,模型加运行时加依赖几乎塞满,升级的时候连临时空间都不够。我把模型做了权重压缩,又把不必要的算子裁掉,腾出来的余量才勉强够用。这件事让我在项目前期就会去确认设备的存储和内存上限,而不是等打包运行的时候才对着报错发呆。
功耗和稳定性是一对需要反复权衡的东西。我试过把设备频率拉满去追时延,短时间测试数字确实好看,但长时间运行会触发温控降频,反而出现时延抖动。我现在的做法是让设备跑在一个留有余量的状态,时延不是最优,但波动小。边缘设备的稳定性比峰值性能重要,这个结论是我在连续跑了一周压测之后才真正接受的。
还有一个容易忽略的点是设备兼容性。同样的 IR 模型,不同批次、不同固件版本的设备上行为可能不一致。我吃过一次亏,一批设备上新模型跑得好好的,另一批却加载失败,排查下来是驱动版本差异。现在我在边缘项目里都会固定设备型号、固件和 OpenVINO 版本,把这三样写进部署清单。
5.4 工程化落地:服务封装、日志监控、版本管理与回归测试
模型跑通只是开始,真正的工作量在服务封装这一层。我做过的小服务用 FastAPI 包一层 HTTP 接口,推理部分放在独立的工作进程里,接口层只管请求校验和结果组装。这种结构的好处是推理挂了不影响接口,接口层还能做限流和排队。我也试过把推理直接塞在 web 进程里,压力一上来整个服务都被拖住,后来拆开之后就稳了。
日志和监控我现在一定会做。日志里记录请求耗时、输入尺寸、推理设备、输出摘要,监控里盯 P90 时延、错误率、队列长度。有一次线上时延突然涨,看监控发现是队列在堆积,顺着日志找到是某类输入尺寸特别大,触发了动态形状的重编译。没有这些日志,这个问题的排查时间会翻好几倍。
版本管理这件事我最初做得比较随意,模型文件改个名字就上线了,后来出问题想回滚都不知道回哪个版本。现在我把模型文件、转换参数、预处理配置、OpenVINO 版本绑成一个版本号,服务启动时把版本号打进日志。回滚的时候按版本号走,干净利落。
回归测试是我现在上线前的固定动作。准备一小批有代表性的样本,覆盖不同尺寸、不同场景,每次模型或代码变更都跑一遍,对比指标和输出。这个测试集不大,跑一次几分钟,拦下的问题不少。我有一次改预处理把归一化的顺序调错了,就是回归测试里指标异常才发现的,肉眼完全看不出来。
5.5 常见问题排查与最佳实践汇总:转换失败、性能不达预期、设备兼容性
转换失败是我问自己最多的一个问题。大多数时候原因不外乎几个:模型里有不支持的算子、输入输出的动态维度没标清楚、算子版本跟运行时对不上。我的排查顺序是先看转换日志里的报错层名,去确认那个算子有没有对应实现,再看输入输出规格。有几个模型我是先把不支持的算子切开或者换掉才转过去的。这一步没有通用解,只能对着日志一层层看。
性能不达预期的情况我遇到过太多次,最后总结出一个检查顺序。先确认 benchmark 和实际业务的输入尺寸、批大小、设备是不是一致的。再确认有没有别的进程在抢资源。然后看设备选择对不对,同一个模型在 CPU 和 GPU 上差别可能很大。最后才去动线程、流数和量化这些参数。我早期喜欢一上来就调参数,其实前面的基础项没对齐,调什么都是白调。
设备兼容性问题在异构环境里特别常见。我的做法是部署前把目标设备的信息打出来,包括设备名、驱动版本、支持的精度。CPU 上能跑的模型 GPU 上不一定能跑,NPU 的支持面更窄。碰到加载失败,我会先降级到一个已知能跑的简单模型做对照,确认是设备环境的问题还是模型本身的问题,这样能省很多时间。
最后我想说,部署这件事没有一个放之四海而皆准的方案。我上面写的这些做法都是在自己项目里试出来的,换一个业务、换一批设备,可能就要重新验证。我现在的习惯是把每次部署遇到的新问题记下来,形成自己的检查清单,下次开新项目先过一遍清单。这份清单还在变长,每加一条,都是之前交过学费换来的。