
凌晨一点四十二分产线PLC那边弹出节拍超时报警我打开工控机上的上位机日志一眼看到那行刺眼的字YOLO推理耗时 20.3ms。这条产线的视觉检测环节从相机触发到PLC拿到OK/NG结果总共只给了120ms窗口之前模型小推理一直稳定在13ms左右没出过事。结果这周换了新模型负载一上来直接报警。如果你也在用C#写上位机、用YOLO做目标检测大概率遇到过类似情况模型明明不大推理却慢得离谱产线一动就是连续报警。这篇就记录我怎么把这20ms一点一点压到5ms的一共五个优化方向每个都有代码和实测数据不搞玄学。1. 先定位瓶颈20ms里模型推理到底占多少1.1 报警现场和第一反应很多人拿到“推理慢”的第一反应是换显卡、换模型、加内存其实都错了。我当时的做法是先冷静下来把报警条件看清楚产线要求120ms内完成一次“取图 → 推理 → 结果上送”报警阈值设在100ms超了就报警。日志里显示的20ms是“从ImageReady到拿到boxes”的整段耗时而不是单纯模型推理时间。也就是说这20ms是预处理、Session.Run、后处理、还有线程调度干扰全部加在一起的结果。如果不分段根本不知道钱花在哪。我当时也差点直接下单买新卡后来用计时器一拆发现CPU推理占了13.6ms预处理4.1ms后处理2.3ms——真正的大头是模型推理但后处理和预处理也不是可以忽视的零头。1.2 用Stopwatch把链路拆开分段计时最忌讳用DateTime.Now精度不够还容易受系统时间调整影响。我直接用Stopwatch.GetTimestamp()这个走的是高精度计数器实测稳定。核心代码长这样long t0 Stopwatch.GetTimestamp(); using var blob Cv2.Dnn.BlobFromImage(frame, 1.0 / 255.0, new Size(640, 640), new Scalar(0, 0, 0), swapRB: true, crop: false); long t1 Stopwatch.GetTimestamp(); using var results session.Run(inputs); long t2 Stopwatch.GetTimestamp(); var boxes PostProcess(results); long t3 Stopwatch.GetTimestamp(); double Ms(long ts) (ts - t0) * 1000.0 / Stopwatch.Frequency; Log($[{Ms(t1):F1}ms] 预处理 | [{Ms(t2):F1}ms] Session.Run | [{Ms(t3):F1}ms] 后处理);我在这个项目里加了启动参数可以随时切换“静默模式”和“详细模式”详细模式会把每一帧的分解耗时写进循环日志。注意频繁写日志本身也会有IO开销我的做法是每50帧输出一行摘要不是每帧都写。实测数据如下阶段优化前耗时说明Resize 归一化 HWC转CHW4.1ms手写双层循环逐像素处理Session.RunCPUFP3213.6ms默认CPU执行提供程序后处理置信度过滤 NMS2.3msLINQ List临时对象合计20.0ms未计线程调度干扰结论很明确要先把13.6ms的模型推理干掉再去管其他。先把大头压下来预处理后处理的优化才有意义。2. 第一招换掉默认CPU执行提供程序让GPU干卷积的活2.1 为什么C#里的YOLO跑得慢执行提供程序选错了ONNX Runtime把底层计算后端叫“执行提供程序”英文是Execution Provider简称EP。默认情况下如果没有额外配置ONNX Runtime会走CPU EP。CPU EP是个通用实现卷积被拆成普通矩阵乘完全没有利用GPU的并行能力。而YOLO这类目标检测模型卷积操作占了整体计算量95%以上。卷积的本质是大量独立的小规模乘加运算天然适合GPU并行。我当时翻了一下工控机的配置发现里面其实插着一张支持FP16加速的老显卡只是之前图省事从没挂过CUDA EP等于一直让CPU在硬扛。这里补充一个备选思路如果工控机没有独立显卡可以试OpenVINO EP它在核显和部分CPU上有向量指令优化很多时候能把20ms压到810ms。但想稳定到5ms这个量级光靠CPU向量指令比较难还是得有真GPU。2.2 安装Gpu包并挂载CUDA Provider第一步是换NuGet包。原来用的是Microsoft.ML.OnnxRuntime要换成Microsoft.ML.OnnxRuntime.Gpu然后代码里显式追加CUDA执行提供程序using Microsoft.ML.OnnxRuntime; var options new SessionOptions(); options.AppendExecutionProvider_CUDA(0); options.AppendExecutionProvider_CPU(0); // CUDA不可用时回退 options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; using var session new InferenceSession(D:\models\yolov8s.onnx, options);这里有一个特别容易踩的坑ONNX Runtime的Gpu包版本必须和工控机上安装的CUDA、cuDNN版本匹配。版本对不上时它不一定报错而是静默回退到CPU你测了半天发现速度没变化还不知道为什么。我第一次挂CUDA时就是这种状态日志里没有任何报错推理耗时纹丝不动。后来我打开详细日志验证执行提供程序到底有没有生效。方法是在main函数开头设置Environment.SetEnvironmentVariable(ORT_LOGGING_LEVEL, VERBOSE);跑起来后看输出如果日志里出现CUDAExecutionProvider字样说明CUDA EP真的挂上了如果只有CPUExecutionProvider说明回退了赶紧去查版本匹配。换完EP之后Session.Run从13.6ms降到了7.4ms几乎减半。这一步没有改任何模型参数只是让代码跑在正确的硬件上。3. 第二招FP32模型改FP16让Tensor Core替你算卷积3.1 FP16为什么能快这么多第一招换EP之后模型本身还是FP32精度。想再往下压就要把模型从FP32改成FP16。原因有两个层面第一FP16占用显存带宽只有FP32的一半。推理瓶颈很多时候不在算力而在显存带宽模型参数和中间激活值都要从显存读进计算单元数字小一半读取时间就省一半。第二支持FP16加速的显卡上有专门的半精度计算单元执行矩阵乘法的吞吐量通常是FP32的数倍。卷积在底层就是矩阵乘法所以收益直接反映在Session.Run耗时上。对工业检测场景来说目标检测任务通常对精度余量比较宽容。YOLO输出的框和置信度在小数点后几位变一变只要阈值设置合理漏检和误检基本不受影响。但不是说可以无脑转有几个层要特殊处理。3.2 转换模型并保持输入输出为FP32我用的是onnxconverter-common这个工具转换脚本如下import onnx from onnxconverter_common import float16 model onnx.load(yolov8s.onnx) model_fp16 float16.convert_float_to_float16( model, keep_io_typesTrue, op_block_list[NonMaxSuppression, Resize] ) onnx.save(model_fp16, yolov8s_fp16.onnx)keep_io_typesTrue的意思是模型输入输出仍然保持FP32这样C#调用侧代码完全不用改。op_block_list里排除了NonMaxSuppression和Resize这两个算子强制它们保持FP32计算。原因是NMS涉及比较和排序数值敏感Resize在很多实现里对精度也有要求。让这些算子留在FP32是精度和速度之间比较稳的平衡点。转换完成以后C#里还是用InferenceSession加载新模型SessionOptions不用改。ONNX Runtime加载FP16模型时会自动处理。这里我必须强调回归验证。转完FP16不能只看速度要拿同一批验证图跑一遍对比检测结果。我当时用100张覆盖不同工况的图做了对比指标FP32FP16平均置信度0.910.91漏检数00误检数00平均推理耗时7.4ms4.6ms4.6ms已经进5ms了。但这一招有个前提你的显卡FP16算力不能太差。如果换了FP16模型后速度没什么变化说明这张卡的半精度计算能力有限那这招可以跳过继续用FP32后面几招照样有意义。4. 第三招预处理一步到位别在C#里手写像素循环4.1 预处理慢在哪最容易忽略的4.1ms很多人的预处理是这么写的先用Resize把图缩到640x640然后两层for循环逐像素把BGR拆成三个通道再除以255归一化最后填进一个1x3x640x640的float数组。这个写法在Python里慢在C#里也慢。640x640x3一共1228800个元素C#每个数组访问都带边界检查编译器还没法自动向量化这种复杂循环。实测4.1ms就是这么来的比整套后处理还慢一倍。正确做法是用OpenCvSharp.Dnn自带的BlobFromImage方法一行搞定Resize、BGR转RGB、归一化、以及HWC到CHW的布局转换。这个方法底层是C实现有SIMD优化比自己写循环快得多using OpenCvSharp; using OpenCvSharp.Dnn; using var blob Cv2.Dnn.BlobFromImage( frame, scalefactor: 1.0 / 255.0, size: new Size(640, 640), mean: new Scalar(0, 0, 0), swapRB: true, crop: false ); blob.GetArray(out float[] inputData);参数含义解释一下OpenCV读进来的图像通道顺序是BGR而YOLO训练时用的是RGB所以swapRB必须设为true。归一化用scalefactor1.0/255.0实现mean设0是平均值归零对应常见YOLO预处理。cropfalse表示不裁剪直接缩放适合目标检测。这里我多说一句关于letterbox的细节。如果你的YOLO模型训练时用了letterbox——就是保持长宽比、周围补灰边的那种resize——那么推理时也应该做同样的letterbox直接拉伸会变形导致小目标精度下降。BlobFromImage本身不做自动补边需要先用CopyMakeBorder把图补成正方形再交给BlobFromImage。很多人在精度回归时发现FP16模型小目标漏检排查半天其实不是FP16的问题是这里的letterbox没对齐训练逻辑。换掉手写循环之后预处理从4.1ms降到了0.9ms左右。4.2 让预处理跑在取图线程推理线程只负责算光有BlobFromImage还不够还要让预处理和推理在时间上重叠。我用了一个单容量的BlockingCollection把取图回调和推理线程解耦private readonly BlockingCollectionfloat[] _queue new(1); void OnImageGrabbed(Mat frame) { using var blob Cv2.Dnn.BlobFromImage(frame, 1.0/255.0, new Size(640, 640), new Scalar(0, 0, 0), swapRB: true, crop: false); blob.GetArray(out float[] data); if (!_queue.TryAdd(data)) { // 上一帧推理还没结束丢弃当前帧保证处理的是最新帧 } } void InferenceLoop() { while (!_cts.IsCancellationRequested) { var data _queue.Take(); using var tensor new DenseTensorfloat(data, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }; using var results session.Run(inputs); // 后处理... } }队列容量设成1是有意的。如果排队太多图像会越积越旧检测的“实时性”反而变差。容量为1加TryAdd相当于一个背压机制处理不过来就直接丢旧帧保证模型每次处理的都是最新一帧。对产线视觉来说宁可直接报警或丢一帧也不能让延迟无限累积。这一步完成之后预处理的时间基本被藏进了推理时间里从外部看端到端耗时又少了一截。5. 第四招后处理和结果上送的GC瘦身专治偶发尖峰5.1 为什么偶发卡顿比平均慢更可怕产线报警日志里有一个现象平均耗时看起来20ms偶尔会突然跳到35ms甚至40ms然后马上又恢复正常。这种“尖峰”不是模型变慢了大概率是垃圾回收在作怪。YOLOv8的输出有8400个候选框640x640输入的典型值。如果你用List 存结果每帧new出来8400个对象再用LINQ按置信度排序每帧至少产生几万次对象分配。等GC触发时所有线程暂停几十毫秒你的推理线程也跑不掉结果就是产线偶发报警。工业上位机最怕的不是平均慢而是最差一次慢。平均20ms没事但GC尖峰一次就超了100ms的报警阈值。5.2 用struct数组替代List和LINQ把分配降到零我把Detection从class改成了struct并且用预分配数组替代了Listpublic struct Detection { public float X, Y, W, H, Score; public int ClassId; } private Detection[] _pool new Detection[64]; private int _validCount;每帧后处理时直接复用_pool数组只记录有效数量不创建任何新对象。置信度过滤和NMS都用手写循环避免LINQ的lambda和枚举器分配。NMS里也尽量不要在循环里new临时列表。我的做法是先过滤置信度把合格框压缩进_pool数组前部再对有效区域做归并式NMS整个过程没有一次堆分配。还要管住结果上送。原来上送PLC时我是这么写的BitConverter.GetBytes(count); // 每帧分配 byte[] $ {x:F1},{y:F1} // 字符串拼接产生更多垃圾后来改成预分配byte[]缓冲区用BinaryPrimitives直接写二进制private readonly byte[] _plcBuffer new byte[32]; private readonly object _plcLock new object(); lock (_plcLock) { BinaryPrimitives.WriteInt32LittleEndian(_plcBuffer.AsSpan(0, 4), _validCount); for (int i 0; i _validCount; i) { BinaryPrimitives.WriteSingleLittleEndian(_plcBuffer.AsSpan(8 i * 4, 4), _pool[i].Score); // 其他坐标类似... } // 通知通信线程发送 }通信线程只读这个预分配的缓冲区不参与分配。这一步之后后处理从2.3ms降到0.8ms更关键的是GC尖峰从偶尔12ms降到了基本可以忽略的程度。6. 第五招线程优先级、CPU亲和性、ServerGC把抖动压平6.1 给推理线程真正独占一颗核前四招解决的是“算法耗时”第五招解决的是“调度干扰”。上位机里通常有好几个线程同时跑UI线程、相机回调线程、PLC通信线程、日志线程。如果不做任何干预推理线程可能被调度到不同核心上还可能和UI线程挤同一个核。我的做法是开一个专用推理线程只负责从队列取数据、跑Session.Run、做后处理。线程启动后设置最高优先级并绑定到指定物理核心[DllImport(kernel32.dll)] private static extern uint GetCurrentThreadId(); void StartInferenceThread() { var thread new Thread(InferenceLoop) { IsBackground true, Priority ThreadPriority.Highest }; thread.Start(); } void PinToCore(int coreIndex) { uint tid GetCurrentThreadId(); using var process Process.GetCurrentProcess(); var processThread process.Threads.CastProcessThread() .FirstOrDefault(t t.Id (int)tid); if (processThread ! null) { processThread.ProcessorAffinity (IntPtr)(1 coreIndex); processThread.IdealProcessor coreIndex; } }PinToCore要在推理线程内部的第一行调用因为GetCurrentThreadId拿的是当前线程ID在外面调用是绑UI线程。我当时第一次就绑错了线程盯着CPU占用看了半天发现没生效。绑定核心有个前提得提前确认工控机CPU的物理核布局。如果开了超线程逻辑核和物理核不是一一对应绑错了可能绑到同一个物理核的两个逻辑核上性能反而下降。工控机BIOS里一般能关超线程开了也建议用GetLogicalProcessorInformation查一下布局再绑定。还有一点ThreadPriority.Highest不是万能的。如果进程里多个线程都设了Highest调度器照样会抢。所以我的原则是整个上位机里只有一个线程设Highest其他线程保持Normal谁重要谁独占。6.2 打开ServerGC调整runtimeconfigC#上位机默认用的是工作站GC它的特点是单线程回收、暂停时间长适合交互式桌面程序。但工业视觉程序本质上是服务器负载持续不断地处理图像、持续分配内存。这种情况应该开ServerGC。在runtimeconfig.json里加配置{ configProperties: { System.GC.Server: true, System.GC.Concurrent: true, System.GC.HeapCount: 2, System.GC.NoAffinitize: true } }ServerGC会为每个CPU核心建立独立的托管堆垃圾回收时多个线程并行回收总暂停时间比工作站GC短得多。代价是内存占用稍高但工控机内存通常不是瓶颈。HeapCount设为2是因为我的推理线程只绑定了一个物理核GC线程太多反而抢推理线程的调度。NoAffinitizetrue是告诉GC不要自己绑定CPU核心把亲和性控制权留给我们。如果这两个配置不设ServerGC默认会绑定所有核等于自己管理亲和性可能跟我们的手动绑定打架。设置完这些之后观察日志里的P99耗时从32ms降到了6.1msGC尖峰几乎消失。7. 合在一起看数据以及一个必须做的回归测试7.1 优化前后整体对比五招全部做完最终数据是这样的阶段优化前耗时优化后耗时预处理4.1ms0.9msSession.Run13.6ms4.6ms后处理2.3ms0.8ms端到端平均20.0ms5.2msP99最差1%32.0ms6.1msGC尖峰12ms2ms实际产线上从相机触发到PLC收到结果的整体时间从原来逼近报警线变成了45ms左右节拍余量多了将近80ms。报警从此再没出现过。7.2 回归测试优化不是改完收工优化过程中最容易被忽略的是精度回归。我给自己定了个规矩每一步优化只改一个变量改完就跑同一批500张测试图记录漏检、误检和平均置信度。这样一旦发现问题可以立刻定位是哪一步引入的。FP16转换后务必检查小目标检测。如果发现原本能检到的小目标漏检了先去看两件事一是letterbox处理是否和训练一致二是op_block_list里有没有把Resize和NonMaxSuppression排进去。比例控制类的层如果数值溢出常见表现是置信度普遍偏低或某些框坐标异常可以通过对比FP32的日志输出快速定位。还有一个个人心得分段计时最好默认内建在上位机启动逻辑里不要等出了报警再加。这次排查之所以顺利是因为日志里早就留了预处理、Session.Run、后处理三段计时数据一秒拉出来。下次再有人跟我说“感觉挺快”我直接让他看P99优化方向自然就清楚了。