AI 的混合精度:為什麼用 16-bit 也能訓練
可表示範圍(約)
有效十進位位數
典型用途
FP32
32
1 / 8 / 23
±3.4×10³⁸
約 7 位
基準訓練、主權重
TF32
19(儲存 32)
1 / 8 / 10
同 FP32
約 3 位
A100 以上矩陣運算
FP16
16
1 / 5 / 10
約 6×10⁻⁸ ~ 65504
約 3.3 位
推論、混合精度訓練
BF16
16
1 / 8 / 7
同 FP32
約 2.4 位
大模型訓練主流
FP8(E4M3)
1 / 4 / 3
約 ±448
約 1 位
H100 以上、推論加速
看這張表就能理解,為什麼純粹把模型「全部轉成 FP16」會出事。FP16 的下界只有 6×10⁻⁸,而神經網路在反向傳播時,梯度值經常小於 10⁻⁸ 甚至 10⁻¹²——這些數字在 FP16 裡會直接被「洗成 0」,也就是所謂的下溢(Underflow)。一旦梯度變成 0,這個參數就再也不會更新了,訓練等於被悄悄凍結。
同時,FP16 的上界只有 65504。在訓練初期,如果權重初始化不當、學習率設太大,或者某個激活值突然爆衝,很容易就越過這個上限變成「inf」。一旦出現 inf,反向傳播的梯度就會變成 NaN,然後 NaNs 會像傳染病一樣污染整個網路——這就是溢位(Overflow)。
二、混合精度訓練到底「混合」了什麼
「混合精度」這個詞很容易被誤解成「一部分層用 FP16、一部分層用 FP32」這種靜態的混搭。實際上,現代混合精度訓練的核心概念是同一份權重同時以兩種格式存在,並且在不同的運算階段使用不同的格式。這個設計才是讓 16-bit 訓練能夠穩定收斂的真正關鍵。
主權重(Master Weights):32 位元的「本體」
混合精度訓練的第一個關鍵設計,是保留一份 FP32 的權重副本,業界通常稱為主權重(Master Weights)。這份 FP32 權重是「真正的」參數,它負責累積每一次的更新量;而 FP16 的權重只是每次前向傳播前,從 FP32 主權重「投影」出來的臨時副本。
為什麼要這樣做?因為參數更新量(也就是梯度乘以學習率)通常非常小。假設某個權重目前是 0.1,而這次的更新量是 10⁻⁵。在 FP16 的精度下,0.1 附近的解析度大約是 6×10⁻⁵,也就是說 10⁻⁵ 這個更新量根本無法被表達——它會被「吸」回原本的數值。這種現象稱為捨入吞掉更新(Swamping)。
如果這種情況反覆發生,模型就會陷入停滯:梯度算得出來、損失也有變化,但權重就是不動。改用 FP32 主權重來累加,10⁻⁵ 相對於 0.1 是完全可以表達的,更新就能被正確地累積下來。這就是為什麼幾乎所有混合精度實作,都一定要有一份 FP32 的主權重。
前向與反向的差別對待
混合精度的第二個關鍵,是不同運算階段使用不同精度。典型的流程是這樣:
這種架構有時被稱為「FP16 計算 + FP32 累加」。它的妙處在於:神經網路消耗算力的地方(矩陣乘法)全部走 16-bit,而需要精確累積的地方(權重更新)全部走 32-bit。兩個世界各取所需,互不干擾。
損失縮放(Loss Scaling):把梯度「放大」再縮回來
有了主權重,捨入問題解決了;但下溢問題還沒解決。梯度值本身可能小到 10⁻¹⁰,這個數字在轉成 FP16 的瞬間就會變成 0,根本來不及累加到主權重上。
損失縮放的思路非常樸素:既然梯度太小,那就先把它放大再算。具體做法是在計算損失函數時,把 loss 乘以一個縮放係數 S(例如 1024 或 65536)。因為反向傳播是線性的,所有梯度都會等比例放大 S 倍。放大後的梯度就能安全地落在 FP16 的可表示範圍內,不會下溢。
等到要把梯度應用到主權重時,再把它除以 S 還原回去。整個過程對模型的數學結果沒有任何影響,只是把「暫時的數值」搬進了一個更適合 16-bit 表達的區間。
更進一步,還有動態損失縮放(Dynamic Loss Scaling)。靜態的縮放係數很難調:太小擋不住下溢,太大又容易溢位。動態版本會監控訓練過程,如果連續 N 個步驟都沒有出現 inf 或 NaN,就把 S 調大一點;一旦偵測到溢位,就跳過這一步更新、把 S 減半。這個機制讓使用者幾乎不需要手動調參,也是 PyTorch AMP 能夠開箱即用的原因。
三、為什麼 16-bit 還能訓練得動?五個關鍵原因
前面講的都是「補救措施」——主權重、損失縮放、精度分流。但真正值得深究的問題是:為什麼神經網路經得起這種精度損失?如果換成其他領域,例如氣象模擬或金融計算,把精度砍半幾乎必然導致結果崩壞。深度學習之所以能容忍,是因為它本身的數學性質與那些領域有本質上的不同。
原因一:訓練的容錯度遠高於推論
很多人會把訓練和推論的精度需求混在一起談,其實兩者完全不同。推論階段的模型是「成品」,輸入的每一個微小擾動都可能被放大成輸出結果的差異,尤其是在分類邊界附近的樣本;而訓練階段的模型是「半成品」,它本來就在不斷地被修改,每一次更新的方向都只是估計值,不是真理。
換句話說,訓練時我們並不需要「精確地」算出梯度,我們只需要算出方向大致正確的梯度。這個要求寬鬆得多。既然梯度本身只是估計值,那麼用 16-bit 算出來的梯度與 32-bit 算出來的梯度有微小差異,對於最終的收斂結果影響極其有限。
原因二:梯度是統計量,不是精確值
反向傳播算出來的梯度,本質上是「整個資料集上損失函數梯度的估計」。我們每次只用了 mini-batch 的幾十或幾百個樣本,去近似全資料集的梯度。這個近似本身就帶有巨大的統計雜訊——batch size 越小,雜訊越大。
把這個雜訊量級和 FP16 的捨入誤差放在一起比較,你會發現:FP16 帶來的相對誤差大約是 10⁻³ 量級,而 mini-batch 取樣帶來的梯度雜訊往往在 10⁻¹ 甚至更大的量級。換句話說,精度損失的信號完全被取樣雜訊給蓋過去了。這就像你在一個吵雜的工廠裡量體溫,體溫計誤差 0.1 度還是 0.5 度,其實根本聽不出來。
原因三:誤差在大量迭代中被平均掉
深度學習的訓練動輒數萬到數百萬步。每一步的捨入誤差雖然存在,但它的方向是隨機的——有時向上捨、有時向下捨。這些誤差在長期的累積下,大部分會互相抵消,而不是系統性地往同一個方向偏移。
更關鍵的是,Adam、SGD with momentum 這類優化器本身就帶有平滑機制。它們不是直接採用單一步驟的梯度,而是對梯度做指數移動平均。這個平均過程會進一步壓制單一步驟的高頻誤差。所以從最佳化的角度看,混合精度引進的雜訊,反而被優化器自動過濾掉了一部分。
原因四:損失縮放把下溢問題擋在門外
前面提過,FP16 最大的風險是梯度下溢。但損失縮放從根本上解決了這個問題:只要你把梯度放大到 FP16 的「甜蜜區間」(大約 10⁻⁴ 到 10³ 之間),就不會下溢,也不會溢位。動態損失縮放更是會自動把梯度維持在這個區間內。
換句話說,16-bit 的問題不是「無法表達」,而是「表達範圍太窄」。只要我們用一個可調整的縮放因子,把數值移動到那個窄窗口裡,問題就消失了。這是一個典型的工程思維:不改變系統的本質,只調整輸入的尺度。
原因五:硬體與編譯器的協同設計
最後一個原因常被忽略,但其實非常重要:現代的 GPU 和加速器,從硬體層面就是為了混合精度而設計的。NVIDIA 從 Volta 架構開始導入 Tensor Core,這是一種專門做 4×4 矩陣乘加的特殊單元,它在硬體內部會用 FP32 來累加 FP16 的乘法結果。
也就是說,「用 16-bit 輸入、32-bit 累加」這件事情,是寫在晶片電路裡的,不是軟體模擬出來的。Google 的 TPU 也是如此,它內部的 bfloat16 乘法器同樣搭配 FP32 累加器。這種硬體設計讓混合精度不只是「省記憶體」,而是真正能帶來數倍吞吐量提升的關鍵。
四、FP16 的三個致命陷阱與實戰解法
理解了原理之後,還是要面對實務上的坑。即使有了 AMP(Automatic Mixed Precision)這樣的自動化工具,很多人在訓練時還是會遇到 loss 變成 NaN、模型完全不收斂、或是訓練速度反而變慢的情況。以下整理三個最常見的陷阱。
陷阱一:梯度下溢與「靜默失敗」
最陰險的問題是「靜默失敗」:訓練看起來正常在跑,loss 也緩慢下降,但某些層的參數其實早就停止更新了。這種情況通常發生在梯度極小的層,例如深層網路的底層、或是使用了很小的初始化尺度。
解法:使用動態損失縮放,並定期監控各層梯度的 norm 值。如果發現某一層的梯度 norm 長期維持在極小值或直接變成 0,就要檢查是否有下溢。PyTorch 的 torch.cuda.amp.GradScaler 會提供 get_scale() 方法,讓你觀察目前的縮放係數變化趨勢。
陷阱二:Softmax、LayerNorm 等歸約運算的精度問題
不是所有運算都適合用 FP16。像 softmax、layer normalization、batch normalization 這些涉及「歸約(reduction)」的操作,需要對大量數值做加總或求平均。在 FP16 下累加幾千個數值,很容易因為尾數不足而產生明顯誤差,甚至因為中間結果超過 65504 而溢位。
解法:把這些運算強制留在 FP32 執行。PyTorch 的 autocast 機制預設就會把 layer_norm、softmax、以及 loss 計算放進 FP32 白名單,這也是為什麼直接用 autocast 通常比自己手動 cast 安全得多。
陷阱三:BN 統計量的累積誤差
Batch Normalization 會維護 running mean 和 running variance,這些統計量是跨 step 累積的。如果在 FP16 下更新,會因為精度不足而導致統計量漂移,尤其是在 batch size 較小、資料分佈變化較大的情況下。
解法:確保 BN 層的參數與 buffer 維持在 FP32。同時可以考慮改用 GroupNorm 或 LayerNorm,這些歸一化方式對精度較不敏感,在混合精度下更穩定。
五、BF16、TF32、FP8:精度格式的演進路線
FP16 是混合精度的第一站,但不是終點。隨著模型規模從幾億參數膨脹到上千億,工程界對精度格式的要求也越來越細緻。這段演進史本身就很值得一看,因為它清楚展示了「範圍」與「精度」之間的拉鋸。
BF16:用精度換範圍的豪賭
BF16 的設計哲學非常直接:與其讓工程師每天擔心梯度爆掉,不如直接把指數位留滿 8 位,讓它的動態範圍與 FP32 完全一致。代價是尾數只剩 7 位,有效數字大約 2.4 位。
乍看之下,這個代價很誇張——2.4 位有效數字,連兩位整數都表達不完整。但實際訓練中,BF16 的表現往往不比 FP16 差,甚至更好。原因是:神經網路對尾數精度的容忍度,遠高於對數值範圍的容忍度。只要數字不會爆掉或歸零,粗一點的解析度並不會顯著影響收斂。
而且 BF16 有一個 FP16 沒有的巨大優勢:不需要損失縮放。因為它的範圍和 FP32 一樣,梯度再小也不會下溢到 0,再大也不會超過上限。這讓訓練流程大幅簡化,也減少了因為損失縮放調不好而導致的各種奇怪問題。這正是為什麼在大型語言模型訓練中,BF16 幾乎成了事實標準。
TF32:藏在硬體裡的折衷方案
TF32 是 NVIDIA 在 Ampere 架構中引入的格式,它的定位很微妙。它的儲存格式其實還是 32 位元,但在送入 Tensor Core 做矩陣乘法時,硬體會自動把尾數截斷到 10 位,只保留 8 位指數。換句話說,TF32 是「計算時降精度、儲存時保精度」的硬體級混合精度。
它的好處是:使用者幾乎不需要修改程式碼,只要把 matmul 的精度設定設為 "high",就能在 A100 上獲得比 FP32 快數倍的矩陣運算,同時因為指數範圍完整,完全不用擔心溢位問題。這種「無痛加速」讓 TF32 成為許多研究團隊的預設選擇。
FP8:下一站的邊界在哪裡
到了 H100 世代,FP8 開始進入實用階段。FP8 有兩種變體:E4M3(4 位指數、3 位尾數)與 E5M2(5 位指數、2 位尾數)。前者的精度稍好、範圍較窄,適合前向傳播;後者的範圍較大,適合反向傳播的梯度。
FP8 的技術難度比 FP16 高得多。因為精度只剩 1 位有效數字左右,傳統的損失縮放已經不夠用,需要更精細的「per-tensor scaling」或「per-block scaling」——也就是為每個張量、甚至每個區塊單獨計算縮放因子。Transformer Engine 這類工具就是在處理這些細節。
目前 FP8 主要用於大型語言模型的訓練與推論,而且通常搭配 BF16 作為主權重。它的極限在哪裡,還需要更多實證研究來確認。
六、實戰:用 PyTorch 寫一段混合精度訓練
講了這麼多理論,最後還是要回到可執行的程式碼。PyTorch 從 1.6 版開始提供 torch.cuda.amp,把混合精度的複雜度封裝成兩個物件:autocast 與 GradScaler。以下是典型的訓練迴圈寫法:
import torch
from torch.cuda.amp import autocast, GradScaler
model = MyModel().cuda()
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
scaler = GradScaler()
for epoch in range(num_epochs):
for inputs, targets in dataloader:
inputs = inputs.cuda(non_blocking=True)
targets = targets.cuda(non_blocking=True)
optimizer.zero_grad(set_to_none=True)
前向傳播:自動把適合的運算轉成 FP16
with autocast(dtype=torch.float16):
outputs = model(inputs)
loss = criterion(outputs, targets)
反向傳播:先放大 loss,再計算梯度
scaler.scale(loss).backward()
更新前先把梯度縮回原尺度,並處理 inf/NaN
scaler.step(optimizer)
scaler.update()
這段程式碼有幾個值得注意的細節。首先,autocast 只是一個上下文管理器,它會根據內建的白名單與黑名單,自動決定每個運算要用 FP16 還是 FP32。像矩陣乘法、卷積會走 FP16;而 softmax、layer norm、loss 計算則維持 FP32。你不需要手動 cast 任何張量。
其次,scaler.scale(loss).backward() 這一行同時做了兩件事:把 loss 乘以當前的縮放係數,然後執行反向傳播。接著 scaler.step(optimizer) 會檢查梯度中是否有 inf 或 NaN:如果沒有,就把梯度除以縮放係數,然後執行 optimizer 的更新;如果有,就跳過這一步,並在 scaler.update() 時把縮放係數減半。
如果你用的是 BF16,程式碼會更簡單,因為不需要 GradScaler:
for inputs, targets in dataloader:
optimizer.zero_grad(set_to_none=True)
with autocast(dtype=torch.bfloat16):
outputs = model(inputs)
loss = criterion(outputs, targets)
loss.backward()
optimizer.step()
這就是 BF16 的最大魅力:少了一個 GradScaler,就少了一整類潛在的 bug。對於剛開始嘗試混合精度的人來說,BF16 其實是更好的起點,前提是你的硬體支援(Ampere 架構以上)。
硬體層面的加速:Tensor Core 到底在做什麼
很多人以為混合精度的加速來自「位元數減半所以記憶體頻寬減半」,這只是其中一部分。真正的大頭來自 Tensor Core。
Tensor Core 是 GPU 內部的特殊運算單元,它能在一個時鐘週期內完成 4×4 矩陣的乘加運算,而且內部的累加器是 FP32 精度。在 Volta 架構上,一顆 Tensor Core 每個週期可以做 64 次 FP16 FMA(fused multiply-add);到了 Ampere 與 Hopper,這個數字又翻了好幾倍。
關鍵在於:Tensor Core 只接受 16-bit(或更低)的輸入,不支援 FP32。這意味著如果你堅持用純 FP32 訓練,你根本用不到 Tensor Core,只能走傳統的 CUDA Core 路徑。這就是為什麼混合精度往往能帶來 2 到 4 倍的實際訓練加速,而不是帳面上的 2 倍頻寬而已。
七、什麼情況下不該用混合精度
混合精度雖然好用,但它不是萬靈丹。以下幾種情況,你可能要特別小心,甚至應該暫時關掉它。
八、結語:16-bit 不是偷工減料,而是精算過的取捨
回到最初的問題:為什麼用 16-bit 也能訓練?答案其實可以濃縮成一句話:因為我們把「需要精確」的部分與「可以容忍誤差」的部分,徹底分開了。
權重更新需要精確,所以我們保留 FP32 主權重;梯度計算需要範圍,所以我們用損失縮放把數值搬進安全區間;矩陣乘法也就是絕大多數的計算量,只需要「大致正確」的方向,所以我們放心地交給 FP16 與 Tensor Core。這不是簡化,而是把不同精度用在最適合的地方——這才是「混合」兩個字真正的含義。
從 FP32 到 FP16、BF16、TF32、再到 FP8,這條演進路線反映的其實是一個更根本的趨勢:隨著模型規模不斷膨脹,我們對「精度」的理解也越來越細緻。不再是「全部用最高精度」,而是「在每個環節用剛剛好的精度」。這種思維不只影響訓練效率,也正在重塑整個 AI 系統的設計哲學。
下次當你看到訓練日誌裡那一行「AMP enabled」時,不妨想想背後這套精巧的機制:主權重的雙重身分、損失縮放的動態平衡、Tensor Core 的硬體級累加,以及無數工程師在精度與速度之間反覆拉扯出來的經驗。16-bit 能訓練,靠的從來不是運氣,而是對數值行為的深刻理解。
```