部署的鴻溝
想像一位廚師,做出你這輩子吃過最精緻的一道菜——但裝盤一份就要花三個小時。在安靜的試菜廚房裡,他是天才;但在週五晚上、兩百位飢腸轆轆客人擠滿的尖峰時段,他卻毫無用處,因為菜永遠上不了桌。一個在筆記本(notebook)裡表現出色的模型,和一個在線上真正有用的模型,中間隔著的正是同一道鴻溝。在保留測試集上的準確率是「試菜廚房」;上線(production)則是「晚餐尖峰」。
一張卷積網路流程圖,從輸入影像經過卷積與池化階段,最後產生預測結果。
以下是貫穿整個學習路徑的核心心態轉變。在訓練時,我們基本上只最佳化一件事:準確率(或損失)。但在部署時,我們依然在乎準確率,卻同時還要最佳化速度、記憶體佔用、能源消耗與金錢成本——而且我們通常很樂意用一點點準確率,換來巨大的效率提升。為了讓模型便宜五倍、快上四倍,而犧牲 1% 準確率,在線上幾乎總是正確的選擇。筆記本從來沒教你這個取捨存在,因為在筆記本裡,那些成本是看不見的。
一個視覺模型實際上會在哪裡執行?大致有三種「家」,各有各的規矩。雲端伺服器有強大的 GPU 和大量記憶體,但你按秒付費,而且每一次請求都得跨越網路。手機有不錯但不算頂尖的晶片、一塊你絕不能耗光的電池,還有一位盯著螢幕等待的使用者。微型邊緣裝置——智慧門鈴、工廠攝影機、無人機——可能只有幾 MB 記憶體、可用功率不到一瓦,有時甚至完全沒有網路。同一個在雲端飛快的網路,到了門鈴上可能慢得無可救藥。在這整條路徑裡,我們會沿著這道階梯一路往下走:從舒適的雲端,一直走到在最受限硬體上的 邊緣部署,在那裡,能用的 推論延遲與不能用的延遲之間的差距,就是成敗的全部。
兩種速度:延遲與吞吐量
「快」這個字底下藏著兩個完全不同的數字,而把它們搞混,是部署新手最常犯的單一錯誤。想像一條高速公路。延遲(latency)是一輛車從上匝道到下匝道、整趟旅程花的時間——是一支「每趟」的碼錶。吞吐量(throughput)是每小時通過某個檢查點的車輛數——是路邊的一台計數器。一條高速公路可以延遲很糟(因為走走停停,每趟都很慢),卻同時擁有巨大的吞吐量(一小時有一千輛車緩慢爬過)。兩者有關聯,但不是同一個數字,而且一個產品幾乎從不會同等地在乎兩者。
精確地說:推論延遲是「拿回一個預測」所花的牆上時鐘時間,通常以毫秒計——臉上的 AR 濾鏡、「這張照片裡有什麼」的答案、使用者正在等的那個回應。推論吞吐量則是系統每單位時間完成多少個預測,通常以「每秒影像數」計——那個必須在天亮前標註完一千萬張商品照的整夜批次工作。一個即時、互動的功能,成敗繫於延遲;一個大量離線的工作,成敗繫於吞吐量。你的產品屬於哪一種,應該是你第一個要決定的事,因為它決定了你接下來最佳化的一切。
吞吐量等於批次大小除以處理一個批次所花的時間。
這樣讀:吞吐量 T(每秒影像數)等於批次大小 B(我們一次塞進加速器的影像數)除以 L_batch(把那一個批次完整推過去所花的秒數)。假設單張影像要 10 毫秒,那麼批次 B = 1 時,T = 1 / 0.010 = 每秒 100 張。現在我們改用 B = 32,GPU 用 L_batch = 40 毫秒跑完整個批次。吞吐量一下跳到 T = 32 / 0.040 = 每秒 800 張——足足高了八倍!為什麼?因為加速器裡有成千上萬個微小的算術單元,而單張影像很少能讓它們全部忙起來,它們有一半在空轉。一個批次把那些空著的車道填滿,於是每秒能完成的總工作量大增,而每個批次只多花了一點點時間。這就像高速公路從一條車道拓寬成很多條。
但注意這個陷阱。批次提高了吞吐量,可是它對單張影像的延遲做了什麼?B = 1 時,一個請求 10 毫秒就完成;B = 32 時,你的影像要等整個 40 毫秒批次跑完才算完成——更糟的是,如果你的影像最先到,它可能得乾坐著「等」另外 31 張影像出現,批次才開始跑。正是那個讓系統變有效率的機制(把所有車道填滿),讓任何單一乘客的旅程變更長。這就是根本性的矛盾:批次是用延遲換吞吐量。對整夜批次工作很棒,對即時 AR 濾鏡卻常常很糟。
Little 法則:平均在途請求數等於吞吐量乘以停留時間。
Little 法則用一條近乎神奇的恆等式,把這兩種速度綁在一起。這裡 N 是任一瞬間系統內「在途」請求的平均數(此刻在那段高速公路上的車輛數),X 是吞吐量(每小時通過的車輛數),R 是停留時間——每個請求待在系統內多久,對我們而言基本上就是它的延遲。法則說 N = X · R。快速驗算一下:若每秒流過 800 張影像(X = 800),每張在內部待 R = 0.040 秒,那麼平均 N = 800 × 0.040 = 32 張影像正同時被處理——正好是我們那批 32 張。這法則很直覺:要每小時推過更多車(提高 X)卻不拉長任何人的旅程(固定 R),你就必須讓更多車同時在路上(提高 N)——也就是更多平行度。它是一套簿記,讓「延遲換吞吐量」的取捨變得精確,而不是含糊其詞。
所以並不存在放諸四海皆準的「快」。正確的指標是由產品決定的。一個在你臉上畫上鬍鬚的即時 AR 濾鏡,必須做到每幀約 30 毫秒,否則鬍鬚就會落在你下巴後面——那是純粹的 p95 延遲問題,批次是敵人。一個整夜替你整個相片庫重新標註的工作,只在乎在天亮前以最低成本跑完一千萬張影像——那是純粹的吞吐量問題,批次是你最好的朋友。同一個模型,相反的工程。在你最佳化任何一行程式之前,先決定你活在哪個世界。
計算工作量:FLOPs 與計算成本
要在跑模型之前就能推理它的速度,我們需要一種衡量「這裡頭有多少算術」的貨幣。這個貨幣就是 FLOP——一次浮點運算(FLoating-point OPeration),意思是對實數做的一次乘法或一次加法。神經網路幾乎完全由同一個動作重複數十億次堆出來:拿兩個數字,把它們相乘,再把結果加到一個累加總和上。這個「先乘再加」叫做乘積累加(multiply-accumulate,簡稱 MAC),它是每一次卷積與每一次矩陣乘法的心跳。我們把一次 MAC 算成兩個 FLOP——一次乘加一次加——並用 GFLOPs(十億)甚至 TFLOPs(兆)來衡量整個模型。
單一 2D 卷積層的近似 FLOPs。
讓我們拆解每一個符號,因為一旦你看清每個從哪來,就能徒手估算任何一層。C_in 是輸入通道數(例如進來 64 張特徵圖),C_out 是輸出通道數(濾波器的數量,比如 128)。K_h 與 K_w 是卷積核的高與寬(3×3 的核給出 K_h = K_w = 3)。H_out 與 W_out 是輸出特徵圖的高與寬(比如 56×56)。邏輯是:要產生一個輸出數字,卷積會在它那個 C_in × K_h × K_w 的小視窗上把核相乘再相加——那就是 C_in · K_h · K_w 次 MAC。每個輸出通道都要做一次(× C_out),每個輸出位置也都要做一次(× H_out · W_out)。最後,開頭的 2 把 MAC 換算成 FLOP,因為每次 MAC 是一次乘加一次加。那個 2 就是整條公式以 2 開頭的全部理由——沒有更深的玄機。
一張卷積核在輸入特徵圖上滑動的圖示,在每個位置計算加權總和。
現在來一個完整的算例,讓你可以代入自己的數字。取一個 3×3 卷積層,輸入 C_in = 64 通道、輸出 C_out = 128 通道,產生 56×56 的輸出圖。代入:FLOPs ≈ 2 · 64 · 128 · 3 · 3 · 56 · 56。一步步算:2 · 64 · 128 = 16,384;乘以 3 · 3 = 9 得 147,456;再乘以 56 · 56 = 3,136 得 462,422,016。所以這一層就要約 4.6 × 10⁸ FLOPs ≈ 0.46 GFLOPs。真實網路會疊上五十多個這樣的層,這就是為什麼一個普通分類器處理單張影像就要好幾 GFLOPs——也是為什麼「一夜跑一千萬次」或「在手機上每秒跑三十次」會變成一個你現在還沒寫任何推論程式碼,就能先估算的預算問題。
def conv_flops(c_in, c_out, kh, kw, h_out, w_out):
"""Approximate FLOPs for one 2D conv layer (1 MAC = 2 FLOPs)."""
macs = c_in * c_out * kh * kw * h_out * w_out
return 2 * macs
# The worked example: a single 3x3 conv, 64 -> 128 channels, 56x56 output
flops = conv_flops(c_in=64, c_out=128, kh=3, kw=3, h_out=56, w_out=56)
print(flops) # 462422016
print(flops / 1e9) # 0.462... GFLOPs for ONE layer工作在哪裡執行:硬體加速器
完全相同、一個位元組都不差的同一個模型,在某顆晶片上可以快得驚人,在另一顆上卻慢得令人痛苦。要懂為什麼,你需要對三大類處理器有感覺。CPU 是一小撮非常聰明、非常快的工人——擅長充滿分支、「先做這個再做那個」的邏輯,但人數只有寥寥幾個。GPU 是一支由成千上萬個比較簡單的工人組成的大軍,他們全都在同一時間做同樣的算術。NPU/TPU(神經處理單元/張量處理單元)則是更專門的工廠:專用的張量或矩陣單元,整個任務就是不停地碾過乘積累加,幾乎什麼別的都不做。選對並善用其中合適的那一個,正是 硬體加速器策略的核心。
為什麼 GPU 與 NPU 在視覺上贏得這麼徹底?因為正如上一節所示,神經網路大多是一座由大型矩陣乘法與卷積堆成的塔——而這些是「極度可平行」的。每個輸出數字都是一個獨立的點積,所以如果你有一萬個算術單元,就能一次算一萬個。一顆八核心 CPU 一次大約能做八個;GPU 一次做幾千個;專用張量單元更是把一整個小型矩陣乘法當成單一硬體指令來啃。加速器的全部精髓,就在於「平行地」做成千上萬次乘積累加,而不是一個接一個地做。
但純粹的算術單元只是晶片的一半。另一半——新手會忘記的那一半——是記憶體頻寬(memory bandwidth):晶片能以多快的速度,在記憶體與那些飢餓的單元之間搬移權重與激活值。想像一萬位能無限快速切菜的廚師,卻只有一道狹窄的門通往儲藏室:他們最後只能站著空等食材。這正是上一節說的記憶體受限狀態,也是為什麼光看 FLOPs 永遠無法預測速度。兩顆峰值 FLOPs 完全相同的晶片,實際延遲可能差到三倍,純粹因為其中一顆餵料更快。日後你聽到有人說某個模型「記憶體受限」,指的就是這幅「廚師餓著肚子等食材」的畫面。
最後一根槓桿,我們稍後會用一整篇來講:數值精度(precision)。上面的數學假設用的是完整的 32 位元浮點(FP32),但加速器在 16 位元(FP16)或 8 位元整數(INT8)算術下會快得多——而且用的記憶體與頻寬也少得多。張量單元每秒能做的 INT8 運算,往往是 FP32 的二到四倍,而 INT8 權重只有四分之一大小,這直接緩解了頻寬的擠壓。代價是位元數變少可能損失一點點準確率,這正是我們開頭點出的取捨——也正是量化那篇的全部主題。現在,先記住一個心智模型:同一個模型會快會慢,取決於它落在哪顆晶片上、它的工作有多平行、那顆晶片餵算術單元的速度有多快,以及你允許它用什麼精度。
用量測代替猜測:模型剖析
以下是區分「工程師」與「滿懷希望的瞎試者」的紀律:先量測,再最佳化。你對「哪一層慢」的直覺,從統計上來說是錯的——連專家的直覺多數時候也是錯的,因為瓶頸往往是某個算術做得不多、卻搬了大量記憶體的不起眼層。模型剖析(profiling)就是「真的在真實硬體上替你的模型計時、找出毫秒花到哪去」的實作。它是一把儀器,把前面幾節抽象的指標——延遲、吞吐量、計算受限、記憶體受限——變成你能據以行動的具體數字。
新手常在「量測衛生」上犯錯,做出一些「自信滿滿卻是胡說八道」的數字。修正方法很機械化,值得背下來。
- 先暖機。最初幾次執行異常地慢——框架正在編譯核心、配置記憶體,並把 GPU 從低耗時脈中喚醒。先跑 10–50 次丟棄用的迭代並捨棄它們,再開始計時。
- 在目標硬體上量測。在你強大的開發 GPU 上跑出的基準,幾乎無法告訴你模型最終必須上線的那支手機或邊緣晶片會如何。要在它將執行的地方剖析。
- 多次執行取平均,並正確地讀錶。計時數百次迭代並回報統計量,而不是某一次的好運。在 GPU 上工作是非同步的,所以你必須在讀計時器之前同步(等裝置真正完成),否則你量到的是「發出指令」,而不是「實際計算」。
- 回報分布,而不只是平均值。給出 p50 與 p95(若使用者感受得到尾端,再給 p99),就如延遲那一節所說——平均值會藏起使用者真正會察覺的那些卡頓。
- 逐層拆解。用剖析器取得「每層(每個運算子)」的計時表,然後排序。最上面那幾列,才是唯一值得你花力氣的地方。
import time, torch
model.eval().to(device)
x = torch.randn(1, 3, 224, 224, device=device)
# 1) Warm up: discard the slow first runs
with torch.no_grad():
for _ in range(20):
model(x)
torch.cuda.synchronize() # wait for the GPU to actually finish
# 2) Time many runs on the target device
times = []
with torch.no_grad():
for _ in range(200):
torch.cuda.synchronize(); t0 = time.perf_counter()
model(x)
torch.cuda.synchronize(); times.append(time.perf_counter() - t0)
times_ms = sorted(t * 1e3 for t in times)
p50 = times_ms[len(times_ms)//2]
p95 = times_ms[int(len(times_ms)*0.95)]
print(f"latency p50={p50:.2f} ms p95={p95:.2f} ms")一旦你有了逐層計時表,就能把模型——或每一層——分類為計算受限或記憶體受限,這正是「屋頂線」概念在實務上的核心(這裡不需要繁重的數學)。一個粗略的判準:估出該層的 FLOPs 與它必須搬移的位元組數,問問硬體會先做完哪一個。大型密集卷積往往是計算受限(算術單元是瓶頸——加更多或更快的算力,或砍 FLOPs)。微小的逐元素運算、激活函數與瘦長的層往往是記憶體受限(晶片缺資料挨餓——解法是搬更少位元組,例如融合層或量化,而不是加算力)。知道一個瓶頸屬於哪一種,就知道這條路徑後面的哪個工具才真的有用——並讓你免於最佳化錯的東西。
效率三角與接下來的路
讓我們把整篇濃縮成一個耐用的心智模型:準確率—延遲—成本三角。想像三個角彼此互相拉扯。你通常只能靠犧牲另一個的一點點來改善其中一個:把準確率往上推,模型就變大,於是延遲與成本上升;把延遲與成本擠下去,你通常就會削掉一些準確率。沒有哪個角是免費的。部署工程師的標誌不是「拒絕取捨」,而是「刻意地、用量測過的數字、朝著產品真正需要的方向取捨」。
一條縮放法則曲線,顯示準確率隨模型大小與計算量提升,並呈現報酬遞減。
這條路徑剩下的一切,都是把模型沿著那個三角形挪動的工具箱——在盡量少犧牲準確率的前提下,把延遲與成本買回來。第 2 篇 模型壓縮,透過剪枝(剪掉幾乎無關緊要的權重)與蒸餾(訓練一個小學生去模仿大老師),讓模型在結構上變小。第 3 篇量化,用更少的位元做更多的事,以 INT8 取代 FP32 來大砍記憶體與頻寬。第 4 篇講執行期與加速器——ONNX、TensorRT 與硬體堆疊——把你手上那顆晶片榨到極致。第 5 篇則把一切串起來,用於手機與微型裝置上的 邊緣部署,是面對三角形最嚴苛那一角的大師級劇本。
來個快速的「前後對照」讓它變真實。你訓練出一個漂亮的偵測器,得分 92%,在測試手機上以 FP32 跑出每幀 320 毫秒——對需要「感覺即時」的即時相機功能來說太慢了。我們會依序拉動的槓桿:把它剪枝與蒸餾成一個更精瘦的網路(第 2 篇)、量化成 INT8(第 3 篇),再透過手機 NPU 上一個最佳化過的行動執行期來跑(第 4、5 篇)。一個務實的結果大概是:91% 準確率、每幀 35 毫秒——用一個百分點的準確率,換來一個終於真的能上線的模型。那一個百分點,替你買到了一個產品。