☰
PP-MattingV2 ONNX部署实战:Python与C++双版本避坑指南
2026/10/14 11:27:34 网站建设 项目流程

简介:面向深度学习与计算机视觉开发者,整合ONNXRuntime部署PaddleSeg实时人像抠图模型PP-MattingV2的完整工程,内含C++与Python两套推理源码,适合需要快速落地人像分割、跨框架推理或研究实时抠图方案的读者。资源共7个文件,包括1个Python脚本、1个C++源文件、1份说明文档及4张测试图片,压缩包仅2.85MB,结构精简,便于对照源码理解推理流程。资源包按源码、说明与测试图片分区,便于快速定位与二次开发。现有501人学习下载,适合有一定深度学习基础的工程开发者。通过该工程可掌握PP-MattingV2接入ONNXRuntime的完整思路,涵盖C++与Python环境下的推理调用、输入输出预处理、跨平台部署差异等关键环节;配套说明文档和测试图片可辅助验证发丝级前景分离效果,为性能敏感场景或快速原型开发提供可直接参考的代码模板。

1. 拿到PP-MattingV2的ONNX部署包:一份能直接跑的工程样本

做服务端最烦的一件事就是模型训练好了,部署链路却要重新趟一遍。人像抠图这种深度学习任务更是如此,PaddleSeg里的PP-MattingV2效果不错,可生产环境里把Paddle的推理环境装齐,再处理版本依赖,一套下来半天没了。所以把模型转成ONNX、统一走ONNXRuntime推理,成了很多后端团队的实际选择。这份资源我拆过之后觉得实用性很高:PP-MattingV2的ONNX模型、C++和Python两套部署源码、四张测试图打包在一起,覆盖了从预处理到alpha后处理的完整闭环,适合正在评估抠图方案或者刚接手部署任务的开发者。省掉的是自己导模型、写demo的时间,留下来的是能照着跑的工程骨架。

2. PP-MattingV2的ONNX形态:模型结构、动态shape与导出边界

部署PP-MattingV2不需要把论文里的前向细节都啃一遍,但要搞清楚三件事:输入是什么格式、输出是什么格式、模型有没有动态维度。这是部署和训练完全不同的视角,也是后面代码能跑通的根基。

2.1 部署视角下的PP-MattingV2:一个输入、一个输出的约定

PP-MattingV2在PaddleSeg里是一个端到端的抠图模型,内部有上下文语义单元和结构细节单元,底层还挂了引导滤波一类的组件来提边缘精度。这些是训练阶段关心的事。到了部署环节,模型对外的契约就三条:

  • 输入张量是NCHW排布的BGR图,数值范围0~1,归一化用ImageNet的均值0.485、0.456、0.406和方差0.229、0.224、0.225;
  • 输出张量是单通道alpha抠图,形状[N,1,H,W],值域理论上在0~1之间,越接近1表示越像前景人像;
  • 模型本身支持动态输入,但导出的ONNX文件是否保留动态轴,取决于PaddleSeg导出infer模型时的配置。

很多第一次部署的人在这里翻车,拿着Paddle的图像预处理文档直接套,却不知道PaddleSeg的transforms流水线里,归一化是除以255之后再做ImageNet标准化,那个"除以255"经常被漏掉,导致输出图整体偏亮或偏暗。另外输入要走BGR而不是RGB,Paddle的约定和OpenCV的读取顺序一致,中间多转一次RGB反而会通道错位。

2.2 Paddle2ONNX导出:opset版本和动态shape的选择

如果手里的模型是Paddle格式的infer目录(包含model.pdmodel和model.pdiparams),导出用Paddle2ONNX是最直接的:

paddle2onnx --model_dir ppmattingv2_infer \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file ppmattingv2.onnx \ --opset_version 11 \ --enable_onnx_checker True

参数说明:model_dir指向PaddleSeg的部署导出目录,PaddleSeg的export_model.py导出脚本会把训练权重转成带后处理的infer模型,这里直接喂给Paddle2ONNX就行;opset_version我习惯用11,ONNXRuntime对opset 11支持最稳,13以上在某些旧算子上有兼容坑;enable_onnx_checker开启结构检查,转完能立刻报出非法节点。

关于动态shape,Paddle2ONNX默认保留batch维度的动态性,但H和W维度是否动态,取决于输入的feed spec。PaddleSeg官方infer模型通常是固定尺寸的,比如[1,3,512,512]。固定shape的好处是ONNXRuntime可以提前做算子融合和内存规划,推理更快;坏处是这份ONNX只能处理512分辨率的输入,原图要么先resize进方框,要么改导出配置重新存一份。工程上我会同时保留两版:线上服务用固定shape版,客户端上传的图长短不一就统一letterbox进512;只有多分辨率场景才需要重新导出动态shape模型。

2.3 拿到ONNX后的第一件事:读输入输出节点名

ONNX文件里的节点名不是PaddleSeg代码里的变量名,而是导出时由前端映射过来的。不同Paddle版本导出的节点名可能是x、image、input_0等等。不先确认就硬编码,后面写代码必然出问题。用一段小脚本把它打出来:

import onnxruntime as ort sess = ort.InferenceSession("ppmattingv2.onnx", providers=["CPUExecutionProvider"]) for i, inp in enumerate(sess.get_inputs()): print(f"input[{i}]: name={inp.name}, shape={inp.shape}, type={inp.type}") for i, out in enumerate(sess.get_outputs()): print(f"output[{i}]: name={out.name}, shape={out.shape}, type={out.type}")

这段脚本的作用就一个:打印会话里真实的输入输出。运行后注意两点:输入名后面C++和Python代码里都要用它,别照抄别人博客里的x;输入shape里的[1,3,512,512]如果是固定数字,说明模型不支持动态分辨率,如果含有dynamic字样,说明导出时保留了动态轴,预处理策略就要换成按原图直接进模型。我把这段脚本放在部署代码的main函数最前面,每次拿到新模型先跑一次,省得后期排查接口对齐问题。

另外建议用onnx.checker再校验一遍文件完整性:

import onnx model = onnx.load("ppmattingv2.onnx") onnx.checker.check_model(model) print(onnx.helper.printable_graph(model.graph))

printable_graph会把整个图的节点和tensor信息打印出来,对付"推理时shape报错但不知道哪一层出问题"的情况很有用,能直接看到是哪个算子输出的维度走了样。

3. Python版部署:三块代码跑通预处理、推理与alpha后处理

Python版是整个链路的参考实现,先把行为验证明白,再让C++版对齐。建议按预处理、推理、后处理三段拆开来理解,不要揉在一个文件里,后面排查问题会省很多事。

注意:Python版跑通后再动C++版,两边行为不一致时优先怀疑预处理顺序,而不是模型本身。

3.1 预处理:BGR读取、letterbox与归一化的顺序

PaddlePaddle的默认图像输入约定是BGR,和OpenCV读取一致,所以直接用cv2.imread就好,别先转成RGB再送进模型,不然颜色通道全部错位,推理出来的alpha会把前后景区域整个搞反。预处理按下面的函数组织:

import cv2 import numpy as np MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) def preprocess(image_path: str, target_size: int = 512): img = cv2.imread(image_path) # BGR, HWC h, w = img.shape[:2] scale = target_size / max(h, w) # 长边对齐512 new_h, new_w = int(round(h * scale)), int(round(w * scale)) resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) canvas = np.zeros((target_size, target_size, 3), dtype=np.uint8) pad_x = (target_size - new_w) // 2 pad_y = (target_size - new_h) // 2 canvas[pad_y:pad_y + new_h, pad_x:pad_x + new_w] = resized tensor = canvas.astype(np.float32) / 255.0 # 除以255 tensor = (tensor - MEAN) / STD # ImageNet标准化 tensor = tensor.transpose(2, 0, 1)[np.newaxis, ...] # HWC -> NCHW return np.ascontiguousarray(tensor, dtype=np.float32), (h, w), (pad_x, pad_y, new_w, new_h)

参数说明:target_size必须和ONNX模型固定输入对齐,模型是512就用512,是768就用768,对不上inference直接抛维度错误;letterbox用长边对齐、短边居中填零的做法,避免直接resize把长宽比拉变形,人像变胖变扁之后alpha轮廓会跟着失真;scale、pad_x、pad_y三个中间值后面裁alpha要用,所以函数把它们一并返回。

transpose之后内存布局是非连续的,ONNXRuntime要求输入是连续内存,所以return之前用np.ascontiguousarray强制拷贝成连续数组。这一步漏了的话,某些版本的运行时在Feed时会报input tensor must be contiguous。

3.2 推理:会话创建、输入绑定与输出提取

ONNXRuntime的Python接口很简单:建session、组feed_dict、调run。但有几个参数值得单独说明:

import onnxruntime as ort def load_session(onnx_path: str): so = ort.SessionOptions() so.intra_op_num_threads = 4 so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL return ort.InferenceSession(onnx_path, sess_options=so, providers=["CPUExecutionProvider"]) def infer(sess: ort.InferenceSession, tensor: np.ndarray): input_name = sess.get_inputs()[0].name output_name = sess.get_outputs()[0].name outs = sess.run([output_name], {input_name: tensor}) return np.squeeze(outs[0]) # 去掉N和C维度,得到[H, W]

参数说明:intra_op_num_threads控制单算子内部的并行线程,纯CPU推理时我一般设为物理核数的一半到全部,初值给4,后面做真实并发压测再调;graph_optimization_level用ORT_ENABLE_ALL,ONNXRuntime会对计算图做算子融合和冗余消除,卷积密集的抠图模型很容易吃到收益;run的第二个参数是输入字典,key必须和2.3节打印的节点名一致。

sess.run返回的是list,顺序和传入的output_names列表对应。我习惯显式传单个output名,而不是用None返回全部输出,因为PP-MattingV2有的导出版本会带trimap分支或者中间特征,全返回白占内存。拿到后立刻np.squeeze,把[N,1,H,W]压成[H,W],后面所有处理都在单通道灰度图上做,逻辑最直接。

3.3 后处理:把预测范围拉回原图并合成新背景

模型输出的alpha值域未必严格落在0~1,有的导出版本因融合了网络尾部的归一化,数值会非常贴近0或1,但个别像素可能越界。直接存图会出现灰蒙蒙的观感。所以后处理里先clip再缩放,然后裁掉letterbox的padding,最后resize回原图尺寸:

def postprocess(alpha_model: np.ndarray, original_size, box, alpha_path="alpha.png"): h, w = original_size pad_x, pad_y, new_w, new_h = box alpha_cropped = alpha_model[pad_y:pad_y + new_h, pad_x:pad_x + new_w] alpha_resized = cv2.resize(alpha_cropped, (w, h), interpolation=cv2.INTER_LINEAR) alpha_8u = np.clip(alpha_resized * 255.0, 0, 255).astype(np.uint8) # 0~255 cv2.imwrite(alpha_path, alpha_8u) return alpha_8u

参数说明:先裁padding再resize是关键顺序,如果先resize再裁,padding区域的黑边会被拉伸成不规则边框,最终alpha图四边出现黑线;alpha_8u是0~255的uint8单通道图,可以直接存PNG,也方便后续合成;resize用INTER_LINEAR,alpha边缘的过渡在放大时更平滑,用INTER_NEAREST会导致锯齿。

合成新背景是验证抠图质量最快的方式,用alpha作为前景权重做逐像素混合:

def compose(fg_path: str, bg_path: str, alpha_8u: np.ndarray, out_path="composed.jpg"): fg = cv2.imread(fg_path) bg = cv2.resize(cv2.imread(bg_path), (fg.shape[1], fg.shape[0])) alpha_f = alpha_8u.astype(np.float32) / 255.0 alpha_3 = cv2.merge([alpha_f, alpha_f, alpha_f]) # 广播到三通道 blended = fg.astype(np.float32) * alpha_3 + bg.astype(np.float32) * (1.0 - alpha_3) cv2.imwrite(out_path, blended.astype(np.uint8))

说明:cv2.merge把单通道alpha复制成三通道,否则numpy的乘法和加法广播会把shape对不上;背景图尺寸必须和前景一致,resize里显式传了fg的宽高,避免因背景图分辨率不同导致blended尺寸错乱。到这里Python版全链路闭环,跑通之后C++版照搬同样的预处理和后处理逻辑,差异集中在张量构造上。

4. C++版部署:从Mat到Tensor的内存搬运与推理参数调优

C++版是生产环境的常客,资源里给的main.cpp我拆过,核心难点不在ONNXRuntime API本身,而在OpenCV的Mat和Ort::Value之间做内存搬运的方式。Python里numpy帮你托管内存,C++里每一步都要自己维护生命周期,这是两版最大的思维差异。

4.1 会话初始化:Ort::Env、SessionOptions与线程数

C++的骨架代码结构:

#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <iostream> #include <vector> #include <array> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "ppmatting"); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); const wchar_t* model_path = L"ppmattingv2.onnx"; Ort::Session session(env, model_path, opts); Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); std::cout << "input: " << input_name.get() << " output: " << output_name.get() << std::endl; return 0; }

说明几点:Windows下Ort::Session的构造函数重载接收const wchar_t*,Linux下用宽字符路径会编译报错,所以生产代码里我一般用#ifdef _WIN32分别走两套路径字符串;GetInputNameAllocated返回带智能指针的std::string,不需要手动release,比旧版的GetInputName少了内存释放的坑;SetIntraOpNumThreads这里设4,后面在第五章细讲为什么不是越大越好。

4.2 Mat转Tensor:BGR到CHW的两种搬运方式

ONNXRuntime的C++接口要的是连续内存,Ort::Value::CreateTensor可以把外部buffer包装成张量,不拷贝数据。这意味着只要std::vector 的生命周期覆盖到session.Run执行完,数据就是安全的:

void bgr_to_chw(const cv::Mat& bgr, int target_size, std::vector<float>& tensor, int& pad_x, int& pad_y) { int h = bgr.rows, w = bgr.cols; float scale = (float)target_size / std::max(h, w); int new_h = (int)std::round(h * scale); int new_w = (int)std::round(w * scale); pad_x = (target_size - new_w) / 2; pad_y = (target_size - new_h) / 2; cv::Mat resized, canvas(target_size, target_size, CV_8UC3, cv::Scalar(0, 0, 0)); cv::resize(bgr, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); resized.copyTo(canvas(cv::Rect(pad_x, pad_y, new_w, new_h))); canvas.convertTo(canvas, CV_32FC3, 1.0 / 255.0); std::vector<cv::Mat> channels(3); cv::split(canvas, channels); // 拆成B、G、R单通道 const float mean[3] = {0.485f, 0.456f, 0.406f}; const float stdv[3] = {0.229f, 0.224f, 0.225f}; tensor.resize(3 * target_size * target_size); for (int c = 0; c < 3; c++) { cv::Mat normed = (channels[c] - mean[c]) / stdv[c]; cv::Mat flat = normed.reshape(1, 1); // 变成1xN的连续行向量 memcpy(tensor.data() + c * target_size * target_size, flat.ptr<float>(0), target_size * target_size * sizeof(float)); } }

参数说明:cv::split把三通道HWC图拆成三个独立Mat,每个Mat内部连续,reshape(1,1)后整行数据连续,memcpy才能安全执行;for循环里c=0对应B通道,c=1对应G通道,c=2对应R通道,Paddle模型要的就是BGR顺序,不要在这里做通道重排。normed局部Mat用完就销毁,但memcpy已经把数据拷进tensor,不存在悬空引用。

另一种方式是直接按像素循环,把每个像素的BGR值分别灌进对应通道位置:

// 直接搬运版:for循环逐像素写入CHW,适合排查通道顺序,性能稍差 for (int i = 0; i < target_size; ++i) { for (int j = 0; j < target_size; ++j) { cv::Vec3f p = canvas.at<cv::Vec3f>(i, j); tensor[0 * target_size * target_size + i * target_size + j] = (p[0] / 255.0f - mean[0]) / stdv[0]; tensor[1 * target_size * target_size + i * target_size + j] = (p[1] / 255.0f - mean[1]) / stdv[1]; tensor[2 * target_size * target_size + i * target_size + j] = (p[2] / 255.0f - mean[2]) / stdv[2]; } }

这段代码逻辑上等价,但at 逐像素访问在release模式下还能接受,debug模式慢一个数量级。我在原型阶段用这个版本来排查通道顺序问题,确认无误后换成分通道memcpy版本上生产。

4.3 推理与输出解析:从Tensor拿回alpha并resize

std::array<int64_t, 4> input_shape{1, 3, target_size, target_size}; Ort::Value input_tensor = Ort::Value::CreateTensor<float>( allocator, tensor.data(), tensor.size(), input_shape.data(), input_shape.size()); std::array<const char*, 1> input_names{input_name.get()}; std::array<const char*, 1> output_names{output_name.get()}; Ort::RunOptions run_opts; auto outputs = session.Run(run_opts, input_names.data(), &input_tensor, 1, output_names.data(), 1); const float* alpha_data = outputs[0].GetTensorData<float>();

这里有个容易忽略的点:input_name.get()返回的指针在session.Run调用时必须仍然存活,所以正常局部变量写法没问题,但如果你把input_name声明成更小作用域并在Run之前析构,指针失效之后读到的就是乱码节点名。我一般把模型路径、输入名、输出名封装在一个struct里,session和名字同生命周期,彻底规避这个问题。

输出拿到的是float数组,PP-MattingV2的输出shape是[1,1,H,W],一维长度等于H乘W。接着用cv::Mat包一层,方便做和Python版相同的裁剪、resize:

cv::Mat alpha_raw(target_size, target_size, CV_32FC1, const_cast<float*>(alpha_data)); cv::Mat alpha_cropped = alpha_raw(cv::Rect(pad_x, pad_y, new_w, new_h)); cv::Mat alpha_resized; cv::resize(alpha_cropped, alpha_resized, cv::Size(w, h), 0, 0, cv::INTER_LINEAR); alpha_resized *= 255.0f; cv::Mat alpha_8u; alpha_resized.convertTo(alpha_8u, CV_8UC1); // convertTo自带饱和截断 cv::imwrite("alpha.png", alpha_8u);

说明:GetTensorData返回const float*,而cv::Mat构造需要非const指针,这里用const_cast是因为只读不写,属于可接受的取值;alpha_raw和outputs[0]共享同一块内存,只要outputs没被析构,Mat的数据就有效;resize之后乘255再convertTo转uint8,和Python版的clip逻辑对齐,因为PaddleSeg导出模型不带后处理,浮点alpha值域理论上在0~1附近,但防御性截断建议保留。

5. 部署避坑:输出图全黑、节点名不匹配与线程数迷思

5.1 抠出来的图全是黑的:浮点alpha别直接当uint8存

现象:推理不报错,但保存的alpha.png看起来全黑,用图像查看器放大才能看到一点灰色轮廓。

原因:模型输出是0~1浮点数。如果直接把CV_32FC1的Mat传给cv::imwrite,OpenCV按0~1范围映射,接近0的像素全被压成黑;如果先转CV_8UC1再存,小于1的像素全部截断为0,alpha只剩边缘一点点灰度。

解决:后处理强制走"clip(0,1) -> 乘255 -> 转uint8"三步。Python里是np.clip(alpha * 255, 0, 255).astype(np.uint8),C++里先alpha_resized *= 255.0f,再convertTo,依赖convertTo的饱和截断。这条是最常见的翻车点,没有之一。

5.2 输入节点名不匹配导致abort:别硬编码,动态取

现象:代码在开发机上编译通过也能跑,换了个模型文件之后session.Run直接抛异常,报错信息大意是input name not found。

原因:PaddleSeg不同版本导出的ONNX,输入节点名不完全一致。资源里的模型可能叫x,但用新版PaddleSeg重新导出的结果可能叫image。代码里硬编码的"x"和模型实际节点名对不上时,ONNXRuntime在Feed阶段直接失败。

解决:始终用session.GetInputNameAllocated(0, allocator)取名字,存成std::string,所有调用处都引用这个变量。我还会在main函数开头打印出来,和2.3节的Python脚本输出对照。这个习惯能避免大量接口对齐问题。

5.3 长宽比超过2:1的图,固定512模型效果崩坏

现象:半身照、正脸照抠出来效果不错,但全身照的发丝直接糊成一团,边缘还有白边。

原因:模型输入固定512x512,letterbox按长边对齐后,全身照的头部区域只占很小的面积,人像细节经过resize和归一化之后信息丢失严重。

解决:区分使用场景。头像类应用(画面接近1:1)直接用512模型没问题;电商全身图或视频会议半身场景,优先把模型导成768或1024的固定输入,或者用动态shape的ONNX,推理时把长边压在1024内。另外可以配合裁剪策略:先用检测框把人像主体裁出来填满输入再推理,alpha再映射回原图坐标,效果比直接塞整图好得多。

5.4 多路并发时每张都慢:线程数不是越多越好

现象:单路测速50ms,很满意。压测8路并发时,整体吞吐不升反降,单张耗时飙到200ms以上。

原因:每一路都创建了独立session,每个session都设SetIntraOpNumThreads(8),8路就是几十个线程在有限物理核上争抢CPU,频繁上下文切换,缓存命中率下降。

解决:线程数按物理核数的一半起步,比如8核16线程的机器先设4;如果服务端同时还有别的计算任务,再降到2。session本身线程安全,多路请求共享同一个Ort::Session实例是官方支持的做法,能省掉重复的模型加载和图优化开销。我在压测前都会用lscpu看一遍拓扑再定线程数,不盲目堆核。

5.5 同一个ONNX在不同机器上结果不一致:算子优化等级没对齐

现象:开发机上抠图效果正常,部署到无GPU的服务器后,发丝边缘出现细小噪点,远看没问题,放大看有颗粒感。

原因:开发环境可能用的是onnxruntime-gpu,GPU和CPU的算子内核实现不同,浮点累加顺序有差异;加上graph_optimization_level没统一,部分融合算子没有生效。

解决:两边统一用CPU版onnxruntime包,graph_optimization_level都开到ORT_ENABLE_ALL。差值通常在10的负三次方量级,但视觉任务对边缘的微小差值敏感,alpha后处理会把这种差异放大成可见噪点。这个坑比较隐蔽,我是在一次线上换机器后才定位到的,从此固定了优化等级的基线。

6. 把抠图结果用到实处:背景合成、GPU切换与耗时统计

6.1 背景合成的细节:alpha边缘需要一次高斯平滑

换背景不是简单的线性混合。模型返回的alpha里,发丝区域往往是一个从0到1的窄带,不做处理直接合成,发丝边缘会出现白色或黑色晕斑。常见做法是在alpha 8u上做一次轻量高斯模糊:

alpha_smooth = cv2.GaussianBlur(alpha_8u, (5, 5), 0) alpha_f = alpha_smooth.astype(np.float32) / 255.0

高斯模糊核越大边缘过渡越软,但头发丝细节也会被抹掉,5x5是我试下来在细节保留和过渡自然之间比较平衡的取值。如果抠图对象有大片纯色背景,残影主要来自轮廓,可以先做3x3的中值滤波去孤立噪点,再做高斯。

6.2 从CPU切到GPU:Python改一行,C++注意包版本

Python端切换很轻,providers参数加一条CUDAExecutionProvider,并保证装的是onnxruntime-gpu而不是onnxruntime:

so = ort.SessionOptions() sess = ort.InferenceSession("ppmattingv2.onnx", sess_options=so, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])

CUDAExecutionProvider要写在前面,ONNXRuntime按顺序选择可用provider,CPU兜底。C++端要链接onnxruntime_gpu的库,且CUDA、cuDNN版本必须和运行时版本匹配,我踩过的坑是CUDA 12环境装了为CUDA 11编译的包,加载session时直接abort,所以C++上GPU我一般先跑通CPU再换包,两步分开验证。

6.3 耗时统计:先20次预热,再统计50次的p50与p95

验证性能时别用一次time.time的差值当结论。第一次推理包含内存分配和算子pattern探索,慢得没参考价值。我习惯这样统计:

import time for _ in range(20): # 预热 sess.run(None, {input_name: tensor}) lats = [] for _ in range(50): t0 = time.perf_counter() sess.run(None, {input_name: tensor}) lats.append((time.perf_counter() - t0) * 1000) lats.sort() avg = sum(lats) / len(lats) p50 = lats[len(lats) // 2] p95 = lats[int(len(lats) * 0.95)] print(f"avg={avg:.1f}ms p50={p50:.1f}ms p95={p95:.1f}ms")

看p95远比看平均值重要,服务端被偶发慢请求拖垮的案例,问题几乎都出在长尾上。我处理过一个视频会议类的Demo,CPU推理平均只有40ms,但p95经常跳到150ms,后来发现就是线程数设太大导致频繁调度,把线程从8降到4之后p95稳定在60ms以内。从那以后每次调性能我都先确认机器拓扑再跑统计脚本,确认p95达标才把参数固化下来,这一套流程对CPU和GPU部署同样适用,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询