做了大半年Unity端的AI推理落地YOLOv8接Sentis这步其实踩过去就顺了真正卡了我好几天的是把模型输出变成带坐标的检测框这件事。模型跑起来了屏幕上也出了诊断图但就是不出框查来查去问题出在NMS。很多人以为把模型导出成ONNX再塞给Sentis就完事了实际根本不是这样。本文就围绕“在Unity中给Sentis部署的YOLOv8模型集成NMS层”这件事把从模型导出、后处理编写到移动端性能调优的完整链路讲清楚适合已经能在Unity里跑通Sentis推理、想继续做出可用检测效果的开发者或者正在做移动端AR、巡检、智能零售项目的朋友参考。先说个反直觉的结论YOLOv8在官方导出ONNX时并不默认帮你把NMS打包进去。你拿到的输出是8400个锚点的原始预测张量哪个框是目标、哪个框是背景、哪些框重叠了要合并全靠自己在工程侧处理。Sentis虽然是一个功能很完整的推理引擎但官方对NMS这类后处理算子的支持一直很保守尤其在移动端后端上部分算子会直接静默回退甚至报错。与其在模型层赌算子兼容性不如自己在Unity里写一套轻量NMS。这样做的好处很明显逻辑完全可控、便于调参与优化而且在紧急情况下可以直接堆JobSystem或者多线程来加速。1. 为什么跑通推理之后第一件事不是调UI而是做NMS1.1 YOLOv8的输出到底是什么结构先把模型输出字节级别的格式捋清楚不然写后处理的时候你大概率会写错索引。YOLOv8的分类检测模型在默认导出时ONNX的输出张量形状是(1, 84, 8400)也有部分版本或者自定义训练导出来是(1, 8400, 84)这和ultralytics导出脚本的参数有关。84的构成是4个边界框坐标 80个类别得分。边界框坐标在YOLOv8里不是中心点加宽高的格式而是直接输出的cx, cy, w, h并且是相对于输入尺寸的归一化值或者基于特征图网格的坐标类别得分则是每个类别未经过Sigmoid激活的原始logits。8400这个数也值得说一句。它来自三个不同尺度特征图的锚点总数。以640x640输入为例YOLOv8会在下采样8倍、16倍、32倍的特征图上做检测三者的网格数加在一起正好是80x80 40x40 20x20 8400。在写解码代码前我还建议你用Netron把导出的模型打开看一眼最后的输出节点名和维度。不夸张地说这里有个非常常见的坑不同版本导出的输出张量维度顺序差很多有的版本默认把anchor维放在最后一个有的放在第二个。接错维度顺序后检测框会变得极其诡异——坐标对的、类别全乱或者框的位置完全错位但概率却是对的。// 以 (1, 84, 8400) 布局为例解码第一个anchor的类别索引 int channels 84; int anchors 8400; // 第 i 个anchor的类别 j 得分在 output[0 * channels * anchors j * anchors i]1.2 三种NMS集成思路的取舍在做NMS前有几个方案可以选实际项目里侧重点不同。第一种是把NMS算子打包进ONNX模型也就是在网上看到很多导出脚本里加的NonMaxSuppression节点。这个方式缺点很明显Sentis对非极大值抑制算子在不同后端上的兼容性不可控。曾经有朋友测过编辑器里正常的模型打包到真机就稀疏化掉了最后只能在Unity里重写后处理。第二种是用C#纯写NMS。这个方案最稳控制力最强。缺点是需要自己处理性能问题。由于YOLOv8输出是结构化数据所以C#方案在不优化的情况下5600个有效候选框在手机上跑一遍完整贪心NMS单帧会多出5到10毫秒的耗时对流畅度影响不可忽视。第三种是ComputeShader实现NMS。这个方案性能上限很高但实现复杂度也最高需要处理GPU缓冲区与Unity对象之间的数据同步而且移动端不同GPU驱动对ComputeShader的支持也存在参差调试时间会成倍增加。方案开发成本性能兼容性推荐程度ONNX算子内嵌低模型推理层耗时增加中Sentis支持不稳定不推荐C#贪心NMS低中需优化极高推荐ComputeShader高高中兼容性挑设备酌情使用从我自己的经验来看第一版不要挑战花活老老实实用C#实现。先把功能跑对后面性能不够再去优化。而且纯C#实现有一个好处在编辑器里Unity的Profiler能直接看到耗时分布哪个环节慢了非常直观。2. 在Unity中实现NMS的完整链路2.1 解码YOLOv8的输出张量从模型拿到Tensor后第一步是把它拿到CPU侧解码。在Sentis的CPU后端下worker.PeekOutput返回的是Tensorfloat你可以用.DataAsTensorfloat()直接拿Span引用。这一步在小数据量时开销是可接受的因为YOLOv8的输出就那么几十万字节真正吃性能的是后面的遍历和排序。解码分三步走把每个anchor的类别分数做Sigmoid区间压到0到1之间。找到该anchor得分最高的类别记录类别索引和得分。判断该类别得分是否大于置信度阈值比如0.25。这一步直接把8400个候选压缩到可能只有几十上百个有效框。这里有一个很多人忽略的细节YOLOv8的坐标解码不需要复杂的anchor偏移计算。它的head设计已经做了DFLDistribution Focal Loss解码ONNX导出的坐标已经是实际坐标。所以解码时只需要做简单的缩放即可不需要像YOLOv5那样加stride计算。坐标和输入尺寸相关。如果模型输入是640x640输出的坐标就是相对640的数值通常需要除以输入尺寸得到归一化值或者直接在屏幕坐标上乘以对应倍率。public struct Detection { public Rect rect; // 检测框像素坐标 public int classIndex; // 类别索引 public float score; // 置信度 } public static ListDetection DecodeTensor( Tensorfloat output, int outputWidth, int outputHeight, float confThreshold) { var detections new ListDetection(64); var data output.DataAsTensorfloat(); int channels output.shape[1]; int anchors output.shape[2]; for (int i 0; i anchors; i) { float maxScore -1; int maxClass -1; for (int c 0; c channels - 4; c) { float raw data[0, c 4, i]; float score 1.0f / (1.0f Mathf.Exp(-raw)); if (score maxScore) { maxScore score; maxClass c; } } if (maxScore confThreshold) continue; float cx data[0, 0, i]; float cy data[0, 1, i]; float w data[0, 2, i]; float h data[0, 3, i]; var rect new Rect( (cx - w * 0.5f) / outputWidth * Screen.width, (cy - h * 0.5f) / outputHeight * Screen.height, w / outputWidth * Screen.width, h / outputHeight * Screen.height ); detections.Add(new Detection { rect rect, classIndex maxClass, score maxScore }); } return detections; }2.2 手写贪心NMS的完整逻辑NMS的全称是Non-Maximum Suppression核心思想很简单对于一堆重叠度很高的同类候选框只保留置信度最高的那个。贪心NMS的标准步骤分四步按照分数组别将检测框分组。通常做法是直接遍历所有检测框用字典按classIndex分桶。每个类别内部按置信度从高到低排序。取最高分的框把它加入最终输出列表。计算它和同类剩余框的IoU删除IoU超过阈值的框例如0.45。重复步骤3和4直到当前类别没有剩余框。IoU是Intersection over Union即交并比。两个框的交集面积除以并集面积。完全重叠为1完全不相交为0。NMS的删除阈值设在0.45到0.5之间比较常用太高会导致重复框残留太低容易误删密集小目标。public static ListDetection NonMaxSuppression( ListDetection detections, float iouThreshold, int maxOutputCount 32) { var grouped new Dictionaryint, ListDetection(); foreach (var det in detections) { if (!grouped.TryGetValue(det.classIndex, out var list)) { list new ListDetection(); grouped.Add(det.classIndex, list); } list.Add(det); } var results new ListDetection(maxOutputCount); foreach (var kv in grouped) { var candidates kv.Value; candidates.Sort((a, b) b.score.CompareTo(a.score)); while (candidates.Count 0 results.Count maxOutputCount) { var best candidates[0]; results.Add(best); candidates.RemoveAt(0); for (int i candidates.Count - 1; i 0; i--) { float iou ComputeIoU(best.rect, candidates[i].rect); if (iou iouThreshold) { candidates.RemoveAt(i); } } } } return results; } private static float ComputeIoU(Rect a, Rect b) { float xMin Mathf.Max(a.xMin, b.xMin); float yMin Mathf.Max(a.yMin, b.yMin); float xMax Mathf.Min(a.xMax, b.xMax); float yMax Mathf.Min(a.yMax, b.yMax); float intersection Mathf.Max(0, xMax - xMin) * Mathf.Max(0, yMax - yMin); float union a.width * a.height b.width * b.height - intersection; return union 0 ? 0 : intersection / union; }这段代码建议不要直接复制到生产环境。注意candidates.RemoveAt(0)是ListT的头部删除操作时间复杂度O(n)在最坏情况下会造成不小开销。更优的做法是用索引循环遍历或者用StackT配合临时表实现。2.3 性能优化的两条关键路径实现完成之后我做了几次Profiler采样发现很多隐形的性能坑。这里给后来人画两个重点第一个是大量使用Mathf.Exp。8400个anchor乘80个类别理论上一帧要做67万次指数运算即使有置信度阈值裁剪你仍然会在类别分数循环里跑全量计算。一个可行的优化是在做Sigmoid之前先判断rawScore是否大于一个粗略阈值。比如rawScore 2.0时Sigmoid结果约等于0.88能通过低阈值过滤只有靠近阈值的分数才需要精确算。当然工程上更常用的做法是直接用1 / (1 exp(-x))的近似实现或者使用查表法。第二个是避免用ListDetection在每帧分配。移动端托管堆比较脆弱连着一帧分配几百字节GC在Profiler里都能看到明显的GC spike。建议用对象池或者NativeArrayDetection配合固定大小的数组来复用内存。如果不想引入ECS一个简单方案是包一层DetectionPool每次取数组用完还回去。public class DetectionPool { private Detection[] buffer new Detection[1024]; private int count 0; public void Clear() count 0; public int Count count; public void Add(in Detection det) { if (count buffer.Length) { buffer[count] det; } } public Detection[] Buffer buffer; }3. 移动端性能瓶颈实测从输入尺寸到后端选择3.1 CPU后端和GPU后端的真实表现Sentis支持两种执行后端CPU后端和GPU后端。不同移动平台上两者的表现差异大得离谱。我自己在Android设备上拿YOLOv8n做的对比骁龙8系手机上CPU后端单帧推理耗时约180到220毫秒换成GPU后端后降到60到80毫秒。iPhone上更明显CPU后端约150毫秒GPU后端只有40到50毫秒。GPU后端理论上吊打CPU但并不是无脑切换就行。让我换一台搭载中端GPU的老设备再测的时候GPU后端反而比CPU还慢。原因有两个一是部分GPU不支持某些算子Sentis会自动回退到CPU增加了数据上传下载的额外开销二是老设备GPU驱动对float16算子的执行效率糟糕出现精度回退后性能雪崩。所以移动端选后端不能拍脑袋。我的建议是主力旗舰机直接GPU后端Metal和Vulkan都能跑得很好。中低端机要在项目启动时做一次Benchmark让系统根据实测帧率自动切换CPU或GPU后端。不要轻易尝试在CPU后端跑全尺寸640x640的YOLOv8s以上模型会很酸爽。3.2 输入分辨率怎么定输入分辨率直接决定FLOPs。YOLOv8n在640x640输入下大约需要8.7 GFLOPs如果降到320x320计算量降到原来的四分之一左右这是立竿见影的优化手段。但降分辨率会直接影响小目标检测率。我做过实验在室内场景检测桌面的水杯和手机640降到416时小目标召回率还有90%以上降到320时直接跌到70%上下。如果你做的是AR测量或者安全帽识别这类对精度敏感的项目最好别低于416x416。这里还有一个容易踩的坑输入纹理的宽高比和模型训练时不一致。比如直接用手机摄像头9:16的画面拉伸到640x640目标会明显变形检测精度下降。推荐做法是等比缩放加Letterbox填充你可以用Unity的TextureScale工具先缩放到模型输入尺寸内两边填充灰色像素。注意YOLOv8训练时用的填充色是114所以Letterbox时也要用RGB(114,114,114)。作为性能调优还有一招是降低摄像头画面的实际分辨率再送模型而不是先拿到4K画面再缩到640x640。处理链路中少一次大纹理缩放能再省不少时间。3.3 移动端内存和GC压力控制实际部署在移动端最影响体验的不只是帧率还有掉帧后的卡顿感。这往往来自内存分配而不是推理本身。Sentis每帧操作的Tensor如果每次都新建再加上我们C#后处理里的List扩容你会看到Profiler里每次推理都跟着一大片GC.Alloc。我是这样处理的第一用TensorAllocator自定义分配的Tensor。Sentis允许你传入IAllocator接管Tensor内存申请内部用NativeArray复用池。这样可以避开很多不必要的托管堆分配。第二把Tensor读取和后处理合并。PeekOutput()在某些后端会做GPU到CPU的Readback这一步非常慢。如果只推理不展示中间结果尽量直接用worker.TakeOutput()让它把所有权转移给脚本省掉一次拷贝。第三不要在主线程做后处理。整段NMS逻辑可以放到子线程里跑Unity的UnityWebRequest和Sentis推理本身都有异步模式配合System.Threading.Tasks能有效避开主线程性能尖刺。4. 几个容易被忽略的Sentis配置细节4.1 用TensorAllocator压掉GC AllocSentis的Worker默认使用Sentis.TensorAllocator来管理中间Tensor。每帧推理结束后如果不复用很多Tensor会被标记释放再重新申请。移动端频繁申请GPU显存和托管内存会很有压力。自定义TensorAllocator的方法其实很简单重写AllocateTensor和RecycleTensor内部让空闲的Tensor进池子。你可以在拿到Tensor后主动Dispose让它在池子里复用于下一帧。public class CachedTensorAllocator : IAllocator { private readonly QueueTensor pool new QueueTensor(); public virtual Tensor AllocateTensor(TensorShape shape, DataType dataType, DeviceType deviceType, int alignment 0) { if (pool.Count 0 deviceType DeviceType.CPU) { var t pool.Dequeue(); if (t.shape shape) { return t; } t.Dispose(); } return new Tensor(shape, dataType, deviceType); } public virtual void RecycleTensor(Tensor tensor) { pool.Enqueue(tensor); } public virtual void Prepare(TensorShape shape, DataType dataType, DeviceType deviceType, int alignment 0) { } }不过要小心GPU Tensor不能随意放回池因为部分后端的底层会绑定纹理或者buffer。我建议在预览阶段只做CPU缓存池即可。4.2 浮点精度和后端回退问题Sentis在GPU后端默认尽可能跑在float16模式。YOLOv8的检测输出是float32在GPU后端运行时推理中间结果可能被压到半精度导致边缘设备上部分阈值附近的置信度出现微小偏差。如果发现同一个模型在模拟器和真机上的检测结果差了1到2个框大概率就是浮点精度问题。你可以把Worker的executionDeviceType指定为CPU后端来验证。如果CPU后端检测一切正常、GPU后端偶发漏检再考虑在Model里通过SentisInternal把敏感层强制转为float32执行。但这个方法也有一些代价性能会下降。比较稳妥的做法是接受这个微小波动把置信度阈值适当放宽从0.25降到0.2。这样一般能弥补精度损失带来的漏检同时性能开销基本不变。4.3 WebGL与微信小游戏端的特殊情况很多人会拿Unity做WebGL和微信小游戏版本这套流程在那边有些不同的表现。WebGL平台要注意的是内存和文件读取本身有严格的限制。你在PC上正常工作的File.ReadAllBytes流程到了WebGL就可能遇到写入IndexedDB失败的问题。这种问题通常不是代码逻辑错误而是浏览器存储配额或运行时一次性内存申请过大导致。官方推荐的做法是使用IndexedDB存储模型权重先分块读取再拼接避免把几十MB的模型一次性加载到内存。WebGL还有个性能细节GPU后端的ComputeShader能力在WebGL 2.0下支持很有限很多算子会回退到CPU。因此在WebGL端我建议直接用CPU后端并把输入分辨率适当降到416以下否则整个加载和推理时间会悬殊到怀疑人生。微信小游戏的话还要额外注意Unity版本和微信SDK的适配特别是对模块裁剪后是否还有足够的特性支持。整体来说移动端的优化手法在WebGL端几乎全部适用唯一要额外留神的是IO和内存而不是CPU计算。5. 实测性能数据与项目落地建议5.1 一组有参考价值的帧率数据下面这组数据来自我自己项目里的STA测试场景不是严谨的Benchmark但作为移动端优化的参考方向是够用的。测试机型是小米13和iPhone 14模型是YOLOv8n后处理包含置信度过滤加NMS输出到屏幕显示Detection结果。配置输入分辨率后端iPhone 14耗时小米13耗时原始全流程640x640GPU72ms98ms分辨率降为416416x416GPU38ms52ms分辨率416GC优化416x416GPU33ms45ms分辨率416GC优化后台异步416x416GPU31ms41ms极限性能尝试320x320GPU22ms28ms极限性能尝试320x320CPU68ms110ms需要说明的是后处理在真机上的耗时分布里NMS往往只占2到5毫秒主要时间还是在模型推理上。所以别指望把NMS压到极致帧率就飞起大头还是模型本身。但把后处理从主线程挪走后帧间隔的波动会小很多体感流畅度明显提升。5.2 我最终采用的落地配置经过多轮测试我在项目里最终确定了一套相对通用的配置读者可以按这个作为起点模型输入分辨率416x416兼顾速度和小目标召回率。推理后端运行时检测设备特性支持Metal和Vulkan时用GPU否则CPU。置信度阈值0.25这个值对大多数场景友好。NMS的IoU阈值0.45实测下在密集小目标场景不容易误删。后处理线程放到子线程主线程只接收结果。推理层我用的是Sentis自带的UnityEngine.Sentis.ModelLoader.Load加Worker后处理则放进了一个独立的C#类Yolo8Decoder对外只暴露Process(Texture, CameraParams)。这样在调UI或者调试的时候不需要动推理代码也不会被误改。5.3 还能继续深挖的方向真正的移动端部署还有三个方向值得再往下深处挖一挖。一个是模型自身轻量化。YOLOv8n只是开胃菜更极端的做法是蒸馏或者剪枝到更小的尺寸比如通过torch.prune剪掉部分通道或者用TensorRT的INT8量化。Sentis官方也支持读取权重时自动做半精度转换但INT8量化目前需要外部工具配合。再一个是输入预处理融合到ComputeShader。目前常见的做法是用ImageConversion.LoadImage把摄像头纹理转成Color32数组再塞给CPU内存带宽根本跑不满。如果写一个Blit或ComputeShader把YCbCr转成RGB的同时直接缩放到416x416一条GPU管线搞完省掉两三次内存拷贝。这个方向实测能省5到8毫秒。还有一个是检测结果的UI层合并。如果你每帧拿到的多个检测框要绘制在Canvas上要避免每帧创建多个Image对象。更好的做法是做一套固定数量的检测框对象池只改坐标和显隐。这也是移动端UI性能优化的老生常谈。写在最后把一个YOLOv8模型从训练仓库搬进Unity Sentis最难的地方根本不在模型加载而在于你把模型输出变成用户能看到的检测框这一整段工程链。NMS在PC上随便写写都能跑放到移动端就开始给你脸色看。排查过几次帧率波动和GC问题之后我现在在项目里会更倾向于“先跑对再跑快最后再想优化”的顺序。也希望这篇关于NMS集成和移动端性能优化的实战拆解能帮你少踩一些我趟过的坑特别是那些看起来不起眼、一上线就闹脾气的配置项。