从 PyTorch 里的 demo 到线上高并发服务,中间隔着一整条“性能鸿沟”。如果你也遇到过 GPU 利用率不到 30%、请求一多就超时、服务进程动不动内存翻倍的问题,这篇基于 YOLOv5 + TensorRT + C++ + HTTP 的部署实践应该能帮上忙。文章会把一条完整的高性能推理服务链路拆开讲:为什么选这套组合、ONNX 怎么导出、TensorRT 引擎怎么构建、C++ 推理侧怎么让显存和内存“物尽其用”、HTTP 接口怎么设计、客户端怎么压测,最后再把上线后踩过的坑一并复盘。
先说结论:同样的 YOLOv5s 模型、同一张 GPU,我从 Python 方案迁到这套 C++ 方案之后,单帧延迟从大约 120ms 降到 18ms 左右,吞吐从不到 10 QPS 提到 50+ QPS(只算推理服务内部,不含业务后处理),而且内存占用从 4GB 稳定在 900MB 上下。数字不重要,重要的是这套架构里每一个环节的选择逻辑,理解了“为什么”,换任何模型和硬件都能复用同一套思路。
1. 为什么是 YOLOv5 + TensorRT + C++ 这套组合:需求拆解与架构取舍
1.1 先算一笔账:模型快不等于服务快
很多团队一开始跑 demo 都挺顺,PyTorch 加载权重,OpenCV 读图,直接 forward,结果也很准。但只要把服务往外一放,并发一上来,性能立刻崩。这不是模型本身的问题,而是整个服务链路没有为“高吞吐”做过专门设计。
我习惯先拆解一次完整请求的时间构成。典型的 Python + Flask + PyTorch 方案里,一个检测请求大概消耗在以下环节:
- HTTP 请求解析、图片字节读取:5~15ms,取决于 Web 框架和网络库。
- 图片解码(JPEG 转 BGR):10~40ms,纯 CPU 操作,且 OpenCV 的 JPEG 解码在高分辨率图片上非常吃资源。
- 预处理(resize、归一化、HWC 转 CHW):5~10ms,Python 循环处理逐像素操作时更慢。
- 模型推理:GPU 上实际跑 YOLOv5s 可能只有 10~20ms,却被上述 CPU 操作拖累。
- 后处理(阈值过滤、NMS):10~30ms,纯 Python 实现时尤其明显。
- 结果序列化与返回:5~10ms。
把这些加起来,单帧 120ms 一点都不夸张。更要命的是 Python 的 GIL 让多线程推理形同虚设,进程多开又导致显存翻倍。GPU 在大部分时间都在空转,因为 CPU 侧把饭送到嘴边之前,GPU 早就把上一口吃完了。
C++ 解决的就是这几个痛点:图片解码、预处理、后处理全部用编译型代码完成,内存预分配、避免频繁 small object 分配,多线程并行没有 GIL 卡脖子。TensorRT 解决的是模型本身的执行效率,把 GPU 上的算子做融合和精度重排。这两个东西叠起来,才是“高性能”的真正来源。
1.2 TensorRT 带来的收益是算子级优化,不是玄学
有人觉得 TensorRT 就是把模型“变了个格式”,加速效果全靠玄学。其实它的核心优化路径是清晰的:
- 算子融合:把卷积 + 偏置 + 激活函数合并成单个 kernel,减少显存读写和 kernel 启动开销。
- 精度选择:FP16、INT8 等低精度计算,在大多数视觉模型上损失精度很小,但吞吐提升明显。
- Kernel 自动调优:针对具体 GPU 架构选择最优的 kernel 实现。
- 显存复用:通过分析整个计算图,复用中间张量的显存,减少峰值显存占用。
所以 TensorRT 的收益不是靠运气,它是把 GPU 计算过程中的“空转时间”和“多余搬运”尽量挤掉了。对于 YOLOv5 这种以卷积为主的模型,收益尤其明显。FP32 转 FP16 通常能带来 1.5 到 2 倍的推理速度提升,显存占用还能大幅下降。INT8 则能再进一步,但需要校准数据集,适合对精度损失接受度较高的场景。
1.3 HTTP 为什么仍然是最合适的对外接口
有人会问:都追求高性能了,为什么不直接用 gRPC 或者裸 TCP?我的选择是:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| HTTP + JSON | 调试简单、工具链成熟、防火墙友好、可读性强 | 序列化/反序列化有开销 | 内部服务、公网 API、需要快速迭代 |
| HTTP + 原始字节 | 省去 base64 开销,性能接近 TCP | 需要约定 Content-Type,不够通用 | 图片检测/分类这类媒体处理服务 |
| gRPC | 二进制协议高效,自带流式、负载均衡 | 需要生成 proto、部署工具链更重 | 微服务间标准化通信 |
| 裸 TCP | 性能极值 | 没有协议层,粘包拆包全靠自己写 | 极度在意延迟且团队有能力造轮子 |
在实际项目中,我们最后还是选了 HTTP + 原始字节请求体 + JSON 响应。理由很现实:内部服务要经常调试,curl 一条命令就能发图测试;负载均衡器和监控工具对 HTTP 的支持最好;业务侧前端、后端、脚本都能轻松对接,团队不需要引入额外 SDK。
HTTP 本身确实有协议开销,但只要做好连接复用,把请求体设计成原始图片字节而不是 base64,性能差距会大幅缩小。真正影响性能的从来不是协议本身,而是协议之上的序列化方式和连接管理策略。
2. 模型侧的准备:从 PyTorch 权重到可部署的 TensorRT 引擎
2.1 先导出 ONNX:opset、动态维度是第一个坑
TensorRT 不能直接吃 PyTorch 权重,通常要经过 ONNX 这个中间格式。导出 ONNX 这一步很多人觉得简单,实际上踩坑最多。
我的导出脚本大概长这样,关键点我都写在注释里:
import torch import torch.nn as nn from models.experimental import attempt_load model = attempt_load("yolov5s.pt", map_location="cpu") model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, "yolov5s.onnx", opset_version=11, input_names=["images"], output_names=["output"], dynamic_axes={ "images": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch"}, }, do_constant_folding=True, verbose=False, )这里有个经验:opset 版本不是越高越好。TensorRT 对高版本 ONNX 算子支持不一定及时,我一般锁定在 11 或 12。opset 太高导致某些算子不支持,反而要在 TensorRT 里回退到低效实现。如果导出后某些自定义算子无法被 TensorRT 解析,优先检查是不是用了 YOLOv5 里某些特殊模块导出成了兼容性差的算子组合。
另一个经验是动态维度。动态 batch、动态宽高意味着 TensorRT 在运行时可能要做更复杂的显存规划和 kernel 选择,性能通常比固定尺寸略低。如果业务场景的输入分辨率可以固定,我建议直接固定成 640×640,省掉一堆麻烦。如果一定要动态,至少把宽高在 320 到 1280 之间设置 profile,避免极端尺寸导致显存规划失控。
2.2 构建 TensorRT 引擎:相同源码,不同精度,性能差一倍
TensorRT 引擎构建通常分两步:创建 builder、network 和 config,然后构建 engine。构建阶段非常耗时,可能从几十秒到几分钟不等,所以一定要把构建好的 engine 序列化到磁盘,之后直接从文件加载,而不是每次启动都重新构建。
构建环节的伪代码逻辑如下:
// 从 ONNX 解析网络结构 IBuilder* builder = createInferBuilder(logger); INetworkDefinition* network = builder->createNetworkV2(1U << static_cast<int>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH)); IParser* parser = createParser(network, logger); parser->parseFromFile("yolov5s.onnx", 1); // 配置构建选项 IBuilderConfig* config = builder->createBuilderConfig(); config->setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1U << 30); // 工作空间上限 1GB config->setFlag(BuilderFlag::kFP16); // 开启 FP16 // 构建引擎并序列化到文件 IHostMemory* serialized = builder->buildSerializedNetwork(*network, *config); std::ofstream out("yolov5s_fp16.trt", std::ios::binary); out.write(static_cast<char*>(serialized->data()), serialized->size());关于精度的选择,我给一个参考表:
| 精度 | 推理速度 | 显存占用 | mAP 损失 | 适用场景 |
|---|---|---|---|---|
| FP32 | 基准 | 高 | 无 | 调试、精度要求极高 |
| FP16 | 约 1.5~2 倍于 FP32 | 约为 FP32 的 60% | 通常 < 0.5% | 绝大多数生产环境首选 |
| INT8 | 约为 FP32 的 2~4 倍 | 更低 | 1%~3%,取决于校准集 | 对速度极端敏感、可接受精度损失 |
实际项目里,我默认走 FP16。INT8 我在另一个场景试过,校准集选择和训练集分布差异大时,检测小物体的精度崩得厉害,所以除非有完整校准方案,否则不建议无脑上 INT8。
还有一个很多人忽视的点:kWORKSPACE的工作空间大小。给太小会导致某些融合算子无法启用,给太大虽然构建阶段可能成功,但运行时显存规划会偏保守。我的经验是 1GB 上限通常够用,没必要给到几 GB,除非是超大模型。
2.3 模型仓库与引擎版本管理:否则你会在深夜后悔
模型一旦进了 TensorRT,就变成了一个文件,而且这个文件是“绑定 GPU 架构”的。同一份引擎文件在 A 卡上构建,拷贝到 B 卡大概率跑不起来,或者性能极差。所以引擎文件必须和 GPU 型号、TensorRT 版本、输入尺寸、精度一起做版本管理。
我的做法是在目录下放一个model_info.json记录元数据:
{ "model_name": "yolov5s", "precision": "fp16", "input_size": [640, 640], "trt_version": "8.4.1", "gpu": "some_gpu", "batch_range": [1, 8], "engine_file": "yolov5s_fp16.trt", "build_time": "2024-06-01T12:00:00Z" }每次启动服务时先读这个文件,校验当前运行环境的 GPU 和 TensorRT 版本,不匹配就直接报错退出,而不是等跑到一半才发现模型输出一堆乱码或者干脆崩溃。这个习惯帮我避免了至少三次“测试环境好端端、生产环境一启动就崩”的深夜事故。
3. C++ 推理侧的实现骨架:从请求字节到检测框
3.1 资源管理:context 是线程私有的,engine 是只读共享的
TensorRT 的线程安全模型非常容易踩坑。ICudaEngine本身是可以多线程共享读取的,适合全局只创建一次;但IExecutionContext并不是线程安全的,而且每个 context 的显存优化策略不同,必须在各自线程里独立创建。
所以我这边核心对象的生命周期设计是:
- 全局唯一
ICudaEngine*:启动时加载,全生命周期复用。 - 每个推理线程一个
IExecutionContext*:线程启动时创建,只在当前线程内使用。 - 每个线程一个
cudaStream_t:用于异步推理,避免阻塞 CPU 侧。
第一版代码贪图省事,让所有线程共享同一个 context,结果并发一上来直接报cudaErrorMisalignedAddress,或者干脆输出错的检测结果。这属于 TensorRT 中最不应该拿来“赌一把”的环节,老老实实按线程绑定 context。
3.2 预处理必须和训练时保持一致:letterbox 与归一化
YOLOv5 训练时用了 letterbox 预处理:把原图等比缩放到目标尺寸,剩余区域填充灰色(114, 114, 114)。如果你在部署阶段把图片直接 resize 到 640×640,而不是 letterbox,小目标检测精度会明显下降,因为图中物体被拉伸变形了。
我封装的预处理逻辑如下,用 C++ 实现比 Python 快一个数量级:
// 输入: cv::Mat src, 输出: float* dst (已经 NCHW 排列) void letterbox(const cv::Mat& src, float* dst, int target_size) { int w = src.cols, h = src.rows; float scale = std::min(static_cast<float>(target_size) / w, static_cast<float>(target_size) / h); int new_w = static_cast<int>(w * scale); int new_h = static_cast<int>(h * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); cv::Mat canvas(target_size, target_size, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(canvas(cv::Rect((target_size - new_w) / 2, (target_size - new_h) / 2, new_w, new_h))); // 归一化: HWC -> CHW, 像素值除以 255 int channels = 3; for (int c = 0; c < channels; c++) { for (int i = 0; i < target_size * target_size; i++) { dst[c * target_size * target_size + i] = canvas.data[i * channels + c] / 255.0f; } } }这里有个性能细节:逐像素循环不要用普通双层循环按 pixel 遍历,而是按通道连续内存遍历,这样 CPU 缓存命中率高得多。实测同样一张 1080p 图片,按通道遍历比按像素遍历快 30% 以上。
3.3 推理与输出解析:YOLOv5 的 25200 个候选框要怎么处理
YOLOv5 在 640×640 输入下,输出张量形状是[1, 25200, 85]。25200 来源于三个检测头:80×80×3 + 40×40×3 + 20×20×3,85 对应4 个坐标 + 1 个置信度 + 80 个类别。TensorRT 推理完拿到的是一个连续 buffer,后面就是纯 CPU 的解析工作。
我的后处理思路是分两步走:
第一步,先按置信度阈值过滤。遍历 25200 个候选框,只保留 score > 0.25 的框,同时记录类别索引。这一步把绝大多数候选框直接淘汰,后续 NMS 的数据量大幅降低。
第二步,做类别内 NMS。对每个类别单独做 IoU 过滤,IoU 阈值一般是 0.45 到 0.5。这个逻辑用 C++ 实现时,最简单的就是双重循环保留最高分框,实测候选框少的时候性能完全够用。
代码核心逻辑如下:
struct Detection { float x1, y1, x2, y2; // 相对于原图的坐标 float score; int class_id; }; std::vector<Detection> decodeOutput(float* raw_output, int orig_w, int orig_h, float conf_thresh, float iou_thresh) { const int num_boxes = 25200; const int num_classes = 80; std::vector<Detection> result; for (int i = 0; i < num_boxes; i++) { float* row = raw_output + i * 85; float box_conf = row[4]; if (box_conf < conf_thresh) continue; int class_id = -1; float max_cls = 0.0f; for (int j = 0; j < num_classes; j++) { if (row[5 + j] > max_cls) { max_cls = row[5 + j]; class_id = j; } } float final_score = box_conf * max_cls; if (final_score < conf_thresh) continue; // 这里的坐标是相对于模型输入尺寸的,需要映射回原图 // x, y, w, h 的原始含义由 YOLOv5 的 anchor 编码决定 Detection det; det.score = final_score; det.class_id = class_id; // 省略坐标反算和 letterbox 偏移还原的代码... result.push_back(det); } // 按类别做 NMS std::vector<Detection> nms_result; // 双重循环按 score 降序做 IoU 抑制... return nms_result; }实际生产代码比这个复杂,因为坐标的反算要考虑每个检测头不同的 stride,以及 letterbox 的缩放和偏移还原。但这些是实现细节,原理就是先把模型输出转化为“原图坐标系的框”,再做 NMS。
这里提醒一句:如果直接拿解析出来的框框去画图,经常发现框偏了、尺寸不对,绝大多数问题是 letterbox 反还原没做对。务必把缩放比例和 pad 偏移记录清楚,从模型坐标映射回原图坐标时要算回去。
3.4 显存和 CPU 内存的预分配:别在请求路径上 new 数组
高性能服务的大忌是在请求处理过程中频繁做动态内存分配。虽然 C++ 有内存分配器,但高频请求下的new/delete仍会造成性能波动和内存碎片。
我的做法是:
- 输入输出 buffer 全部预先分配好,按最大 batch 和最大输入尺寸分配,服务生命周期内不释放。
- 每个推理线程维护自己的临时缓冲区和后处理容器,线程之间不共享。
- JPEG 解码的图片 Mat 尽量复用,OpenCV 的
imdecode目标 Mat 可以在循环外创建,用cv::Mat传引用让内部复用分配好的内存。
有人会觉得预分配浪费内存,但一台推理服务器上,显存往往比内存更稀缺。把 CPU 内存预分配个几百 MB 换稳定的帧间隔,这个账很划算。
4. HTTP 服务层怎么设计,才能兜住高性能推理
4.1 接口约定:图片走原始字节,结果走 JSON
我见过不少团队把图片 base64 编码后放到 JSON 里上传,这是性能杀手。一张 200KB 的 JPEG,base64 编码后变成约 267KB,多出 33% 的体积,还增加了编码和解码的 CPU 开销。在高 QPS 下,这部分开销会占掉相当一部分 CPU 预算。
我们的接口约定很简单:
POST /detect Content-Type: image/jpeg Body: <原始图片字节> Response: { "code": 0, "time": 18.2, "detections": [ {"x1": 120, "y1": 40, "x2": 260, "y2": 300, "score": 0.92, "class_id": 0} ] }这样做的好处有三点:
- 请求体是纯字节流,服务端拿到后直接
cv::imdecode,省去 base64 解码。 - 响应体仍然是 JSON,方便业务侧解析,不用自己写二进制协议。
- 用 curl 调试时一眼能看明白,不需要额外写 client。
如果要支持多张图片,我建议还是拆成多次请求,而不是搞一个“批量图片数组”的接口。因为 HTTP 连接可以复用,多次请求的开销远小于单次请求内传多张图的复杂度。除非你实现服务端自动凑 batch(下一节讲),否则不要把多个图片塞进一个请求。
4.2 线程模型:请求线程只做分发,推理线程池承载计算
如果直接把 HTTP 请求处理函数里做推理,并发一高就会出现两种问题:一是请求线程阻塞导致后续请求排队,二是多个请求同时访问 TensorRT context 引发崩溃。
更合理的设计是解耦:
- 一个或几个 IO 线程负责接收 HTTP 请求、解析图片、写入任务队列。
- 一个固定大小的推理线程池负责从队列取任务,执行 TensorRT 推理和后处理,把结果放回响应队列。
- IO 线程再把结果写回 HTTP 连接。
这个生产者-消费者模型的好处是:HTTP 接收和模型推理可以并行,图片解码可以流水线化,下一张图的解码和上一张图的推理同时在进行。
具体到线程数量,我建议推理线程数不超过 TensorRT 引擎的最大并发数(通常 2~4 个就够了),IO 线程数和 CPU 核数相关。线程不是越多越好,因为 GPU 上的 kernel 是串行执行的,太多线程抢同一个 GPU 只会增加上下文切换开销。
4.3 自动 batch:把并发请求凑成一帧推理
这是性能优化里最值得做的一招。TensorRT 的 batch 越大,单位时间处理的总帧数越高。YOLOv5s 在 batch=1 时可能只有 50 QPS,但 batch=4 时能达到 120 QPS 以上,因为 GPU 计算资源被更充分地利用。
实现方式是在任务队列上做“批聚合”:推理线程从队列取任务时,如果队列里还有等待任务,而且当前 batch 没到上限,就多取几个任务一起推理。关键是要设置一个最大等待时间(比如 3ms),避免为了凑 batch 让单帧延迟增加到用户无法接受的程度。
伪代码如下:
std::vector<Task> batch; batch.push_back(queue.pop()); while (batch.size() < max_batch && queue.wait_for(chrono::milliseconds(3))) { batch.push_back(queue.pop()); } // 统一 resize 到模型输入尺寸,执行 TensorRT 推理 runInference(batch);这里有一个映射关系非常重要:batch 里每个任务对应输入 tensor 的一维,推理完成后按顺序把输出拆分回各个任务。只要任务顺序不要乱,一切问题都好说。
5. 客户端与压测:验证性能不能只靠“感觉”
5.1 客户端该关注什么:连接复用 + 合理超时
很多人在压测服务器时,用 Postman 或者简单的单线程脚本发几十个请求就算完。这样测出来的数据跟生产环境完全不是一回事。正确的高性能客户端要做两件事:连接复用和并发请求。
如果用的是 libcurl,开启连接复用很容易:同一 CURL 句柄连续发起请求即可,curl 会自动复用 HTTP keep-alive 连接。更省事的做法是封装一个连接池,每个线程一个自己的 curl 句柄,避免句柄跨线程使用。
超时设置也很关键。服务端如果暂时满负荷,客户端立刻超时重试,大量重试会导致雪崩。我的经验是:连接超时设 2 秒,请求超时设 5 秒,重试次数不超过 2 次,重试之间加指数退避。
5.2 压测方法与数据:一次完整的性能验证流程
拿一批有代表性的真实图片(不要用纯白图纯黑图,阳性样本至少要占一半),循环 POST 到服务端。压测时我习惯关注三个指标:
- 平均延迟:所有请求的耗时均值。
- P95/P99 延迟:代表最差情况下的体验。
- QPS:每秒能处理多少请求。
我这边在一张消费级 GPU 上做过的对照数据大概是这样:
| 方案 | 平均延迟 | P99 延迟 | QPS | 显存占用 | 内存占用 |
|---|---|---|---|---|---|
| Python + PyTorch + Flask | 122ms | 380ms | 8 | 约 4GB | 4.2GB |
| C++ + PyTorch + HTTP | 68ms | 150ms | 15 | 约 4GB | 1.5GB |
| C++ + TensorRT FP16 + HTTP | 21ms | 42ms | 46 | 约 1.2GB | 0.9GB |
| C++ + TensorRT FP16 + 自动 batch | 18ms | 35ms | 55+ | 约 1.3GB | 0.9GB |
数据是参考值,不同图片分辨率、不同模型大小都会带来浮动,但趋势是稳定的:模型推理引擎的优化(TensorRT)和语言侧开销的削减(C++),两者叠加才能带来数量级的提升。
压测过程中要盯着 GPU 利用率看,如果 GPU 利用率一直上不去(比如低于 50%),通常意味着 CPU 侧预处理或者 HTTP 解析成了瓶颈;如果 GPU 利用率长期 90% 以上,说明服务器已经接近上限,加 CPU 核也没用,只能换更强的 GPU 或者缩小输入尺寸。
我第一版压测时发现 QPS 到 30 就上不去了,GPU 利用率只有 60%,排查后发现 JPEG 解码全在一个线程里执行,成了瓶颈。后来把解码挪到多个 IO 线程并行,QPS 立刻跳了一大截。这类瓶颈在监控数据上非常明显,前提是你真的去看监控。
5.3 客户端压测的常见误区和数据处理细节
有人压测时直接用本地生成的随机噪声图,这种数据完全不能代表真实场景。随机图的解码耗时、检测出的候选框数量都跟真实业务差得很远,导致压测结果虚高或虚低。正确做法是抓一批线上真实图片,覆盖不同分辨率、不同光照、不同物体数量,放到本地一个测试目录,每次压测随机抽取。
候选框数量对后处理的影响也很大。如果一张图里有几十个目标,后处理 NMS 的开销会比一张空场景图高好几倍。压测报告里最好标注“平均每帧检测框数”,否则跟别人对比性能数字时根本没有参照系。
6. 上线后踩过的坑与调优复盘
6.1 图片解码险些成为最大的性能瓶颈
这是我在第一版压测时遇到的第一个问题。直觉上,模型推理应该是最慢的,但实际 profiling 发现 JPEG 解码占了整个请求链路 35% 以上的 CPU 时间。特别是手机拍摄的高像素照片,一张 1200 万像素的 JPEG,解码就要 40~50ms。
解决办法有三层:
- 限制上游传入图片大小,超过 1920 像素的图片先由客户端缩略。
- 服务端解码前先读取 JPEG 头部,如果尺寸过大立刻拒绝或降采样解码。
- 解码放在 IO 线程池里并行执行,而不是在推理线程里串行处理。
第二层尤其重要。OpenCV 的imdecode有降采样解码参数,IMREAD_REDUCED_COLOR_2可以直接按 1/2 尺寸解码,速度能快 3 倍以上。YOLO 反正要 letterbox 到 640,源图分辨率超过 1280 之后,再高的像素对检测精度几乎没有贡献,不如提前降采样换取速度。
6.2 推理并发没来得及处理时 HTTP 超时,队列无限增长让内存爆掉
流量高峰时,任务队列里的请求会越积越多。如果队列是无界的,内存占用会一路飙升到 OOM。这个问题在生产环境出现过一次,排查到最后发现罪魁祸首就是std::queue::push_back没有做容量限制。
解决方案是带背压的有界队列。当队列满了,新的请求直接返回503 Service Unavailable,而不是无限排队。业务端对这种错误会有兜底策略,比如稍后重试、降级到备用模型服务,总比让整个服务进程 OOM 重启要好。
队列深度建议根据最大可接受等待时间反推。比如单帧推理 20ms,允许请求排队 200ms,那队列深度200ms / 20ms = 10,有界队列就控制在 10 到 20 之间,留一点缓冲。
6.3 TensorRT 上下文与动态输入 shape 的配合问题
之前提到 context 是线程私有的,但还有一个坑是动态输入 shape。使用动态 batch 时,每个 context 需要调用setInputShape设置当前推理的输入形状,而且这个设置必须在每次推理前做,因为下一次推理的 batch size 可能变了。
代码很容易写出这种 bug:第一次推理 batch=4 成功后,第二次推理 batch=2 时忘了重新setInputShape,结果 TensorRT 内部还在按上一轮的 shape 处理,输出 buffer 越界写入,整个进程崩溃。我的习惯是封装一个InferenceSession类,把“设置 shape -> 绑定 buffer -> 执行推理 -> 等待流完成”这个流程固定下来,每次推理都完整执行,不留任何“上次状态”的依赖。
6.4 从结果 JSON 到业务端的数据合同
这个坑不在服务端,而在服务端和业务端的协作上。YOLOv5 的类别 ID 是 COCO 数据集的 80 类编号,业务侧如果不做映射,经常会遇到“明明检测到了 person,但业务端当成了 dog”这种低级的乌龙。
我在服务返回里额外加了一个class_name字段,而不是只返回class_id,虽然增加了几个字节的传输,但极大地减少了跨团队协作时的误解。另外,坐标一律用浮点数返回,业务端负责四舍五入到整数,不要在服务端提前截断,否则画框时边缘会歪。
6.5 最终效果复盘:性能之外,这套架构带来的长期收益
上线三个月后回头看,这套 YOLOv5 + TensorRT + C++ + HTTP 的架构带来最大的收获其实不是快了多少倍,而是服务的可预测性。Python 方案在流量高峰期延迟会剧烈抖动,C++ 方案即使在接近满载时,P99 延迟也只是平均延迟的两倍左右,整体表现非常平稳。
运维上也简单了不少。服务进程启动后加载引擎文件,再起固定数量的线程,资源占用从头到尾基本不变,不用担心 Python 里 GC 导致的内存波动。日志系统用标准输出打 JSON 日志,接入监控告警非常顺手。
如果你准备在自己项目里复刻这套方案,我最后再给一条建议:先把数据链路跑通,再谈优化。先用最简单的 C++ 代码把 HTTP 请求、推理、响应全部连通,用压测脚本记录基线数据,然后一项一项做优化,每步都对照数据看收益。性能优化最怕一上来就堆复杂架构,最后都不知道瓶颈在哪、优化到底有没有效果。