GPU 利用率很低不一定是模型慢;利用率很高也不代表整条视频任务足够快。用户等待的是从输入视频到可播放结果的总时间,而这段时间由读取解码、预处理、推理、后处理、绘制和编码共同组成。本文用固定视频任务说明怎样定位瓶颈,而不是只报一个模型 FPS。
目录
- 先拆开端到端耗时
- 不同瓶颈该怎么处理
- 最小测量实现、测试与 SQL
- 验收边界
- 性能结论怎样才能比较
- 小结
一、先拆开端到端耗时
总耗时 = 探测/解码 + 预处理 + 推理 + 后处理 + 绘制/合成 + 编码/写盘同一模型在图片基准中很快,放进视频任务仍可能受解码、CPU 内存复制或编码限制。性能记录必须按阶段保留帧数、总耗时、平均耗时和长尾耗时。
图1:模型 FPS 不是用户等待时间,性能必须落到整条视频处理链。
二、不同瓶颈该怎么处理
| 瓶颈 | 典型症状 | 首先检查 | 可行方向 | 不应直接做 |
|---|---|---|---|---|
| 解码/读取 | GPU 空闲、读帧不足 | 编码、磁盘、读取耗时 | 批量读取、合适解码器 | 盲目换更大 GPU |
| 预处理 | CPU 高、推理等待 | resize、颜色转换、内存复制 | 合并操作、减少复制 | 降模型精度掩盖问题 |
| 推理 | GPU 阶段占比最高 | 输入尺寸、batch、运行时 | 选择模型规格与运行时 | 只报单图 FPS |
| 后处理/绘制 | 模型快但整体慢 | NMS、轨迹、文字绘制 | 减少重复计算 | 丢掉必要证据 |
| 编码/写盘 | 处理后仍长时间卡住 | 编码器、码率、磁盘 | 合适编码与异步交付 | 把临时文件当结果 |
表格只能提供第一眼的判断,真正排查时应从任务时间线倒推,而不是看到 GPU 利用率低就先怀疑模型。下面以固定的VP-20261005-001视频任务为例:输入为 90 秒、1080P、25fps 的 MP4,输出为同分辨率标注 MP4。先记录每个阶段处理了多少帧、花了多少时间、是否出现重试;再只改一个变量复测,避免一次同时改模型、编码器和 batch 后无法判断收益来自哪里。
1. 解码慢:先确认不是输入媒体或读取路径在拖后腿
如果VideoCapture.read()经常等待、CPU/GPU 都不忙,或同一视频在不同机器耗时差异极大,先检查输入编码、磁盘位置和实际读帧速度。不要只看文件大小:高码率、长 GOP、网络盘读取、损坏帧重试都可能让解码成为主耗时。ffprobe用于记录编码、帧率、时长和流信息;FFmpeg 的-benchmark_all可辅助观察解码/编码阶段耗时。
ffprobe-verror-select_streamsv:0\-show_entriesstream=codec_name,width,height,r_frame_rate,avg_frame_rate\-show_entriesformat=duration,size-ofjson source.mp4 ffmpeg-benchmark_all-isource.mp4-fnull -若媒体侧耗时占比高,优先比较本地磁盘与网络路径、输入编码差异、目标帧抽取策略;若业务只需每 5 帧分析一次,应在任务定义中明确采样策略,而不是悄悄跳帧后仍按全帧结果宣传速度。
2. 预处理慢:查重复转换与 CPU-GPU 数据搬运
视觉模型通常需要固定尺寸和颜色排列,例如 BGR 转 RGB、缩放、归一化、HWC 转 CHW,再把数组复制到 GPU。逐帧创建新数组、重复cvtColor、每个小步骤都在 CPU/GPU 间同步,会让模型等待数据。先在日志中把read、resize、color_convert、to_device单独计时,再检查是否有两次缩放或不必要的numpy -> tensor -> numpy往返。
frame=timer.measure("decode",capture.read)rgb=timer.measure("bgr_to_rgb",lambda:cv2.cvtColor(frame,cv2.COLOR_BGR2RGB))input_tensor=timer.measure("preprocess",lambda:to_model_tensor(rgb,size=640))input_tensor=timer.measure("host_to_device",lambda:input_tensor.to("cuda",non_blocking=True))这里的目标不是把代码压成一行,而是让每次内存搬运都有名字。若host_to_device占比异常,才进一步检查 batch、固定输入形状、页锁定内存或运行时配置;不能先假定 GPU 算力不足。
3. 推理慢:区分模型算子、输入形状与运行时开销
确认推理是真瓶颈后,再比较模型大小、输入尺寸、batch 和运行时。输入边长从 640 提升到 1280,计算量并非简单翻倍;动态输入形状也可能引入额外的运行时开销。PyTorch 可用 Profiler 查算子和显存,TensorRT 可用trtexec或逐层信息查引擎执行;但任何 FP16/INT8、TensorRT、ONNX 的速度收益都必须配合关键类别的 recall 和标注帧复检。
不要把“推理时间下降”直接写成“系统性能提高”。若量化后小目标漏检增多,或动态形状在真实输入上出现首帧长尾,整体任务并没有真正变好。
4. 后处理与编码慢:模型完成以后仍有大量工作
NMS、跟踪关联、掩膜缩放、文字绘制和将帧写回视频都发生在模型之后。特别是逐帧绘制大量中文文本、逐帧创建编码器、或把临时图片落盘再读回,常会让“模型只占 30%”的任务被误诊为推理慢。编码阶段还要区分 CPU 编码、GPU 编码、码率控制和写盘等待;优化时保持输出分辨率、帧率和媒体校验一致,不能以降低交付要求换取好看的数字。
推荐排查顺序:媒体探测 -> 分阶段计时 -> 确认最大阶段 -> 只改一个变量 -> 重新检查输出帧数、检测结果和媒体属性 -> 写入新的基线或回滚。图2:瓶颈不同,优化方向不同;模型推理不是唯一可优化对象。
三、最小测量实现、测试与 SQL
fromtimeimportperf_counterclassStageTimer:def__init__(self):self.total={}defmeasure(self,name,fn):begin=perf_counter();result=fn()self.total[name]=self.total.get(name,0.0)+(perf_counter()-begin)*1000returnresultdefbottleneck(total:dict[str,float])->str:returnmax(total,key=total.get)GPU 计时不能只包住一行model(frame)
CUDA 调用通常是异步提交的:CPU 代码继续往下走,并不代表 GPU 已完成计算。因此直接用perf_counter()包住推理调用,可能得到的只是提交开销。基准前需要预热,计时边界需要同步,并将解码、拷贝、推理、后处理分开记录:
importtimeimporttorchdeftimed_inference(model,batch):for_inrange(10):model(batch)# 预热,不计入结果torch.cuda.synchronize()started=time.perf_counter()output=model(batch)torch.cuda.synchronize()returnoutput,(time.perf_counter()-started)*1000这段示例只适用于 CUDA 可用时;CPU、ONNX Runtime 或不同推理引擎应采用各自的同步与计时方式。一次基准还应记录 P50、P95 和最大值。平均 20ms、但每 100 帧有一次 400ms 卡顿的系统,对实时预览和视频队列都是不同的问题。
用 Profiler 找到“推理慢”背后的具体阶段
阶段计时告诉我们哪一段慢,torch.profiler可以进一步观察 CPU、CUDA、显存、算子输入形状和调用栈。Profiler 本身有开销,应只对有限帧采样,不能混入正式性能数字:
withtorch.profiler.profile(activities=[torch.profiler.ProfilerActivity.CPU,torch.profiler.ProfilerActivity.CUDA],record_shapes=True,profile_memory=True,)asprofiler:for_inrange(20):model(batch)print(profiler.key_averages().table(sort_by="cuda_time_total",row_limit=12))看到resize、颜色转换或 CPU 到 GPU 的复制占比高时,换更大模型没有意义;看到 NMS 或绘制占比高时,应审查后处理;若 GPU 算子本身占主导,才进入模型规格、输入尺寸、batch、运行时和精度的选择。
deftest_bottleneck_is_largest_stage():assertbottleneck({'decode':12,'infer':50,'encode':20})=='infer'固定基准与预期输出
基准任务VP-20261005-001使用同一份 90 秒、25fps、1080P 脱敏视频,固定模型版本、输入尺寸、阈值和输出编码。每次运行输出以下摘要,而不是只有“总用时”:
{"frameCount":2250,"decodeMs":18400,"preprocessMs":6200,"inferenceMs":33200,"postprocessMs":5800,"encodeMs":19100,"outputFrames":2250}例如推理占比不足 30% 时,优先优化模型很可能没有收益;若编码耗时占比最高,则需要检查交付编码策略和存储,而不是压低检测阈值。基准输入和输出质量不变,是任何前后对比成立的前提。
媒体基准与推理基准必须分开看
FFmpeg 可以通过-benchmark或-benchmark_all输出编码、解码相关的时间信息;它适合确认媒体侧是否成为瓶颈,但不能证明模型算子是否高效。相反,TensorRT 或 ONNX 等推理运行时的基准也不应拿来代表整条 MP4 工作流。
# 只用于收集媒体侧基准信息;实际编码参数按交付要求确定。ffmpeg-benchmark-isource.mp4-c:vlibx264-c:aaac output.mp4# 运行时基准要固定输入形状、batch、精度和设备,不能与端到端时长混写。trtexec--onnx=model.onnx--shapes=images:1x3x640x640--useCudaGraph运行时选择也不是“TensorRT 一定更快”。TensorRT 可使用 FP16、INT8 等精度和图优化来改善 NVIDIA GPU 推理,但需要对量化或精度切换后的关键类别结果重新验证;CPU 部署则可能更适合 ONNX 或 OpenVINO 等路径。先建立统一输出质量的基线,再比较运行时,才能避免用速度掩盖感知退化。
CREATETABLEvisual_performance_profile(idBIGINTPRIMARYKEYAUTO_INCREMENT,run_noVARCHAR(64)NOTNULL,frame_countINTNOTNULL,decode_msDECIMAL(12,3)NOTNULL,preprocess_msDECIMAL(12,3)NOTNULL,inference_msDECIMAL(12,3)NOTNULL,postprocess_msDECIMAL(12,3)NOTNULL,encode_msDECIMAL(12,3)NOTNULL,UNIQUEKEYuk_visual_profile_run_no(run_no));SELECTrun_noFROMvisual_performance_profileWHEREframe_count<=0ORdecode_ms<0ORinference_ms<0ORencode_ms<0;@Transactional(rollbackFor=Exception.class)publicPerformanceSummaryrecord(PerformanceCommandcommand,StageCostscosts){if(costs.frameCount()!=command.expectedFrames()){thrownewBizException("性能结果帧数不完整");}profileRepository.save(command.runNo(),costs);returnPerformanceSummary.of(costs,costs.largestStage());}自动测试除最大阶段识别外,还应覆盖帧数减少、阶段耗时为负、输出编码失败和运行时版本改变四种情况。任何一项发生时,不能把结果和上一轮基准放进同一张性能趋势图。
图3:相同输入、输出质量与阶段数据是性能比较的基础。
四、验收边界
优化后仍应核对输入帧数、输出帧数、模型/阈值、最终媒体流和视觉抽检。报告需要区分平均值与长尾耗时,避免偶发解码或写盘阻塞被均值掩盖。速度提升不能以少处理帧、丢失证据或损坏成片为代价。
性能通过不等于结果通过
一次优化可以改变运行时、batch、精度、编解码器或并行策略,但不能悄悄改变任务的业务含义。验收时至少把“速度是否提升”和“结果是否等价”拆成两张检查表:前者关注总耗时、P50、P95、显存和失败率;后者关注处理帧范围、关键类别结果、事件数量、掩膜/标注质量和最终媒体属性。
| 验收层 | 优化后必须保持或重新证明的事实 | 常见伪优化 |
|---|---|---|
| 输入范围 | 源文件摘要、起止时间、采样策略、实际读取帧数 | 悄悄少读尾部帧或提高抽帧间隔 |
| 感知结果 | 关键类别 recall、置信度阈值、检测/跟踪/掩膜抽检 | 降低输入尺寸或量化后漏检增加 |
| 事件结果 | 事件数、唯一性、时间窗口与证据引用 | 后处理跳过导致重复事件或少记事件 |
| 视频交付 | 宽高、fps、时长、视频/音频流、可播放性 | 降分辨率、去音频或输出损坏文件 |
| 稳定性 | 连续多次运行的 P95、失败率、显存峰值 | 只挑一次最快运行作为结论 |
例如,若为提升吞吐把输入从逐帧改为每 5 帧分析,必须将其登记为采样策略变更,重新评估快速移动目标的 recall 和事件时间偏差;它不是同等条件下的性能优化。若从 FP32 改为 INT8,也必须在关键类别、困难样本和输出视频上重新验收,不能只看引擎的毫秒数。
三道验收门:自动完整性、视觉抽检、媒体探测
第一道门是自动完整性:输入和输出帧数、任务状态、阶段记录、异常码是否齐全。第二道门是视觉抽检:在固定时间点对照优化前后的标注画面,检查检测框、轨迹编号、掩膜边缘或事件标记是否改变。第三道门是媒体探测:用ffprobe核对正式文件存在预期视频流、时长和分辨率;必要时再抽样播放,确认浏览器或目标播放器没有黑屏、无声或时间轴异常。
defperformance_result_acceptable(before,after):same_frames=before["output_frames"]==after["output_frames"]recall_ok=after["key_recall"]>=before["min_key_recall"]latency_ok=after["p95_ms"]<=before["p95_ms"]media_ok=after["video_stream"]andafter["duration_delta_ms"]<=100returnsame_framesandrecall_okandlatency_okandmedia_okdeftest_faster_run_with_missing_frames_is_rejected():before={"output_frames":2250,"min_key_recall":0.90,"p95_ms":80}after={"output_frames":2200,"key_recall":0.93,"p95_ms":40,"video_stream":True,"duration_delta_ms":0}assertnotperformance_result_acceptable(before,after)什么时候可以更新性能基线
只有当三道门都通过,且连续多次运行没有出现异常长尾、显存泄漏或媒体失败,才应将该版本写为新基线。基线记录应同时包含源文件摘要、模型和运行时版本、设备信息、输出配置、均值/P95、关键质量指标和验收日期。任何一项改变都应创建新行而不是覆盖旧记录,这样性能回退时才能知道是模型、驱动、输入还是交付策略发生了变化。
图4:提速后仍要证明输出与优化前具有相同的处理范围和交付质量。
五、性能结论怎样才能比较
一份性能报告至少要说明“比较了什么”和“没有比较什么”。例如,把 FP32 的小模型、INT8 的大模型、不同输入尺寸和不同编码器混在一张 FPS 表里,数字即使真实,也不能帮助读者或团队作出选择。性能优化前后必须冻结输入视频、帧数、模型版本、阈值、运行时、设备、输出分辨率和编码要求。
| 比较项 | 必须保持一致 | 为什么 |
|---|---|---|
| 输入 | 同一视频、同一帧范围、同一解码策略 | 避免素材复杂度改变造成假提升 |
| 感知结果 | 同一模型/类别/阈值,或明确记录改变 | 防止靠放宽阈值减少后处理工作 |
| 交付要求 | 同一输出分辨率、帧率、编码和媒体校验 | 防止靠降低成片质量换速度 |
| 运行环境 | 设备、驱动、运行时、批次策略 | 让其他人能够复现结论 |
| 指标 | 总时长、各阶段均值与 P95 | 平均值不能掩盖长尾卡顿 |
当确实需要改变模型、输入尺寸或量化精度时,应把它记录为一次方案切换,而不是把它混成“同一方案优化”。此时除了比较速度,还要重新比较关键类别的 recall、标注帧抽检和最终输出帧数。只有速度和视觉质量同时满足目标,才允许写入新的性能基线。
defcomparable(run_a,run_b):keys=("source_sha256","frame_count","input_size","runtime","output_profile")returnall(run_a[k]==run_b[k]forkinkeys)deftest_changed_output_profile_is_not_same_baseline():assertnotcomparable({"source_sha256":"a","frame_count":100,"input_size":640,"runtime":"trt","output_profile":"h264-1080p"},{"source_sha256":"a","frame_count":100,"input_size":640,"runtime":"trt","output_profile":"h264-720p"},)图5:性能结论必须同时说明快在哪里、慢在哪里以及输出是否仍可靠。
六、小结
性能优化不是让某个 GPU 指标变漂亮,而是缩短可交付视频任务的真实等待时间。先测量,再定位最大阶段;优化后保持可比条件,并用同一输出质量重新验收,才能得到可复用的性能结论。
参考资料:PyTorch Profiler、PyTorch Profiler Tutorial、NVIDIA TensorRT Performance Benchmarking、NVIDIA TensorRT Best Practices、FFmpeg Benchmark Options。