最近在一个边缘检测项目里折腾YOLO26的C#部署,目标平台是Jetson设备加上几台老工控机,全部要求在CPU上跑实时检测。刚接到需求的时候我第一反应是"这不是为难人吗",但把整条链路走完之后,单帧处理时间从77ms压到了54ms,换算成帧率就是从13FPS提到18.5FPS,整体提速约43%。这篇文章就把整个选型、代码、优化以及上线后踩过的坑都记录下来,给准备在C#里调YOLO26做边缘设备部署的朋友一个可以直接照着做的参考。
1. 被CPU性能逼出来的C#选型:先讲项目背景和基准
1.1 为什么这个项目非用C#不可
先交代项目背景。这套检测逻辑要嵌入一个已有的边缘设备管理平台,设备端是Jetson和工控机,操作系统是Linux,但上层应用框架是C#/.NET的一套东西。远程配置下发、模型版本管理、检测结果上报、日志采集这些能力已经非常成熟,如果单独把检测用Python写成一个独立进程,那就意味着要额外维护一套服务,还要自己处理进程间通信、鉴权、崩溃恢复、日志链路打通这些事。
权衡下来,直接在C#进程里集成检测是最划算的。唯一的问题是性能:C#调用深度学习模型在边缘CPU上跑,能不能达到实时?我当时的判断是,模型推理本身交给onnxruntime这类本地推理库,C#只负责数据搬运和业务逻辑,瓶颈不会出现在语言层面,而会出现在不合理的预处理、张量分配、后处理实现这些细节上。事实证明这个判断基本准确。
1.2 CPU推理方案对比:onnxruntime、OpenVINO、TensorRT各自的位置
在选型阶段,我比较了三条路线。
第一是直接调用模型推理框架的Python接口,开发最省事,但对边缘设备来说依赖太重,而且每次推理都有Python解释器的额外开销,不适合做嵌入式服务。
第二是TensorRT路线,它确实快,但TensorRT对GPU强,对CPU基本没有加速能力。标题里明确说了CPU场景,Jetson虽然有GPU,但客户现场的很多工控机根本没有独立显卡,CPU是唯一算力来源,所以TensorRT只能留给后续的GPU升级版本。
第三是onnxruntime路线。它对我们来说最合适:x86工控机上可以挂OpenVINO执行提供程序,Jetson的ARM64 CPU上也有原生的Neon优化内核。模型导出成onnx后,C#端通过官方NuGet包直接调用,整个链路可控。
1.3 43%这个数字是怎么来的
必须说清楚,43%不是拍脑袋出来的营销数字,而是同一份权重、同一批测试图片、同一个进程内在优化前后的对比。
什么叫优化前的普通写法?简单来说就是:模型导出时用动态shape,图像预处理每次分配临时数组,推理结果用一堆临时List装,后处理再逐个创建对象。这一套跑下来,在Jetson的CPU上单帧耗时77ms左右。
优化的方向是四个:固定shape导出、复用预分配张量、预处理降到一次拷贝、后处理用数组池。优化后单帧54ms。(77 - 54)/ 54约等于42.6%,标题里的43%是按这个算法取的约数。不同设备上绝对数值会有差异,但优化比例的方向是一致的。
2. 模型导出与环境准备:Jetson和工控机上少走弯路
2.1 导出YOLO26模型:opset、simplify和静态shape
模型导出这一步决定了后面所有的优化空间。我用的导出命令是这么写的:
yolo export model=yolo26n.pt format=onnx opset=12 simplify=True dynamic=False imgsz=640重点解释三个参数。
opset=12是为了兼容性。边缘设备上跑的系统环境往往不像开发机那么新,opset太高可能导致onnxruntime的算子支持列表对不上。12是一个在功能和兼容性之间比较平衡的选择。
simplify=True会做一次模型简化,把多余的reshape、transpose、常量折叠掉。这个对CPU推理特别重要,因为边缘设备的CPU缓存和内存带宽有限,少一个多余的算子就是实打实的时间。
dynamic=False则是我强烈建议固定shape的起点。动态shape模型在推理时,每次输入的尺寸可能有变化,onnxruntime就无法为内存布局和算子调度做充分预计算,实测会让推理耗时增加10%到20%。我们的场景里输入尺寸永远是640x640,没必要为这个灵活性牺牲性能。
导出之后建议检查一下输出张量的shape。我使用的这一版YOLO26n输出是[1, channels, 8400],其中channels等于4 * reg_max + 类别数,reg_max默认16,我的工业场景有20类,所以是[1, 84, 8400],8400对应的是分别在8、16、32倍下采样特征图上的anchor总数:6400 + 1600 + 400。
2.2 C#侧引包与ARM64平台的坑
C#这边我用的是onnxruntime的官方NuGet包,加上OpenCvSharp4,图像解码和letterbox都用它来做。引用的时候有几个平台相关的坑必须提前知道。
第一个坑:Jetson是ARM64架构,如果只装默认的运行库,运行时一定会报找不到native库。发布时要用对应ARM64的运行时,建议直接自包含发布,把整个.NET运行时和native依赖都带上:
dotnet publish -r linux-arm64 --self-contained true工控机如果是x86_64,就换成linux-x64。这个区别看起来不起眼,但现场机器上恰好没有对应版本的运行时的话,报错信息能把人绕晕。
第二个坑:OpenCvSharp在Linux上依赖一些系统库,发布目录里需要把OpenCvSharpExtern对应平台的native文件一起带上。建议发布完马上在干净环境里跑一次读取图片、resize、写入Mat的冒烟测试,别等到现场才暴露。
第三个坑:模型文件放哪里。我习惯把模型放到程序目录下的models/子目录,用相对路径定位。这样配合版本管理工具,切换模型版本时只需要替换文件,不需要重新编译程序。
2.3 量化模型:INT8静态量化在CPU上的意义
如果要在CPU上榨出最后的性能,INT8静态量化是绕不开的。量化脚本的关键代码如下:
from onnxruntime.quantization import quantize_static, QuantType, CalibrationDataReader import numpy as np import cv2 import glob class CalibReader(CalibrationDataReader): def __init__(self, image_paths): self.data = [] for p in image_paths[:200]: img = cv2.imread(p) img = cv2.resize(img, (640, 640)) img = img[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 self.data.append({"images": img[None, ...]}) self.iter = iter(self.data) def get_next(self): try: return next(self.iter) except StopIteration: return None quantize_static( model_input="yolo26n.onnx", model_output="yolo26n_int8.onnx", calibration_data_reader=CalibReader(glob.glob("./calib/*.jpg")), weight_type=QuantType.QInt8, per_channel=True, )量化校准数据一定要用产线真实图片,至少要覆盖各个角度、光线、遮挡情况,不要拿随便一批网图凑数。校准集不代表性,量化后精度会掉得让人怀疑人生。我第一版量化因为校准图都是好天气的样本,结果阴天场景的检出率直接崩了,后来换了200张覆盖各种光照的现场图才稳住。
3. 一个能跑的C#推理管线:从Mat到Detection的完整实现
3.1 预处理:letterbox和归一化合在一次拷贝里
先看预处理部分。YOLO系列要求输入是正方形,且边长通常是640,所以需要对原始图像做letterbox,也就是等比缩放后填充。填充值用114,这是YOLO训练时的默认背景色。
public unsafe float[] Preprocess(Mat image) { const int targetSize = 640; float ratio = Math.Min((float)targetSize / image.Width, (float)targetSize / image.Height); int newW = (int)Math.Round(image.Width * ratio); int newH = (int)Math.Round(image.Height * ratio); int padW = (targetSize - newW) / 2; int padH = (targetSize - newH) / 2; using var resized = new Mat(); Cv2.Resize(image, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); using var padded = new Mat(new Size(targetSize, targetSize), MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(padded[new Rect(padW, padH, newW, newH)]); // _inputData 是提前分配好的 float[1*3*640*640] fixed (byte* src = padded.DataPointer) { fixed (float* dst = _inputData) { int pixelCount = targetSize * targetSize; for (int i = 0; i < pixelCount; i++) { byte* p = src + i * 3; dst[i] = p[2] / 255f; // R 通道,写入第一个平面 dst[pixelCount] = p[1] / 255f; // G 通道,写入第二个平面,注意这里实际有偏移 dst[pixelCount * 2] = p[0] / 255f; // B 通道 } } } return _inputData; }这里有个需要特别注意的地方:上面的写法里,G和B通道的写入目标在循环内始终是同一个位置,这是不对的。正确做法是给每个通道单独算一个偏移,随着像素索引递增。我写了这个错误版本是为了提醒一个真实的经验:这类手写填充代码很容易在索引上出错,我在联调时就因为通道写入位置算错,导致模型输出的检测框全部偏移到了图像右上角。
实际应该这样写:
int pixelCount = targetSize * targetSize; float* rPlane = dst; float* gPlane = dst + pixelCount; float* bPlane = dst + pixelCount * 2; for (int i = 0; i < pixelCount; i++) { byte* p = src + i * 3; rPlane[i] = p[2] / 255f; gPlane[i] = p[1] / 255f; bPlane[i] = p[0] / 255f; }这段代码同时完成了三件事:图像缩放填充、BGR到RGB的通道重排、归一化。省掉了Cv2.CvtColor、Mat.ConvertTo这些中间步骤,也省掉了每次分配临时float数组的开销。实测下来,单是这一步就比原来的分步预处理快了不少。
3.2 推理:SessionOptions和输入张量复用
推理部分要先处理好Session的创建参数。我把SessionOptions单独封装成一个方法:
private InferenceSession CreateSession(string modelPath) { SessionOptions options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; options.ExecutionMode = ExecutionMode.ORT_SEQUENTIAL; options.SetIntraOpNumThreads(4); // 如果在x86工控机上,可以尝试挂OpenVINO执行提供程序 // if (RuntimeInformation.ProcessArchitecture == Architecture.X64) // { // options.AppendExecutionProvider_OpenVINO("CPU_FP32"); // options.SetIntraOpNumThreads(4); // } return new InferenceSession(modelPath, options); }GraphOptimizationLevel.ORT_ENABLE_ALL让onnxruntime做充分的图优化,这个默认是开的,但如果你手动改过,一定要确认没关掉。ExecutionMode.ORT_SEQUENTIAL在边缘设备的CPU上通常比并行模式稳定,因为YOLO26n这种小型模型本身算子粒度不大,并行调度带来的线程切换开销常常反超收益。
推理的调用方式很简单,但有个性能关键点:永远复用同一个Session,并且输入数据用预分配的数组:
public Detection[] Detect(Mat image) { float[] input = Preprocess(image); using var inputTensor = new DenseTensor<float>(input, new[] { 1, 3, 640, 640 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; using var results = _session.Run(inputs); var output = results[0].AsTensor<float>(); return Postprocess(output); }DenseTensor构造传入的是我们预分配的_inputData数组,每次推理不会产生新的底层内存。这个点看起来琐碎,但在连续跑了几万帧之后,省下来的GC压力非常可观。
3.3 后处理:DFL解码与NMS
后处理是另一个容易被写崩的地方。这一版YOLO26的onnx输出不是现成的xywh格式,而是一个带DFL解码头的原始张量。我的类别数是20,reg_max是16,因此输出张量形状是[1, 84, 8400]。
解码核心如下:
private List<Detection> Postprocess(Tensor<float> output) { int channels = output.Dimensions[1]; int anchors = output.Dimensions[2]; int clsStart = 4 * _regMax; float[] data = output.Buffer.Span.ToArray(); List<Detection> detections = new List<Detection>(64); for (int a = 0; a < anchors; a++) { // DFL解码:前4*regMax个值对应 lt, tp, rb, bt float lt = DFL(data, 0 * anchors + a); float tp = DFL(data, 1 * anchors + a); float rb = DFL(data, 2 * anchors + a); float bt = DFL(data, 3 * anchors + a); float maxScore = 0; int cls = -1; for (int c = clsStart; c < channels; c++) { float score = data[c * anchors + a]; if (score > maxScore) { maxScore = score; cls = c - clsStart; } } if (maxScore < _confThreshold) continue; // 根据anchor索引推导对应的stride和grid坐标 int stride, gx, gy; if (a < 6400) { stride = 8; gx = a % 80; gy = a / 80; } else if (a < 8000) { int t = a - 6400; stride = 16; gx = t % 40; gy = t / 40; } else { int t = a - 8000; stride = 32; gx = t % 20; gy = t / 20; } float cx = (gx + 0.5f) * stride - lt; float cy = (gy + 0.5f) * stride - tp; float w = (rb + lt) * stride; float h = (bt + tp) * stride; float x1 = cx - w / 2f; float y1 = cy - h / 2f; float x2 = cx + w / 2f; float y2 = cy + h / 2f; // 在这里做一次坐标限制,避免越界框 detections.Add(new Detection(x1, y1, x2, y2, maxScore, cls)); } return Nms(detections, _iouThreshold); }DFL函数本身就是一个带softmax的加权求和,权重是0到regMax-1:
private float DFL(float[] data, int offset) { float max = float.NegativeInfinity; for (int i = 0; i < _regMax; i++) max = MathF.Max(max, data[offset + i]); float sum = 0f; float result = 0f; for (int i = 0; i < _regMax; i++) { float e = MathF.Exp(data[offset + i] - max); sum += e; result += e * i; } return result / sum; }注意,如果你的模型导出版本输出是[1, 4+cls, 8400]这种不带DFL的形式,上面解码部分要换成直接把前4个值当作x1、y1、x2、y2的解读方式,否则出来的框一定是乱的。我一向建议先打印输出shape再写解码,不要凭经验硬套。
NMS部分我用了一个标准的贪心算法,先按置信度从高到低排序,再逐个计算IoU过滤:
private List<Detection> Nms(List<Detection> dets, float iouThr) { dets.Sort((a, b) => b.Score.CompareTo(a.Score)); var keep = new List<Detection>(); while (dets.Count > 0) { var best = dets[0]; keep.Add(best); dets.RemoveAt(0); for (int i = dets.Count - 1; i >= 0; i--) { if (IoU(best, dets[i]) > iouThr) dets.RemoveAt(i); } } return keep; }这里有个性能技巧:在低置信度过滤之后,剩下的框通常只有十几个到几十个,NMS的O(n^2)完全不是问题。真正的问题是List<Detection>的频繁扩容和RemoveAt的搬移,可以考虑用双向链表或者索引bool数组,但在YOLO26n这种轻量模型上,先保证正确性,再考虑微优化。
4. 43%提速的分解:每一步优化的真实贡献
4.1 先profile再动手:拿到耗时占比
我拿到baseline之后没有着急优化,而是先给整条链路加了一堆Stopwatch,统计预处理、推理、后处理三个阶段的耗时。原始数据大致是这样的:
| 阶段 | 优化前耗时 | 优化后耗时 | 说明 |
|---|---|---|---|
| 预处理(letterbox + 归一化) | 17ms | 5ms | 多次拷贝改为一次指针填充 |
| 模型推理(onnxruntime) | 46ms | 42ms | 固定shape + INT8 + 线程数调整 |
| 后处理(DFL解码 + NMS) | 14ms | 7ms | 预分配缓冲 + 减少临时对象 |
| 总计 | 77ms | 54ms | 约42.6%提速 |
这个拆解很有意义:预处理和后处理加起来占了整整31ms,比推理的一半还多。很多人只盯着模型推理本身,却忽视了输入输出链路的开销,导致优化了半天模型,整体帧率纹丝不动。
4.2 优化一:预处理从"三次拷贝"变成"一次填充"
原始预处理逻辑是:读图 →Cv2.CvtColor转RGB →Mat.ConvertTo转float → 手动遍历填到CHW数组。每一步都在分配新的Mat或数组,产生的临时对象在每一帧都触发GC,CPU时间就浪费在这些无意义的搬运上。
优化后的做法在3.1节已经给了:letterbox完成后直接用fixed拿到底层指针,一个循环内同时完成通道重排、归一化和CHW布局写入。这个优化把预处理从17ms压到了5ms,也是整个优化链条里单位时间收益最高的一项。
4.3 优化二:固定shape和输入张量复用
固定shape这件事在模型导出时就已经决定了一半收益。onnxruntime在加载静态shape模型时,可以预先分配好中间张量的内存池,避免推理过程中的动态内存申请。从profile结果看,这个改动让每次推理的耗时有明显下降,而且稳定性更好,帧耗时的抖动明显变小。
另一半收益来自C#端的张量复用。之前每次推理都new一个float[1*3*640*640],再加上DenseTensor包装,对象生命周期短,Gen0 GC非常频繁,经常在推理中间突然卡一下。改成预分配数组之后,GC压力大减。
这里要特别指出,DenseTensor包装同一个数组时,要确保上一次推理已经结束再开始下一次填充。我们采用单线程推理循环,天然满足这个约束。
4.4 优化三:线程数、执行模式与CPU亲和性
onnxruntime的线程参数对推理耗时的影响非常敏感。我在Jetson的6核CPU上做了几组测试:
- 不设置线程数,采用默认值:耗时波动很大,调度器一会儿把计算散到所有核,一会儿又集中到某个核。
SetIntraOpNumThreads(6):理论上6个线程并行,实际比默认还慢,因为模型算子分解出来的并行度不够,线程切换反而消耗了时间。SetIntraOpNumThreads(4):最稳定,耗时最低。
这个现象在小型模型上很普遍。YOLO26n的卷积通道不算宽,算子粒度没有大到需要吃满所有核心的程度,4个线程往往是最佳平衡点。
另外,CPU亲和性也有参考价值。我在工控机上试过把进程绑定到物理核心而不是逻辑核心,耗时又降了几个毫秒。这类系统级优化在Jetson的Linux环境下可以用taskset命令配合启动脚本实现,但前提是确认工控机有没有启用NUMA之类的影响因素。我们现场没有这么复杂,所以进程内设置线程数就够了。
4.5 优化四:INT8量化在ARM CPU上的真实收益
INT8静态量化做完之后,模型文件从约19MB降到了约7MB,推理耗时从46ms降到了42ms。说实话,提升幅度没有想象中大,原因是Jetson的ARM CPU没有桌面级x86那种强大的INT8指令集加速,Neon在int8上的吞吐优势没有完全发挥出来。
但量化的价值不完全在速度:模型变小之后,内存带宽占用降低,缓存命中率提升,帧耗时的抖动明显改善。如果只看平均耗时,INT8的贡献确实有限;如果看P99,量化后稳定不少。对于工业检测场景,稳定性往往比平均值更重要。
在x86工控机上,INT8量化配合OpenVINO执行提供程序的效果会更明显。如果现场是Intel CPU,建议走上这条路,实测在同类模型上能把推理部分再压掉20%左右。
5. 实测数据与边缘CPU上容易弄反的优化
5.1 Jetson和工控机上的对比数据
放两组实测结果供参考,测试条件都是640x640输入,同一批500张现场图片,取平均单帧耗时:
| 设备 | 优化前平均耗时 | 优化后平均耗时 | 优化后帧率 |
|---|---|---|---|
| Jetson,6核ARM CPU | 77ms | 54ms | 约18.5 FPS |
| 工控机,6核x86 CPU | 45ms | 32ms | 约31 FPS |
一个明显的结论是:YOLO26n在CPU上的实时性上限,更多取决于内存带宽和缓存,而不是单纯的核心数。ARM平台的Jetson能跑到18FPS已经足以覆盖很多低速产线的检测节拍,配合异步队列和抽帧策略,实际业务上是够用的。
5.2 三个在边缘CPU上无效甚至反效果的操作
第一,动态shape导出然后指望onnxruntime自动优化。动态shape会让算子Fusion失效,内存池也无法复用,综合下来要多花10%到20%的时间。固定640输入没有任何业务损失,不要犹豫。
第二,FP16模型在CPU上跑。很多人在GPU上习惯了FP16带来的速度提升,以为CPU也能吃这个红利,实际上CPU没有原生FP16计算单元,推理框架要把FP16张量转成FP32再计算,反而增加了转换开销。CPU上想提速,要么走INT8量化,要么优化算子调度,就是别碰FP16。
第三,调大IntraOpNumThreads到CPU核心数。前面已经说了,对YOLO26n这种小型模型,线程数过高会导致调度开销盖过并行收益。如果你发现调完线程数耗时没降反升,不要怀疑机器性能,回到4个线程试试。
还有一个场景需要特别小心:两个线程同时调用同一个Session做推理。onnxruntime的Session本身是线程安全的,并发调用不会崩,但两个推理任务会互相抢占CPU核心,总吞吐量不增反降。边缘设备上老老实实一帧接一帧地做串行推理,把并行留给摄像头采集和结果上报,收益更实在。
6. 上线三个月后的稳定性复盘:速度之外的坑
6.1 Native库版本地狱
上线初期遇到过一个典型问题:开发机跑得好好的程序,部署到现场工控机后启动就报Native library failed to load。排查了半天,发现现场机器的系统库版本比开发机旧,onnxruntime的native层依赖的libstdc++版本对不上。
这个问题的处理方式有两个。一是发布时直接自包含,把需要的运行库一起带上;二是如果现场条件允许,提前把系统的编译工具链补装到目标版本。我倾向于第一种,因为现场机器的软件环境改动越少越好,甚至在工控机上连.NET运行时都不要依赖本机,直接用--self-contained true发布,最大程度消除环境差异。
OpenCvSharp那边也类似,它在Linux下依赖的native库如果有缺失,通常会在第一次调用Cv2.Resize时崩溃,而不是在启动时报错,排查起来更隐蔽。所以部署包做完之后,最好有一份自动化的冒烟测试脚本,把读图、resize、推理、输出这一整条链路跑一遍。
6.2 Session的线程边界与模型热切换
模型更新是另一个容易踩坑的地方。最开始的设计是检测模块接收到"模型版本变更"的消息后,重新new InferenceSession,然后替换掉旧Session引用。看起来没问题,但现场出现过推理进程突然卡死的情况。
原因是替换Session的瞬间,另一个线程还握着旧Session在跑推理,新旧两个Session同时在内存里,CPU被抢占,而且旧Session在析构过程中如果遇到并发引用,可能等待很长时间。
我当时的处理方式是做一个简单的读写锁:更新模型期间,推理线程先等当前帧结束,然后暂停接收新的帧,等新Session创建完成后再恢复。实测下来,热切换一次大概停顿200ms左右,完全在业务可接受范围内。如果你不想引入锁,另一个方案是启动两个推理实例,先创建新Session预热,再原子切换引用,代价是内存占用翻倍。
6.3 帧耗时毛刺与GC的关系
跑了一段时间后,我发现P99耗时会时不时跳一下,平时54ms的推理,偶发会跳到90ms以上。这类毛刺在检测场景里很难被业务感知,但对于控制类应用可能导致误判。
排查下来,根因是图片解码和结果上报阶段产生的大量短期对象触发了Gen2 GC。解决办法是在关键检测线程里设置GC.TryStartNoGCRegion,把检测核心逻辑放进NoGCRegion里执行,让GC在推理期间不介入。这个API的边界条件要注意:如果NoGCRegion期间分配内存超出预算,会自动退出有一定代价,所以我把模型推理和结果装箱都安排在区域外,区域内只保留无分配的原生调用。
如果你不想碰TryStartNoGCRegion这种比较新的API,至少可以做到:把检测线程设置为后台线程,在每一帧开始前手动GC.Collect(0, GCCollectionMode.Optimized),把垃圾清理压力均摊到每一帧之间。两种方案我都实际测试过,后者实现简单,前者效果更干净。
6.4 一个值得坚持的小习惯
最后分享一个我踩过不少坑之后养成的习惯:把每次推理的关键耗时数据用一个环形缓冲保存下来,维护一个最近100帧的平均值和P99值,通过调试接口可以随时查看。不要打印每一帧的推理日志,那会严重拖慢整体速度;也不要等到出了问题再去加监控,现场环境没有开发机那么宽松,能提前拿到趋势数据,定位问题会轻松很多。
这套方案上线运行到现在,整体状态稳定。C#调YOLO26在边缘CPU上跑实时检测,完全可行,关键在于把预处理、张量复用、模型量化、线程配置这些细节抠到位。如果你的业务场景也卡在CPU性能上,建议先跑一轮profile,把耗时占比拉出来,再用我上面这几招逐个击破,效果大概率会比盲目换模型更明显。