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