邊緣的限制:沒有雲端、預算吃緊
歡迎來到本軌的總結篇。整個系列裡,你逐一學會了每一個槓桿——「夠快」到底是什麼意思、如何縮小模型、如何量化、以及執行階段如何把模型映射到晶片上。現在我們要把所有槓桿一次拉滿,讓一個真正的視覺模型即時跑在手機上。第一個問題是:何必如此?為何不乾脆把每一張影格送到強大的雲端 GPU?有四個理由推著我們走向在裝置本身上執行模型。隱私:相機影格永遠不離開使用者的手機,對人臉、文件、醫療影像來說極為重要。延遲:沒有網路來回,因此避開了雲端呼叫那 50–300 毫秒(而且抖動極大)的延遲。離線運作:app 在隧道裡、飛機上、或訊號全無的偏鄉診所都能用。成本:沒有按次計費的伺服器帳單——使用者自己的硬體就把活幹了。
但搬上裝置是一筆浮士德式的交易。一旦你決定在手機上推論,就繼承了一組殘酷的三重限制。記憶體極小:手機可能只給你的 app 幾百 MB,而非伺服器 GPU 那種數十 GB,所以一個 400 MB 的模型根本載不進去。沒有資料中心的散熱:手機是一塊密封的玻璃板,重度運算幾秒後就會發熱,作業系統便會熱節流——刻意降低晶片速度以保護它,你漂亮的 30 FPS 就悄悄崩成 12 FPS。能量是配給的:每一次推論都在消耗使用者期待能撐一整天的電池,所以我們在意的不只是毫秒,還有每張影格的毫焦耳。在邊緣,你是一名在鞋盒裡、在高溫中、緊咬預算工作的工程師。
這是大師級的實戰手冊,所以我們設一個有野心但務實的標準。我們會拿一個今天在手機 CPU 上每張影格約 180 毫秒(約 5 FPS——卡頓的幻燈片)的模型,透過疊加本軌所有技術,把它穩穩壓到手機神經加速器上的 33 毫秒以內,同時幾乎不損失準確度。這大約是 6–10 倍的加速。讀完之後,你應該能看著任何視覺模型與一台目標裝置,用數字回答:它裝得下嗎、會夠快嗎、以及我該如何把它弄到那裡?
行動裝置推論堆疊
要在裝置上執行,你不會寫原生的 GPU 程式碼;你把模型交給一個行動執行階段,由它去驅動那台手機的晶片。從概念上看,這個版圖其實不大。在 Android 上常見的路徑是 TFLite(一個輕量直譯器)對接 NNAPI,也就是負責找出手機上任何可用加速器的作業系統層。在 Apple 裝置上的對應物是 Core ML,它會自動把工作分散到 CPU、GPU 與 Apple 神經引擎上。兩者也都能用 GPU delegate,透過著色器把工作跑在行動 GPU 上。而在最好的手機底層坐著一顆 NPU——神經處理單元,就是你在第 4 課認識的專用加速器的行動版表親,一塊除了快速又便宜地做矩陣乘法之外什麼都不幹的矽晶。忘掉確切的 API 名稱;只要抓住一個心智模型——哪個引擎跑在哪塊矽上。
以下是關於行動裝置最重要的一個事實,而且它直接呼應第 3 課:INT8 量化在實務上是強制的。 行動 NPU 是以整數為本的機器——它們在物理上就是被造來相乘 8 位元整數的,其中許多要嘛把 FP32 跑得很慢,要嘛根本碰不了,會把那些層踢回 CPU。所以 8 位元整數推論在手機上並不是像伺服器上那樣可有可無的拋光步驟;它是進入 NPU 的門票。一個你沒量化的模型,就是一個快速矽晶大半會拒絕執行的模型。這就是為什麼在邊緣,量化從「不錯的最佳化」升格為「要及早做出、不容妥協的設計決定」。
那麼執行階段是怎麼決定什麼跑在哪裡的?透過 delegate(委派器)。delegate 是一個外掛,它宣告「這些運算我能在這個加速器上跑」。當執行階段載入你的模型時,它會逐個運算走過整張計算圖:delegate 支援的每個運算都被導向 NPU(或 GPU);它不支援的每個運算則留在 CPU 上。這正是我們在第 4 課警告過的回退機制——而且它是雙面刃。一個不支援的運算(比如某個冷門的激活函數)不會讓你的 app 當掉,這很棒;它只是在 CPU 上跑得慢些。但如果那一個 CPU 運算坐落在你網路的正中間,那麼每個張量都得繞著它做 NPU→CPU→NPU 的搬運,而這些搬運的代價,可能比你想加速的那個運算還要高。
# Pseudocode: load an INT8 model and attach an accelerator delegate
interpreter = Interpreter(model="detector_int8.tflite")
# Prefer the integer NPU; degrade gracefully if it's missing
try:
interpreter.add_delegate(NpuDelegate()) # routes INT8 ops to the NPU
except DelegateUnavailable:
interpreter.add_delegate(GpuDelegate()) # fall back to fp16 on the GPU
interpreter.allocate_tensors()
# Ops the delegate can't run silently stay on the CPU.
# ALWAYS profile to confirm the heavy conv layers actually landed on the NPU,
# and that the graph isn't fragmented into costly NPU<->CPU hops.
report = interpreter.profile_ops()
assert report.fraction_on_npu > 0.90, "graph fragmented: transfer overhead may dominate"為預算而設計:延遲與能耗
大師都從預算反推。我們每張影格有 33 毫秒;什麼填滿它?新手的錯誤是以為「模型」就是全部成本。實際上,單張影格的實際牆鐘延遲有三段:前處理(解碼相機緩衝、縮放到網路的輸入尺寸、把像素正規化)、推論(真正的前向傳遞)、以及後處理(對偵測器而言,把原始輸出解碼成框,並執行非極大值抑制以去除重複)。解碼/縮放與 NMS 都是純 CPU 工作,輕易就吃掉你 30–40% 的預算。如果你永遠只最佳化模型,你可以把推論變快兩倍卻仍然達不到 30 FPS,因為另外兩段從來沒動過。
端到端延遲是各階段之和;影格率是它的倒數。
第一條式子讀作「一張影格的時間,等於前處理時間加上推論時間加上後處理時間」。這裡 L_{\text{total}} 是每張影格的總秒數,L_{\text{pre}} 是前處理時間,L_{\text{infer}} 是前向傳遞,L_{\text{post}} 是後處理。第二條式子說每秒影格數就是 1 除以每張影格的時間——如果每張影格要四分之一秒,你就得到 4 FPS。給我們的偵測器套上數字:假設 L_{\text{pre}}=5 毫秒、L_{\text{infer}}=20 毫秒、L_{\text{post}}=6 毫秒。那麼 L_{\text{total}}=31 毫秒,\text{FPS}=1/0.031\approx 32——剛好越過 30 FPS。現在注意:如果我們把推論減半到 10 毫秒卻不管其他,L_{\text{total}}=21 毫秒 → 47 FPS,但那 11 毫秒的 CPU 前/後處理現在超過預算的一半,成了下一個要攻擊的對象。這條式子精準地告訴你該把力氣花在哪。
現在來到整個系列的分析高峰。為什麼一個模型快、另一個運算次數相同卻慢?答案在於它是計算受限(被晶片能多快做乘法所限)還是記憶體受限(被它能多快從記憶體取數所限)。看清這件事的透鏡是算術強度:你每搬一個位元組能做多少次算術運算。強度低的模型搬大量資料卻只做少量數學,於是乘法器閒著等記憶體——這正是邊緣上的隱形殺手,因為搬位元組也最耗能。光數 FLOPs 是不夠的;你必須拿它去對照這台裝置加速器上所搬的位元組數。
算術強度與屋頂線:你的上限是原始算力,或強度乘以頻寬,取兩者中較小者。
拆解它。I 是算術強度,單位是每位元組的 FLOPs——總浮點運算數除以在記憶體與計算單元之間搬運的總位元組數。P_{\text{peak}} 是晶片的最大計算速率(FLOPs/秒),\text{BW} 是它的記憶體頻寬(位元組/秒),P_{\text{attainable}} 是你實際能達到的最佳速度。屋頂線說,你可達到的效能是兩道天花板中較小的那個:平的計算屋頂 P_{\text{peak}},與斜的記憶體屋頂 I\cdot\text{BW}。交叉點(「屋脊」)在 I = P_{\text{peak}}/\text{BW}。在它之下你記憶體受限(斜屋頂咬住你);在它之上你計算受限(平屋頂咬住你)。實算範例:某一層做 2\times10^{9} FLOPs,而在 FP32 下搬 200 MB,所以 I = 2\times10^{9}/2\times10^{8} = 10 FLOP/位元組。在一台 P_{\text{peak}}=1\text{ TFLOP/秒}、\text{BW}=50\text{ GB/秒} 的裝置上,屋脊位於 10^{12}/(5\times10^{10}) = 20 FLOP/位元組。由於 10 < 20,我們記憶體受限,P_{\text{attainable}} = 10\times 5\times10^{10} = 0.5\text{ TFLOP/秒}——只有晶片一半的算力,其餘都浪費在等記憶體上。
這就是把量化和屋頂線綁在一起的重點。把 FP32 → INT8 量化會讓每個權重與激活縮小 4 倍,於是搬運的位元組數降為 1/4,而 FLOP 數不變。在我們的例子裡,位元組從 200 MB 降到 50 MB,所以 I 從 10 上升到 2\times10^{9}/5\times10^{7} = 40 FLOP/位元組。現在 40 > 20:我們越過了屋脊,變成計算受限,可以自由使用晶片完整的整數吞吐量,而不再因記憶體而挨餓。這就是量化在邊緣如此強大的深層原因——它不只是更小的檔案;它實實在在地把你的模型沿屋頂線往右推,拯救一個記憶體受限的網路,而且因為過匯流排的位元組更少,同時也降低了每次推論的能耗。
一張對數-對數圖,一條斜線(記憶體受限區)上升至與一條水平線(計算受限區)交會;一個箭頭顯示模型在量化後從屋脊下方往右移動到上方。
完整技術堆疊協同運作
現在我們把一切組裝起來。整個系列的核心論點是:這些槓桿會疊加——而且按刻意安排的順序疊加效果最好,因為每一步都在為下一步預備模型。套用到我們的偵測器,大師級的流水線是:蒸餾 → 結構化剪枝 → 量化 → 匯出 → 編譯。 關鍵在於,你不是盲目地一次做完;你在每個有損步驟之後都微調以回復一點準確度,讓誤差不會層層累積。把它想成裝修房子:先改結構(牆),再改裝潢(油漆),絕不顛倒。
- 蒸餾:透過知識蒸餾,訓練一個小「學生」偵測器去模仿一個又大又準的「老師」,讓這個小架構一開始就以小博大。
- 結構化剪枝:用結構化剪枝移除整條通道/濾波器,讓網路在真實硬體上真正變小變快(而非只是紙面上稀疏),然後微調以回復準確度。
- 量化:用量化把權重與激活轉成 INT8——先試快速的訓練後量化(PTQ);若準確度掉太多,改用量化感知訓練(QAT)微調模型,讓它學會在 8 位元裡生存。
- 匯出:把完成的模型序列化成可攜的計算圖(ONNX 或執行階段的原生格式),讓它乾淨地離開訓練框架。
- 編譯:建出裝置專屬的引擎(例如給 NVIDIA 邊緣板的 TensorRT,或給手機的 TFLite/Core ML 模型),讓核心被融合並針對確切的目標矽晶調校。
為什麼是這個順序,而非別的? 蒸餾與剪枝改變的是架構——存在多少層與通道——所以它們必須先做;量化你正打算刪掉的通道毫無意義。量化在有損步驟中最後做,因為它依賴最終的權重分布:先剪枝,存活的權重會落定到新的數值,然後你才圍繞它們校準 8 位元的範圍。而你在每一步之後微調,是因為每個有損變換都會把準確度往下推一點;在步驟之間回復,能阻止小損失滾雪球成一個沒用的模型。粗略地說:先塑形架構,再壓縮數字,再降低精度,每道關卡都短暫再訓練。這就是「守住準確度的壓縮」與「悄悄毀掉準確度的壓縮」之間的差別。
一條由左至右的流水線:輸入影像、前處理/縮放、堆疊的卷積特徵擷取器、偵測頭,接著後處理/NMS 產出邊界框。
用一個務實的前後對照把成果具體化。之前——FP32 雲端模型:2,500 萬個參數,每個 4 位元組 ≈ 100 MB,mAP 37.0,在手機 CPU 上每張影格 180 毫秒(約 5 FPS,對即時預覽毫無用處)。之後——蒸餾過、結構化剪枝 40%(降到約 1,500 萬參數)、再 INT8 量化並為 NPU 編譯:檔案 約 15 MB(小約 6.7 倍,輕鬆塞進 app 的記憶體),在 NPU 上延遲 約 18 毫秒(10 倍加速,舒適地落在 33 毫秒牆之內),準確度落在 mAP ≈ 35.5——只損失約 1.5 分,大部分由蒸餾與逐步微調回復。大小、延遲、準確度一起移動了,而這同步的移動,就是本軌整個論點在單一模型上被證明。
兩個世界:規模化吞吐 vs 邊緣低延遲
你現在對兩個部署世界的理解,已經深到足以看出它們是相反的。在第 1 課我們把延遲(單一請求要多久)與吞吐量(每秒完成多少請求)區分開來。在伺服器上,你最佳化吞吐:成千上萬使用者送來影像,而你可以等個幾毫秒,把許多張湊成一個大批次,讓 GPU 數千個核心一起開工。在邊緣上,恰好只有一個使用者、一台相機;影格必須當下被回答,一次一張,在批次大小 1 下,延遲就是一切。同樣的模型,相反的目標。
為什麼批次化幫得了伺服器、卻幾乎幫不了邊緣?GPU 或加速器要付出固定開銷去啟動工作、以及從記憶體載入權重;如果它把權重載入一次、然後在 64 張影像間重複使用,那筆成本就被攤成 64 份,每張影像的成本驟降——這正是第 3 節的算術強度,批次化把它拉高。但在批次 1 下沒有東西可攤,權重只為單一影像而載入,加速器以低使用率運轉。在邊緣你無法批次(你只有一張即時影格),所以你改用另一種方式追求使用率:一個極小的模型、INT8、以及一個就算單串流前向傳遞也能讓它忙起來的編譯引擎。伺服器把延遲藏在吞吐之後;邊緣無處可藏。
伺服器經濟學:單位成本隨吞吐上升而下降,這正是伺服器積極批次化的原因。
把它讀作「一次推論的成本,等於機器每小時的成本,除以它每小時完成多少次推論」。C_{\text{hour}} 是硬體(租用)每小時的成本(美元),\text{throughput} 是每秒完成的推論數,\times 3600 把秒換算成一小時。實算範例:一張雲端 GPU 成本 C_{\text{hour}}=\4。批次化做得好,它達到 2000 次/秒的吞吐,於是每次推論成本 = 4/(2000\times3600) \approx \5.6\times10^{-7}——約每百萬張 \\(0.56。把同一張 GPU 跑在批次 1,吞吐崩到比如 400/秒,得到 \)4/(400\times3600)\approx\2.8\times10^{-6}——約每百萬張 \$2.78,貴了五倍。這五倍正是伺服器營運者不停批次化的原因:吞吐就是錢。邊緣完全看不到這筆帳——使用者的手機就是硬體——這正是為什麼在邊緣我們最佳化另一條軸,單串流延遲,並把吞吐視為無關緊要。
大師級部署檢查清單
最後一次畢業。懂得壓縮與編譯模型,讓你成為一名強悍的工程師;而在數月之間把視覺跑在生產環境、當世界在你的模型周圍變化時仍能撐住,才讓你成為大師。一個出貨的模型不是完工的成品——它是一個會悄悄腐壞的活服務。底下這套紀律,正是把一個聰明的基準分隔成一個人們可以依賴的系統的關鍵。注意第一項就把本軌的教訓變成永久的:你不是只達成一次延遲目標,你要永遠捍衛它。
- 在 CI 裡同時對準確度與延遲做回歸測試:每次模型變更都跑一套自動化測試,只要 mAP 跌破下限、或 p95 延遲爬過 33 毫秒預算,就讓建置失敗——速度是一條測試,不是一個願望。
- 部署後監看資料漂移與概念漂移:真實世界會逐漸不再吻合你的訓練集(新的手機相機、夜景、流行樣式),所以追蹤輸入統計與預測信心,當它們游移時發出警報。
- 為模型做版本管理並保留安全的回滾:為每個部署的模型打標籤,以分階段推出方式上線,並能在指標或當機率飆升時於數分鐘內退回前一版。
- 在全面推出之前,於實際流量上對新的壓縮模型與舊模型做 A/B 比較,這樣你量到的是真實世界的準確度與互動,而非只是離線基準。
- 蒐集裝置端遙測以掌握真實世界的 p95 延遲:你實驗室的手機不是使用者那台用了三年、被熱節流的手機——要量使用者真正感受到的尾端延遲,橫跨真實的裝置族群。
那份清單上的每一項,都倚靠你從一開始就帶在身上的一個習慣:把剖析當成隨時開著的回饋迴路,而非一次性的實驗。你剖析來在壓縮前找出瓶頸;你在 CI 裡剖析來守護延遲;你剖析裝置端遙測來逮住野外的熱節流。剖析是邊緣部署的儀表板——沒有它,你就是盲飛,最佳化無關緊要的東西、卻錯過真正要命的退化。永遠先量測。