:基于ONNX Runtime的推理方案全解析)
簡介這是一份基于Visual Studio 2013開發(fā)的C#深度學習源碼項目采用純CPU運行方式演示深度學習的核心流程面向希望在Windows環(huán)境直接運行、不愿折騰Linux版第三方庫配置的C#開發(fā)者。與網(wǎng)上常見的Linux移植源碼不同本工程已將運行所需庫文件全部打包只要裝有VS2013即可打開編譯并運行免去多天配置環(huán)境的困擾。壓縮包共123個文件整體約6.11MB核心包括38個.cs源文件、26個dll運行庫、7個XAML及其BAML界面資源另有一些配置文件、PDB調(diào)試符號和可執(zhí)行程序層次清楚便于按源碼、界面、依賴分類查看。目前已有1526人學習下載。通過運行示例并結合源碼可以直觀理解深度學習網(wǎng)絡的層級組織、網(wǎng)絡結構圖繪制、節(jié)點狀態(tài)展示、性能曲線監(jiān)控等關鍵設計由于是純CPU實現(xiàn)還能輕松單步調(diào)試適合把底層計算邏輯和界面交互對應起來學習也是一份輕量、完整且方便改動的C#與深度學習結合入門案例。 在C#生態(tài)里聊深度學習源碼第一反應往往是“繞道走”。畢竟Python有PyTorch、TensorFlow社區(qū)資源幾乎一邊倒很多搞工控的老哥一聽CNN、Transformer就默認那必須是Python的活兒。但實際項目里我見過太多做上位機、做廠內(nèi)視覺檢測、做設備自動化的團隊軟件底座是C#寫的WinForms或WPF設備通訊走串口、網(wǎng)口、Modbus相機用??祷駼asler的SDK。這時候如果識別模型要在本地跑最順手的方案絕對不是另起一個Python服務而是在C#進程內(nèi)直接集成推理引擎。這個標題看起來很大但放到實際工程語境里“C#深度學習源碼”指的無非是兩類東西一類是C#寫的深度學習訓練或推理框架源碼另一類是在C#項目中集成深度學習模型推理的完整實現(xiàn)源碼。前者目前在工業(yè)落地中相對少見后者才是真正的剛需。這篇文章我主要圍繞后者展開把我踩過的坑、驗證過能用能上產(chǎn)線的路子按從選型到落地的順序完整梳理一遍。1. 先看清大局C#跑深度學習的幾條技術路線1.1 不自己造輪子四種主流方案的橫向對比在C#里做深度學習本質上是兩條路要么用純C#實現(xiàn)的框架要么綁定到已有的原生深度學習庫。純C#方案的典型代表是ML.NET它的設計哲學是“讓.NET開發(fā)者用熟悉的方式做機器學習”支持分類、回歸、聚類、推薦、目標檢測等常見任務也支持導出ONNX模型。但ML.NET對深度神經(jīng)網(wǎng)絡的支持深度有限像自定義Transformer架構、復雜的注意力機制、細粒度的訓練控制用起來都比較別扭。另一條路是綁定方案主流選項有三類TensorFlow.NET、TorchSharp、ONNX Runtime。TensorFlow.NET是TensorFlow的C#綁定繼承了TF的完整能力但TF本身模型部署偏重環(huán)境依賴復雜工業(yè)場景里有點殺雞用牛刀。TorchSharp是PyTorch的C#綁定能寫訓練代碼也能加載PyTorch模型靈活性高但它的API風格始終帶著“翻譯味”上手成本不低。ONNX Runtime則是微軟主推的跨平臺推理引擎定位非常純粹只做推理不負責訓練支持從PyTorch、TensorFlow、PaddlePaddle等框架導出ONNX模型然后在C#里加載執(zhí)行。我整理了這四個方案在工程維度的核心差異方便大家按場景選方案訓練/推理模型來源部署體積上手難度典型場景ML.NET訓練推理自帶API或ONNX中等低結構化數(shù)據(jù)、簡單視覺任務TensorFlow.NET訓練推理TensorFlow大依賴TF runtime高需要完全復用TF生態(tài)TorchSharp訓練推理PyTorch大需要libtorch較高需要C#側微調(diào)模型ONNX Runtime僅推理任意導出ONNX小單DLL對應運行時低工業(yè)部署、邊緣設備、產(chǎn)線視覺1.2 我為什么在工業(yè)場景里鎖定ONNX Runtime我自己做過不少產(chǎn)線視覺項目最終的落地方案幾乎都是ONNX Runtime。原因很直接第一模型訓練在Python側完成團隊可以繼續(xù)用PyTorch或YOLO生態(tài)不改變算法同事的工作習慣第二ONNX Runtime在Windows x64下的部署極度輕量NuGet包引入后直接調(diào)用原生庫沒有虛擬環(huán)境、沒有解釋器依賴第三推理性能經(jīng)過微軟持續(xù)優(yōu)化CPU和GPU都支持單張工業(yè)相機圖片的推理延遲可以壓到幾十毫秒甚至更低。還有一個容易被忽略但很關鍵的點C#項目在工控機上跑往往需要和相機SDK、運動控制卡、PLC通訊庫、數(shù)據(jù)庫等一大堆原生庫共存。ONNX Runtime的依賴極其干凈幾乎不會和這些庫打架。而TorchSharp或TensorFlow.NET對VC運行時的版本比較敏感有時候一個DLL版本沖突就夠排查半天。另外ONNX Runtime對多模型并發(fā)、多線程推理的支持也很完善這一點在產(chǎn)線多工位場景中特別重要。2. 從零搭一個能跑的C#深度學習推理項目2.1 環(huán)境準備與NuGet包選擇我用的開發(fā)環(huán)境是Visual Studio 2022 .NET 8目標平臺是x64。這里有個細節(jié)ONNX Runtime的NuGet包有CPU版和GPU版CPU版包名是Microsoft.ML.OnnxRuntimeGPU版是Microsoft.ML.OnnxRuntime.Gpu。如果只是簡單測試先裝CPU版就夠了等模型驗證通過再切GPU版因為GPU版還額外依賴CUDA和cuDNN配置不當反而會拖垮啟動時間。創(chuàng)建一個控制臺或WinForms項目后在NuGet里搜索并安裝Microsoft.ML.OnnxRuntime即可。另外如果需要對圖像做預處理建議一并引入OpenCvSharp4和OpenCvSharp4.runtime.win用OpenCV做Resize、歸一化、通道變換比手動用GDI處理圖像高效得多。安裝完成后最簡單的驗證代碼是這樣using Microsoft.ML.OnnxRuntime; var sessionOptions new SessionOptions(); // 開啟內(nèi)存優(yōu)化適合長期駐留的工控程序 sessionOptions.EnableMemoryPattern true; sessionOptions.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; using var session new InferenceSession(yolov8n.onnx, sessionOptions); Console.WriteLine($輸入節(jié)點: {string.Join(, , session.InputMetadata.Keys)}); Console.WriteLine($輸出節(jié)點: {string.Join(, , session.OutputMetadata.Keys)});如果這段代碼能正確打印模型的輸入輸出節(jié)點名說明環(huán)境已經(jīng)通了一半。2.2 模型從PyTorch轉換到ONNX的完整鏈路很多初學者卡在這一步訓練好的PyTorch模型怎么變成ONNX這里以最常用的YOLOv8為例算法同事在Python側只需要這樣做import torch from ultralytics import YOLO model YOLO(yolov8n.pt) model.model.eval() # 構造一個假的輸入張量batch_size1輸入尺寸為640x640RGB三通道 dummy_input torch.randn(1, 3, 640, 640) torch.onnx.export( model.model, dummy_input, yolov8n.onnx, opset_version17, input_names[images], output_names[output0], dynamic_axes{images: {0: batch}, output0: {0: batch}} )導出時務必確認三個參數(shù)opset_version不能太低否則有些算子不支持輸入尺寸必須和預處理尺寸一致dynamic_axes可以根據(jù)業(yè)務需求決定是否把Batch維設為動態(tài)。產(chǎn)出ONNX文件后用onnxruntime在Python側先做一個推理驗證確認輸出結果和PyTorch一致再交給C#側集成。這一步前置驗證特別重要能過濾掉大量模型轉換引入的隱形問題。2.3 在C#里加載模型并完成第一次推理拿到ONNX文件后在C#里的推理流程分成三步讀取圖像并預處理、構造輸入Tensor、執(zhí)行推理并解析輸出。下面以YOLOv8檢測模型為例演示一個無封裝的最小實現(xiàn)using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; // 1. 讀取圖像并預處理為640x640 RGB張量 using var image new Mat(test.jpg, ImreadModes.Color); var resized new Mat(); Cv2.Resize(image, resized, new Size(640, 640)); var inputData new float[1 * 3 * 640 * 640]; // 注意OpenCV默認是BGR順序需要轉換為RGB并做歸一化 for (int y 0; y 640; y) { for (int x 0; x 640; x) { var pixel resized.AtVec3b(y, x); inputData[0 * 3 * 640 * 640 0 * 640 * 640 y * 640 x] pixel[2] / 255f; inputData[0 * 3 * 640 * 640 1 * 640 * 640 y * 640 x] pixel[1] / 255f; inputData[0 * 3 * 640 * 640 2 * 640 * 640 y * 640 x] pixel[0] / 255f; } } // 2. 構造輸入Tensor var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, inputTensor) }; // 3. 執(zhí)行推理 using var session new InferenceSession(yolov8n.onnx); using var results session.Run(inputs); var output results.First().AsTensorfloat(); // 輸出形狀是 [1, 84, 8400]解析方式這里不展開后文細說這段代碼的執(zhí)行時間在CPU上通常只有幾十毫秒GPU上更快。如果你打印的是分類模型的輸出直接找output里最大值對應的下標即可檢測模型則要額外解析候選框。2.4 預處理必須嚴格對齊訓練階段預處理是整個C#集成里最容易踩坑的環(huán)節(jié)沒有之一。訓練YOLO時PyTorch側的處理流程通常包含等比縮放填充letterbox、BGR轉RGB、歸一化到[0,1]。如果C#側只做了普通Resize沒有做letterbox那么同樣一張圖推理結果會出現(xiàn)大量漏檢和誤檢。所謂letterbox縮放是保持圖像寬高比不變的情況下將短邊縮放到目標尺寸長邊按比例縮放然后四周填充灰色通常是114、114、114。實現(xiàn)并不復雜但必須保證和訓練配置一致。我見過不止一次算法同事?lián)Q了個模型訓練時新版用了RectangularTrueC#側還按老版的方式預處理結果現(xiàn)場模型幾乎全盲。所以拿到模型的第一件事是去問算法同事要一份完整的預處理參數(shù)表包括歸一化均值、方差、輸入尺寸、是否填充、填充顏色然后封裝成C#方法和Python側逐像素對比驗證。3. 源碼怎么讀、怎么改幾個關鍵模塊的逐段拆解3.1 推理封裝類的設計讀C#深度學習項目的源碼時抓大放小的順序應該是先找模型加載邏輯再看輸入輸出Tensor構造最后看后處理解析。一個規(guī)范的項目會把這三件事拆到不同的類里。實踐下來我比較推薦的封裝方式如下public class YoloDetector : IDisposable { private readonly InferenceSession _session; private readonly int _inputSize; private readonly float[] _means; private readonly float[] _stds; public YoloDetector(string modelPath, int inputSize 640) { var options new SessionOptions(); options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; _session new InferenceSession(modelPath, options); _inputSize inputSize; _means new float[] { 0f, 0f, 0f }; _stds new float[] { 1f / 255f, 1f / 255f, 1f / 255f }; } public ListDetectionResult Detect(Mat image) { // 預處理 推理 后處理 } public void Dispose() { _session?.Dispose(); } }設計時注意三點第一InferenceSession是線程安全的多線程場景下可以共享同一個實例不必每次推理都new一個第二Dispose方法必須實現(xiàn)句柄泄漏在工控長穩(wěn)測試中是大忌第三不要在后處理函數(shù)里輸出日志高頻推理時日志IO會嚴重影響實時性。3.2 輸入輸出張量的綁定細節(jié)不少人在NamedOnnxValue.CreateFromTensor這里栽跟頭。這里的輸入名“images”必須嚴格對應ONNX模型的輸入節(jié)點名大小寫敏感不能憑感覺寫。拿到模型后我習慣在啟動時打印一次所有輸入輸出節(jié)點的名稱和維度作為校驗foreach (var input in _session.InputMetadata) { Console.WriteLine($Input: {input.Key}, dims: {string.Join(,, input.Value.Dimensions)}); } foreach (var output in _session.OutputMetadata) { Console.WriteLine($Output: {output.Key}, dims: {string.Join(,, output.Value.Dimensions)}); }輸出張量的解析也各有門道。YOLOv8的輸出形狀是[1, 84, 8400]其中84表示4個框坐標80個類別概率8400表示不同尺度下的候選框總數(shù)。解析時要先做維度交換把形狀變?yōu)閇1, 8400, 84]再用置信度閾值過濾。有些模型還帶NMS算子可以直接在推理結果里得到最終檢測框沒有的話就要自己寫非極大值抑制。3.3 一個常見瓶頸單例Session還是每次都創(chuàng)建這個問題我在好幾個項目里都被問過。結論很明確在工業(yè)場景中InferenceSession永遠應該復用不應該每次推理都重新創(chuàng)建。我實測過創(chuàng)建一個YOLOv8的Session在CPU上可能要幾百毫秒到1秒等于直接丟掉了實時性。正確做法是把Session當作一個長時間存活的單例在程序啟動時初始化在程序退出時釋放。但這里有個容易忽略的前提如果你在C#里同時跑多個模型或者需要動態(tài)切換模型比如生產(chǎn)品種變化時切換檢測模型需要合理規(guī)劃Session的生命周期。多模型場景下的穩(wěn)妥做法是為每個模型維護一個ConcurrentDictionary以模型路徑為Key值為對應的InferenceSession實例切換時按需獲取避免反復創(chuàng)建銷毀帶來的GC壓力。4. 工業(yè)部署中的三大坑4.1 相機采集和UI刷新卡頓處理檢索熱詞里出現(xiàn)“c# 循環(huán)數(shù)據(jù)采集和ui刷新卡頓”這在帶視覺檢測的上位機里特別典型。很多剛接觸上位機開發(fā)的程序員會把圖像采集、推理、結果顯示全部放在UI線程里執(zhí)行結果就是界面一卡一卡甚至會報“UI線程無響應”。正確的思路是采集和推理全部放在后臺線程UI線程只負責顯示。用一個經(jīng)典的System.Threading.Channels或者BlockingCollection做隊列相機線程往隊列里塞圖像推理線程從隊列取圖執(zhí)行模型推理完成后通過BeginInvoke或Dispatcher把結果回調(diào)到UI線程。瓶頸隊列的長度要設置上限比如10幀滿了就丟最舊的幀保證處理的是實時畫面而不是積壓的歷史幀。4.2 掃碼槍觸發(fā)與推理業(yè)務串行還是并行熱詞里“c# 掃碼槍觸發(fā)事件”對應的場景是掃碼槍掃到條碼后觸發(fā)相機拍照并進入檢測流程。這里有個并發(fā)陷阱掃碼槍事件觸發(fā)頻率可能遠高于單張圖片的推理耗時如果每次觸發(fā)都同步執(zhí)行拍照推理事件會積壓現(xiàn)場表現(xiàn)為掃碼沒反應或漏檢。我的做法是把掃碼事件當成一個“拍照請求”入隊相機采集線程輪詢隊列有請求則觸發(fā)硬觸發(fā)或軟觸發(fā)拍照然后進入檢測流水線。另一個更快的方案是相機一直處于連續(xù)采集模式后臺線程檢測到掃碼事件后從最近一幀圖像里處理即可。兩種方案各有優(yōu)劣前者適合需要精確定位抓拍瞬間的場景后者適合生產(chǎn)線速度較快、不允許額外拍照延時的場景。4.3 調(diào)試模型輸出完全不符怎么辦這是排查過程最痛苦的一類問題C#代碼跑起來了模型也加載了但輸出結果就是和Python側對不上。遇到這種情況我的排查順序是固定的第一步確認輸入張量是否一致。把C#預處理后的inputData保存成二進制文件同時在Python側用同樣的預處理把Tensor導出逐元素對比看差異出現(xiàn)在哪一環(huán)。圖像預處理最常見的坑包括顏色通道順序錯誤、歸一化參數(shù)不一致、Resize方式不同OpenCV默認雙線性插值和PyTorch默認插值可能不同、letterbox填充值不同。第二步確認模型是否在C#側被錯誤地再次歸一化。有些初學者拿到PyTorch模型Python側訓練時做過歸一化C#側又加了一次除以255導致輸入變成原來的1/255推理結果自然面目全非。第三步確認輸出解析邏輯是否正確。比如YOLOv8的輸出是cx, cy, w, h形式的中心坐標解析時卻按x1, y1, x2, y2去算得到的框位置必然不對。建議在C#后處理每個階段都打印一條中間結果和Python側逐行對照。這一步如果全部走通基本可以確定問題不在集成層。剩下的可能是模型導出時算子的兼容性問題但這類問題在ONNX Runtime和opset 17以上的組合下已經(jīng)非常少見了。5. 源碼工程里的高頻細節(jié)補充5.1 自己動手編譯ONNX Runtime的必要性如果只是做普通項目用NuGet官方包完全夠用。但老實說官方包為了通用性做了一些取舍比如默認不啟用OpenMP、可能使用MLAS默認調(diào)度策略等在特定CPU上性能未必最優(yōu)。我遇到的少數(shù)需要自己編譯的場景有兩個一是目標平臺是國產(chǎn)CPU比如ARM架構的邊緣盒子官方包沒有對應的預編譯版本二是需要裁剪算子只保留模型用到的算子把DLL體積壓到最小。自己編譯ONNX Runtime并不是一個輕松活需要用CMake配置、拉取Python工具鏈、跑完整構建腳本整個過程非常耗時。建議先確認官方發(fā)布頁面里是否有匹配目標平臺的包有的情況下就不要自己折騰編譯。5.2 用GPU加速前的硬件適配如果工控機有獨立顯卡比如NVIDIA的MX系列或RTX系列可以考慮使用Microsoft.ML.OnnxRuntime.Gpu提升推理速度。但這里有兩個前提顯卡驅動必須支持CUDA且需要額外安裝對應版本的CUDA和cuDNN。GPU推理并非在所有場景都有優(yōu)勢小模型在CPU上本身就很快GPU反而會因為內(nèi)存拷貝增加毫秒級延遲。所以我的經(jīng)驗是先把CPU推理跑通再根據(jù)實際幀率決定是否上GPU而不是一上來就鋪開GPU環(huán)境徒增部署復雜度。5.3 日志與診斷信息的保留工控軟件在產(chǎn)線現(xiàn)場跑日志是排查問題最重要的抓手。建議在推理線程里用Stopwatch記錄預處理、推理、后處理三個環(huán)節(jié)各自的耗時寫日志時帶上時間戳、模型版本、圖像尺寸、推理結果概要。這個習慣我堅持了很多年幫我在現(xiàn)場省了大量時間。模型迭代后如果出現(xiàn)性能下降先翻日志往往就能定位到是預處理參數(shù)變化還是模型本身變化導致的。6. 最后分享一點實際經(jīng)驗如果你現(xiàn)在正準備在C#里集成深度學習模型我的建議是先不要糾結框架選型直接走“PyTorch導出ONNX ONNX Runtime推理”這條路這是目前生態(tài)最成熟、文檔最齊全、坑最少的方向。整個鏈路跑通后再回頭根據(jù)自己的場景優(yōu)化是換GPU、調(diào)線程模型、還是按需裁剪DLL。從源碼閱讀的角度把重心放在輸入輸出張量構造和后處理解析這兩部分因為這部分高度依賴具體模型也是出錯率最高的地方。模型加載本身反而沒什么玄機。C#做深度學習這件事核心挑戰(zhàn)從來不是語言能力而是整個工程鏈路的暢順度。把Python訓練側和C#部署側的約定對齊保證預處理、張量布局、后處理邏輯的每一步都能對上剩下的就都是工程優(yōu)化問題。希望這篇文章能幫你少走一些彎路。本文還有配套的精品資源點擊獲取