處理不再拖后腿)
做 AI 推理落地的人應(yīng)該都有同感模型跑起來只是第一步真正磨人的是模型前后的那一段數(shù)據(jù)處理。輸入一張 1080P 的圖先要解碼、縮放、色彩空間轉(zhuǎn)換、歸一化數(shù)據(jù)才能喂進網(wǎng)絡(luò)推理完也不是終點輸出還得分揀、解碼、NMS才能變成業(yè)務(wù)能用的檢測框。這一整條鏈路上CANN Ops-CV 倉庫做的事情就是把計算機視覺里最高頻的核心算子沉淀成高性能實現(xiàn)讓它們直接運行在 AI 處理器的專用計算單元上避免預(yù)處理和后處理變成整個推理鏈路的短板。這篇文章我會結(jié)合自己在一個檢測項目里的實操經(jīng)歷拆解這個倉庫的定位、算子分類、底層加速機制、跑通算子的完整過程以及實際踩過的坑給準備在異構(gòu) AI 平臺上做 CV 推理的同學一份可以直接參考的資料。1. 它解決的問題為什么 CV 算子不能全堆在 CPU 上1.1 推理鏈路里被低估的預(yù)處理和后處理先算一筆時間賬。在一個典型的端到端檢測服務(wù)里模型在 AI 處理器上跑一次前向可能只要 2~3 毫秒但如果預(yù)處理放在 CPU 上做讀圖、resize、轉(zhuǎn) RGB、減均值除方差1080P 的輸入在普通服務(wù)器上差不多要 4~6 毫秒后處理再做一次閾值過濾和 NMS又是 1~2 毫秒。也就是說模型算得快反而讓 CPU 上的那部分變成了整條鏈路最短的一塊板。更麻煩的是CPU 還要同時承擔推理框架調(diào)度、網(wǎng)絡(luò)收發(fā)、業(yè)務(wù)邏輯預(yù)處理一旦占住核整體吞吐就會被拖下來。把算子挪到 AI 處理器上有兩個直接好處。第一AI 計算單元在一段時間內(nèi)只處理當前 batch其他時間段是閑置的用這些碎片時間做像素級操作幾乎不增加全鏈路延遲第二數(shù)據(jù)不用在主板上來回搬運預(yù)處理后的張量直接留在加速卡內(nèi)存里喂給模型省掉了內(nèi)存拷貝那一大筆開銷。這也是 Ops-CV 這類算子倉存在的根本出發(fā)點不是為了替代 OpenCV而是要把 CV 處理留在加速鏈路內(nèi)部讓模型前后的所有操作都在一個高效閉環(huán)里完成。1.2 把 OpenCV 代碼“搬進去”為什么行不通很多同學第一反應(yīng)是把 OpenCV 的源碼交叉編譯到 AI 平臺上不就行了這事我一開始也干過實際跑起來問題一堆。首先是硬件差異AI 處理器通用核少、主頻低它快在專用計算陣列上而 OpenCV 的算法是為通用處理器的 SIMD 指令優(yōu)化過的交叉編譯過去等于放棄加速優(yōu)勢。其次是訪存差異像素級算子對內(nèi)存訪問模式非常敏感AI 加速卡里有獨特的存儲層次和搬移引擎代碼不改性能上不去。再加上數(shù)據(jù)布局不匹配模型要的是 NCHW 甚至更特殊的五維格式OpenCV 輸出的是標準圖像布局中間必然多一次昂貴的格式轉(zhuǎn)換轉(zhuǎn)換的代價有時候比算子本身還高。所以 Ops-CV 的每個算子幾乎都是針對 AI 處理器的存儲層次和計算單元重新設(shè)計的誰負責切分數(shù)據(jù)、誰負責搬運、誰負責向量運算、精度怎么對齊這些是重寫時才會考慮的問題。這解釋了為什么倉庫里的算子雖然名字和 OpenCV 一樣內(nèi)部實現(xiàn)完全是另一套東西。我在項目里對比過同樣一個雙線性 resize直接搬到加速卡上的 OpenCV 實現(xiàn)比倉庫算子慢三倍還多而且內(nèi)存訪問錯亂導致偶發(fā)崩潰最后只能老老實實用倉庫方案。1.3 倉庫的生態(tài)位算力與易用性之間的平衡點從軟件棧看Ops-CV 位于運行時 API 之上、應(yīng)用框架之下。它對上層提供統(tǒng)一的算子接口對下層屏蔽 AI 處理器的細節(jié)。開發(fā)者不需要理解 Tiling、流水線、向量指令只需要知道這個算子的語義和參數(shù)同時因為實現(xiàn)是固定的、經(jīng)過驗證的性能表現(xiàn)也是可預(yù)期的。這個可預(yù)期在工程上非常重要——同樣的算子如果大家各寫各的性能差三倍五倍都正常倉庫統(tǒng)一沉淀一套實現(xiàn)整個生態(tài)里的項目起點就拉齊了。另外倉庫還自帶測試用例和基準數(shù)據(jù)。團隊接手的項目如果換了硬件平臺算子接口不變回歸測試可以直接復(fù)用遷移成本一下子就降下來了。這一點在我維護長期項目時感受特別深算子行為穩(wěn)定業(yè)務(wù)代碼就不用反復(fù)調(diào)整。2. 算子清單與實現(xiàn)思路拆解CV 算子到底有哪些講究2.1 像素級算子讓數(shù)據(jù)的形狀和數(shù)值都合身像素級算子處理的是每個像素獨立運算的問題典型代表是色彩空間轉(zhuǎn)換、Resize、歸一化、翻轉(zhuǎn)??雌饋砗唵蔚珜崿F(xiàn)細節(jié)非常多。色彩空間轉(zhuǎn)換最常用的是 RGB?BGR、RGB→Gray、RGB→YUV以及在視頻場景里繞不開的 NV12→RGB。NV12 是硬件視頻解碼器最常吐出來的格式Y(jié) 平面和 UV 交錯平面的內(nèi)存布局比較特殊轉(zhuǎn)出來還要配上寬高對齊一個對齊沒做好整張圖就是花的。實現(xiàn)時一般先把公式展開把系數(shù)轉(zhuǎn)成定點數(shù)計算避免浮點導致不同平臺結(jié)果不一致。這里有個容易被忽略的點很多 AI 模型訓練時用的是固定的均值方差而視頻流里是 BGR 順序順序和數(shù)值兩件事都要對齊錯任何一個模型精度立刻變差。我排查過一次線上精度掉點最后定位就是預(yù)處理里把 RGB 當 BGR 用了模型本身完全沒問題。Resize 是另一個看著簡單、實際很見功底的算子。以雙線性插值為例每個輸出像素要找到它在原圖中的浮點坐標再取周圍四個像素加權(quán)。CPU 上這段邏輯很直白但在 AI 處理器上要注意的是四個相鄰像素的讀取可能跨內(nèi)存行直接逐像素算會讓向量單元效率很低。比較常見的做法是先計算好坐標映射表然后按行或按塊處理讓相鄰輸出像素盡可能共享輸入數(shù)據(jù)配合 DMA 批量搬入。邊界處理也要統(tǒng)一策略是 clamp、鏡像還是補零必須和訓練時保持一致否則推理結(jié)果和離線評測對不上。歸一化算子是典型的融合對象。減均值、除方差、可能再加上通道重排和量化縮放這些步驟如果能在一個 kernel 里一次完成內(nèi)存帶寬的節(jié)省非??捎^。實踐中我通常把歸一化和前面的色彩轉(zhuǎn)換、Resize 合并成一個算子整個預(yù)處理就變成一次大搬運加一次計算而不是三段式的小搬運。數(shù)據(jù)每在內(nèi)外存之間倒一次手都有額外開銷少倒一次就是實打?qū)嵉奶崴佟?.2 幾何變換坐標系映射比像素搬運更值得摳仿射變換、透視變換、旋轉(zhuǎn)、翻轉(zhuǎn)這類算子計算的核心不在搬運像素而在怎么把目標像素映射回原圖。拿 warpAffine 舉例6 個參數(shù)構(gòu)成的矩陣可以把一個矩形映射成任意平行四邊形。正向映射遍歷原圖像素寫到目標位置會產(chǎn)生空洞和重疊所以工程上幾乎都采用反向映射遍歷輸出圖每個像素用逆矩陣算出它在原圖中的位置再取周圍像素插值。這樣每個輸出像素都有確定的值實現(xiàn)也更規(guī)整。在 AI 處理器上做反向映射麻煩的是輸出坐標是浮點計算出來的落回整數(shù)像素坐標后取數(shù)范圍不確定DMA 搬運的邊界也變復(fù)雜。透視變換更甚8 個參數(shù)邊界上的坐標可能跑到很遠。我的建議是先把變換矩陣和逆矩陣在 Host 側(cè)算好Device 側(cè)只做查表和插值如果業(yè)務(wù)允許還能用單應(yīng)性變換做近似省掉除法效率更高。這些算子雖然看起來小眾但做數(shù)據(jù)增強、圖像矯正、文字檢測預(yù)處理時都是剛需。2.3 濾波與邊緣從滑窗到可分離卷積濾波算子看著基礎(chǔ)但在 AI 處理器上降速的差異很大。高斯模糊這類卷積核是可分離的一個 5x5 的核可以先做水平一維濾波再做垂直一維濾波計算量從 25 次乘加降到 10 次?;暗泥徲蛟L問在連續(xù)行上還好但跨行就牽扯到行緩沖和重疊區(qū)搬運這給 Tiling 帶來了額外約束。切分時每個計算核要負責自己那份行條帶還得帶上一圈 halo 重疊行搬運量就漲上去了所以濾波算子的性能瓶頸常常不在計算而在邊界數(shù)據(jù)的重復(fù)搬入。均值模糊可以用積分圖做也可以直接用可分離的 Box 濾波等價。中值濾波更麻煩本質(zhì)是排序問題向量單元不擅長比較密集的分支操作一般會改用直方圖法或其他近似策略。Sobel 這類邊緣算子實現(xiàn)相對溫和本質(zhì)就是兩次可分離濾波再加一個梯度幅值計算Canny 就復(fù)雜了非極大值抑制和雙閾值滯后這兩個環(huán)節(jié)存在大量條件分支不太適合在向量單元上直接寫。如果一定要在加速卡上跑 Canny通常的做法是讓通用核處理后兩段前端的梯度計算交給向量單元各用所長。2.4 檢測后處理真正決定端到端效果的算子后處理經(jīng)常被當成分內(nèi)小事實際上它對端到端結(jié)果的影響非常大。以單階段檢測為例模型輸出的是一堆卷積結(jié)果每個格子都有若干候選框的偏移量、置信度和類別概率。后處理要把這些原始數(shù)值解碼成真實坐標做閾值過濾再做 NMS 去掉重復(fù)框。解碼部分包括激活函數(shù)、坐標變換、每個格子的偏移累加這些是典型逐元素或逐局部運算放到向量單元上很合適。麻煩的是 NMS。NMS 有兩個內(nèi)在矛盾一是它天然串行一個框要不要刪取決于它和更高分框的 IoU而排序又讓順序不確定二是同一張圖里的框數(shù)量每次都不同動態(tài)形狀對 AI 核并不友好。目前常見的優(yōu)化思路是先按類別分組再按分數(shù)降序用足夠大的固定緩沖處理最大框數(shù)閾值過濾盡可能提前減少候選集也有的實現(xiàn)會把多個框之間的 IoU 計算矩陣化一次算出一組框的 IoU 矩陣再用向量指令做閾值判斷減少串行等待。實測下來合理的閾值過濾能把 NMS 的耗時砍掉一半以上這個錢花得最值。Anchor 生成和網(wǎng)格坐標預(yù)計算相對無腦但價值在于消除重復(fù)勞動可以在 Host 側(cè)一次性生成常量表Device 側(cè)直接查避免每次推理都重復(fù)算。這類小優(yōu)化不起眼但在高吞吐服務(wù)里每一處省下的微秒都會疊加到 QPS 上。3. 算子為什么能在 AI 處理器上“快”底層關(guān)鍵機制3.1 數(shù)據(jù)布局五維格式與通道對齊很多人在算子實現(xiàn)上栽跟頭第一個坑就是數(shù)據(jù)布局。AI 處理器上的張量很少直接是 NCHW 或 NHWC而是一種把通道進一步切塊的格式工程上常見的是把 C 維拆成 C1 和 C0 兩段形成 N_C1_H_W_C0 的五維表示其中 C0 通常是 16 或 32 的整數(shù)倍。這么做的原因很簡單向量單元每次讀寫的位寬是固定的通道方向上湊不夠一個對齊單位就會浪費帶寬、增加邊界分支。對于 CV 算子來說這意味著你在寫色彩轉(zhuǎn)換、歸一化之前必須先明確輸入的 C0 對齊方式。比如三通道 RGBC016 時中間會有大量 padding如果算子按 C 連續(xù)處理可能一個像素的三通道被拆到不同通道塊里。所以 CV 算子通常寧可先把數(shù)據(jù)轉(zhuǎn)換到 NHWC再做逐像素計算最后再轉(zhuǎn)回模型需要的布局——中間多一次格式轉(zhuǎn)換也比在五維格式上硬寫簡單得多。但既然轉(zhuǎn)換也是一次全量搬運能融合就不要拆分這又回到了前面說的算子融合思路每一次格式轉(zhuǎn)換都是在燒帶寬能不轉(zhuǎn)就不轉(zhuǎn)。3.2 切分把一張大圖交給多個計算核AI 處理器上通常有多個 AI 計算核算子想快必須把數(shù)據(jù)切到多核上并行。圖像數(shù)據(jù)最自然的切分是沿 H 方向切成若干條帶每個核處理一行或幾行。切分聽起來簡單里面有三個講究。第一是切分粒度。塊太大后啟動的核等前面的搬運利用率低塊太小每個核的開銷相對變高DMA 搬運的啟動成本攤不薄。一般是根據(jù)算子類型、可用片上存儲和圖像尺寸算一個經(jīng)驗區(qū)間再留參數(shù)給調(diào)優(yōu)器調(diào)。第二是重疊區(qū)。濾波類算子需要鄰域像素每個核處理的行條帶要額外搬入上下各若干行這段重疊區(qū)也叫 halo要單獨處理不能算進有效輸出否則輸出的行數(shù)就對不上。第三是切分對齊。塊寬要盡量服從 16 字節(jié)或按 C0 對齊的約束否則搬運會分裂成很多小請求帶寬直接掉一大截。3.3 流水線DMA 搬運和向量計算的并行我曾見過一個算子代碼邏輯完全正確性能卻只有預(yù)期的三分之一最后定位發(fā)現(xiàn)是算的時候在等數(shù)據(jù)搬數(shù)據(jù)的時候計算單元在睡大覺。AI 核上數(shù)據(jù)搬運、向量計算、標量控制是三條不同流水理想狀態(tài)下應(yīng)該讓它們同時忙DMA 預(yù)取下一塊數(shù)據(jù)的同時向量單元算當前塊標量單元同步更新地址。這就是常說的雙緩沖或多緩沖。理解這個機制可以用一個類比食堂打飯一個人既要炒菜又要盛飯又要擦桌子肯定慢合適的分工是先把下一桌的菜備好這邊同時給上一桌盛飯。要拿到這個效果寫算子時要注意兩點。一是循環(huán)體的迭代步驟里計算和搬運要在不同 buffer 上交替能不能交替看 buffer 是否足夠不夠就只能退化成串行。二是要主動控制預(yù)取深度不要等 DMA 完成事件到了才發(fā)下一次搬運而是提前把地址和長度準備好。實際調(diào)優(yōu)中我習慣把帶寬利用率當作第一指標看搬運占用的時間如果沒和計算重疊利用率很難超過一半。優(yōu)化順序也基本固定先看訪問總量能不能減再看搬運和計算有沒有重疊最后才看指令級的優(yōu)化。3.4 精度策略不同算子要有不同取舍AI 處理器的計算單元為了速度會傾向使用低精度但 CV 算子對精度要求各不相同需要分別處理。色彩轉(zhuǎn)換、歸一化這類算子輸出往往直接作為模型輸入精度誤差會被后續(xù)網(wǎng)絡(luò)放大我一般會用 FP32 做中間累加最后再轉(zhuǎn)成模型需要的 FP16均值方差計算里也要注意累加順序用 FP16 累加小數(shù)值很容易漂移結(jié)果就是和 CPU 參考實現(xiàn)一對比diff 老是壓不下去。Resize 插值的權(quán)重是固定系數(shù)轉(zhuǎn)成定點整數(shù)后計算完全可復(fù)現(xiàn)反而比浮點更穩(wěn)定。NMS 的 IoU 計算用 FP16 也夠但分數(shù)排序階段的比較精度如果不夠會出現(xiàn)兩個相同分數(shù)的框被順序敏感地保留或刪除表現(xiàn)為同一張圖多次推理結(jié)果輕微抖動??傊款愃阕拥木炔呗圆皇桥哪X袋定的而是用可接受誤差范圍 與 CPU 參考實現(xiàn)的 diff這個標準去約束每次改動都跑一遍 diff 測試心里才有底。4. 實操記錄完整跑通 Resize 歸一化算子的過程4.1 準備工作與算子包安裝以我的某檢測項目為例環(huán)境是一臺裝了 AI 加速卡的主機CANN 工具鏈已經(jīng)部署好。第一步是確認版本和算子包是否匹配這一步非常容易踩坑驅(qū)動、運行時、算子包的版本只要有一個不一致算子加載階段就會報各種看不懂的錯誤。我把版本號統(tǒng)一寫在一個環(huán)境變量文件里每次換機器先 source 一下省得排查半天發(fā)現(xiàn)是版本問題。接下來是算子包安裝。Ops-CV 倉庫里的算子一般會隨發(fā)布介質(zhì)提供離線包安裝完成后可以查詢已注冊的算子列表確認 Resize 和 Normalize 確實在列。如果是自己改內(nèi)核代碼則要走算子工程的編譯流程把算子編譯成二進制再部署到指定目錄。第一次編譯通常比較慢主要時間花在生成針對不同 shape 的編譯產(chǎn)物上。這里有個經(jīng)驗如果只是調(diào)參不用每次重編全套把算子包的緩存目錄保留好增量編譯能快很多。4.2 調(diào)用側(cè)代碼怎么寫用運行時 API 調(diào)算子整體邏輯很直觀準備輸入輸出張量的描述符把輸入數(shù)據(jù)拷進加速卡內(nèi)存然后調(diào)用算子接口。下面是一段典型調(diào)用代碼的骨架// 偽代碼基于運行時 API 調(diào)用 Resize Normalize 融合算子 auto desc_in aclCreateTensorDesc(ACL_FP16, 3, {1, 3, 1080, 1920}, ACL_FORMAT_ND); auto desc_out aclCreateTensorDesc(ACL_FP16, 3, {1, 3, 540, 960}, ACL_FORMAT_ND); void* dev_in nullptr; void* dev_out nullptr; aclMalloc(dev_in, in_bytes, ACL_MEM_MALLOC_NORMAL_ONLY); aclMalloc(dev_out, out_bytes, ACL_MEM_MALLOC_NORMAL_ONLY); // 拷入輸入圖像已解碼為 BGR 的原始數(shù)據(jù) aclrtMemcpy(dev_in, in_bytes, host_buffer, in_bytes, ACL_MEMCPY_HOST_TO_DEVICE); // 設(shè)置屬性目標寬高、插值方式、均值方差 OpResizeNormalize op(540, 960, ACL_INTERP_LINEAR, mean, std); op.SetInput(0, desc_in, dev_in); op.SetOutput(0, desc_out, dev_out); // 執(zhí)行 op.Launch(stream); aclrtSynchronize(stream); // 輸出直接送到下一個模型算子 model_input dev_out;一個關(guān)鍵點是輸出張量可以直接作為下一個算子的輸入不需要拷貝回 Host。這就是讓整條鏈路留在加速卡里的意義中間結(jié)果始終在設(shè)備內(nèi)存里省掉了兩次 PCIe 傳輸。如果項目里存在從卡上拷出來處理完再拷回去的寫法請務(wù)必想辦法改掉這往往是最隱蔽的性能黑洞拷來拷去可能比算子本身還慢。4.3 性能對比一次真實的優(yōu)化過程第一次我把 Resize 和歸一化分開實現(xiàn)先跑基準。1080P→540P 的線性插值CPU 上用 OpenCV 大約 3.5ms加速卡上這個算子單獨跑大概 1.2ms看起來已經(jīng)不錯但帶寬利用率只有 60% 左右。問題出在哪我觀察到一個現(xiàn)象算子執(zhí)行時間內(nèi)搬運總字節(jié)數(shù)是輸入加輸出約 12MB按理論帶寬算應(yīng)該 0.8ms 就能搬完說明計算和搬運沒有完全重疊。優(yōu)化動作有兩個。一是把 Resize 和歸一化融合成一個 kernel輸入只讀一次、輸出只寫一次總訪問字節(jié)數(shù)直接下降二是給內(nèi)核加上雙緩沖預(yù)取下一塊輸入的同時計算當前塊。改動后算子整體耗時降到 0.7ms帶寬利用率接近 85%。這個案例說明算子優(yōu)化的第一杠桿永遠是減少數(shù)據(jù)訪問總量第二杠桿才是提高訪存并行度純粹的指令優(yōu)化在內(nèi)存密集型的 CV 算子上的收益反而有限。之后每次做性能預(yù)估我都會先問一句這個算子到底要讀多少、寫多少能不能更少。5. 高頻問題與避坑指南5.1 實測高頻問題速查表現(xiàn)象常見原因排查思路算子加載失敗報版本相關(guān)錯誤驅(qū)動/運行時/算子包版本錯位先統(tǒng)一版本再復(fù)現(xiàn)檢查環(huán)境變量輸入總是黑圖或花屏色彩順序錯了或 NV12 轉(zhuǎn) RGB 公式不對用純色圖逐通道檢查確認順序輸出尺寸對但內(nèi)容錯位數(shù)據(jù)布局不是預(yù)期的 NHWC打印每個通道第一個元素對比性能只有預(yù)期一半DMA 與計算沒有重疊看帶寬利用率檢查是否有隱式同步同一輸入多次推理結(jié)果不同F(xiàn)P16 比較精度不夠或并行切分不確定排序和比較用 FP32固定切分策略小分辨率圖崩潰切分粒度或 buffer 尺寸小于下限檢查對齊和最小塊約束表格里的前兩條我都在項目里真實遇到過。特別是版本不匹配癥狀經(jīng)常是錯誤碼完全看不懂最后逐版本排查才定位到是某個組件多升了一個小版本。建議從一開始就把版本鎖死并且用容器鏡像把環(huán)境固化下來這樣換機器、加節(jié)點都不會出幺蛾子。5.2 幾條值得記住的經(jīng)驗第一優(yōu)先復(fù)用倉庫已有算子不要一上來就自己寫內(nèi)核。自己寫 CV 算子的成本遠高于寫個矩陣乘法因為圖像邊界、通道對齊、插值策略這些細節(jié)太瑣碎任何一個考慮不周后面都是坑。倉庫算子的實現(xiàn)雖然不一定針對你的特定 shape 最優(yōu)但正確性和穩(wěn)定性是驗證過的先把鏈路跑通再談定制。第二把端到端精度對比當成每次改動后的必跑測試拿一組固定圖像對比 CPU 參考輸出和加速卡輸出的 diffdiff 超過閾值立刻查不要等上線后才發(fā)現(xiàn)。這個習慣幫我在項目里攔住過至少三次回歸每次都是看似無害的小改動引入了偏差。第三復(fù)雜后處理盡量拆成可并行部分 少量串行部分??刹⑿胁糠稚舷蛄繂卧胁糠衷踊蚍诺酵ㄓ煤瞬灰噲D讓整個 NMS 在一個核內(nèi)線性完成。并行部分和串行部分的邊界設(shè)計決定了后處理在 batch 變大時是線性擴展還是卡死在一個核上。最后再分享一個小技巧我在做性能分析時最常用的指標不是算子耗時而是有效字節(jié)數(shù) / 實際耗時可達到的帶寬。這個比值如果低于 60%八成是有數(shù)據(jù)沒在用、或者搬運在等計算、或者有多余的格式轉(zhuǎn)換。先把這條追平再談指令級的優(yōu)化。很多看起來玄乎的性能問題追到最后其實都是數(shù)據(jù)路徑上的浪費。