JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

量化:用更少的位元做更多的事

把權重從 32 位元改成 8 位元,記憶體少四分之三、速度翻倍——本篇教你背後的數學與安全做法。

為什麼位元越少越划算

神經網路裡的每一個權重與激活值,預設都是 32 位元的浮點數(FP32)。這是相當奢侈的精度——大約七位有效數字——而在推論時其中大部分都被浪費掉了。模型量化就是把這些數字用少很多的位元來儲存的手藝,最常見的是 8 位元整數(int8 推論)。好處立竿見影:一個 INT8 值只佔一個位元組而非四個,所以模型在磁碟與記憶體中大約小了 4 倍

變小只是開始。回想第 1 篇所講的,推論常常是記憶體受限(memory-bound)的:晶片花在從記憶體搬數字進來的時間,比真正計算的時間還多。位元組減為四分之一,記憶體流量也跟著減為四分之一,這往往直接換成速度。整數的計算成本也更低——一次 8 位元整數的乘加運算,所耗的能量與矽面積只是 32 位元浮點的一小部分,這正是為什麼硬體加速器(GPU 的 tensor core、NPU、手機的 DSP)跑 INT8 運算能比 FP32 快上好幾倍。

所以位元越少,在體積、頻寬、能耗與原始吞吐量上一次全贏。難處——也是本篇接下來的主題——是要在不毀掉準確率的前提下做到。捨入得太粗糙,模型的預測就會退化;捨入得聰明,往往能把準確率維持在與原模型相差不到一個百分點之內。接下來的章節會給你精確的數學,以及讓激進量化變安全的配方。

量化的數學:縮放與零點

量化的核心,是把一段連續的實數範圍,用一條直線對應到一個小小的、等間距的整數格點上。兩個數字就能完整描述這條線:縮放(scale)(一個整數步長在實數單位下有多大)與零點(zero-point)(哪一個整數恰好落在實數的零上)。這稱為仿射(affine)均勻(uniform)量化,因為每一步的大小都相同,而這個對應只是單純的線性「平移加縮放」。

q = \operatorname{round}\!\left(\frac{r}{s}\right) + z, \qquad r \approx s\,(q - z), \qquad s = \frac{r_{\max}-r_{\min}}{q_{\max}-q_{\min}}

左:量化(實數 → 整數)。中:反量化(整數 → 實數)。右:縮放如何由實數範圍與整數範圍決定。

我們把每個符號都點名一遍。r 是我們想儲存的實數(FP32)值;q 是我們真正存下的整數碼;s 是縮放,也就是一個整數步長在真實世界中的大小;z 是零點,也就是會解碼回實數 0 的那個特定整數。常數 q_minq_max 是該格式允許的最小與最大整數——對有號 int8 而言就是 −128 與 127。第一條式子做量化(實數 → 整數):除以步長、四捨五入到最近的整數,再用零點平移。第二條做反量化(整數 → 實數):把平移還原,再乘回步長。那個小小的 ≈ 就是本篇的全部重點——round() 丟掉了一絲資訊,所以 r 還原回來時是幾乎相等,但不是完全相等。

  1. 選定要涵蓋的實數範圍。假設某個張量的值落在 r_min = −2.5 到 r_max = 4.0 之間,目標是有號 int8,所以 q_min = −128、q_max = 127。
  2. 計算縮放:s = (r_max − r_min)/(q_max − q_min) = (4.0 − (−2.5))/(127 − (−128)) = 6.5 / 255 ≈ 0.02549。每一個整數步長約等於實數單位的 0.0255。
  3. 計算零點:z = round(q_min − r_min/s) = round(−128 − (−2.5/0.02549)) = round(−128 + 98.08) = round(−29.92) = −30。檢驗一下:把 q = −30 解碼得到 s·(−30 − (−30)) = 0,恰好是實數零。
  4. 量化樣本值 r = 1.0:q = round(1.0 / 0.02549) + (−30) = round(39.23) − 30 = 39 − 30 = 9。我們存下單一位元組 9。
  5. 反量化,看看還原出什麼:r̂ = s·(q − z) = 0.02549·(9 − (−30)) = 0.02549·39 ≈ 0.994。
  6. 讀出量化誤差:|r − r̂| = |1.0 − 0.994| = 0.006。這舒舒服服地低於半個步長(s/2 ≈ 0.0127),而半個步長正是捨入在最壞情況下會付出的代價。
張量的實數值被對應到等間距的 int8 格點上——縮放決定各層級之間的間距,零點則固定實數零落在哪裡。

一條鐘形分佈的數值曲線,上面疊著等間距的垂直線,標示出各個整數量化層級。

訓練後量化(PTQ)

訓練後量化(PTQ)是進入這個房間最輕鬆的那道門。你把一個已經用 FP32 訓練好的模型,完全不重新訓練就轉成整數——不需要梯度、不需要標籤、不需要 GPU 時數,往往只要幾分鐘的處理。對權重而言這很直接:它們的值是固定的,所以你只要掃過每個權重張量,讀出它的最小值與最大值,再像上面那樣算出縮放與零點即可。這就把一個普通模型變成了可做 int8 推論量化模型。

激活值就比較棘手了,因為它們的範圍取決於輸入,不讓資料流過就不知道。解法是校準(calibration):把一小批具代表性的真實輸入餵過模型,並在每一層的輸出觀察滑動的最小/最大值(或更聰明的百分位數)。這些觀察到的範圍就成為激活值的縮放。通常幾百張不需要標籤的影像就綽綽有餘。

還有一個槓桿大大左右準確率:粒度(granularity)逐張量(per-tensor)量化對整個權重張量只用單一縮放。這很簡單,但當某個輸出通道的權重遠大於鄰居時就很殘忍——那個大通道逼出一個大縮放,於是每個小通道都被粗糙地捨入。逐通道(per-channel)量化讓每個輸出通道(每個卷積濾波器)各有自己的縮放,於是大嗓門的通道不再淹沒安靜的通道。對權重而言這幾乎免費,是標準做法;激活值通常維持逐張量,因為逐通道的激活縮放對硬體來說較難善用。

# Post-training quantization with calibration
model_fp32 = load_trained_model()
model_fp32.eval()

# 1. Insert observers that watch activation ranges
qmodel = prepare_ptq(model_fp32)    # adds min/max observers per tensor

# 2. Calibrate: feed a few hundred REPRESENTATIVE samples
for images in calibration_loader:   # ~100-500 images, NO labels needed
    qmodel(images)                  # observers record running min/max

# 3. Freeze observed ranges into scale + zero-point, convert to int8
int8_model = convert_ptq(qmodel)    # per-channel weights, per-tensor activations
PTQ 流程:觀察、校準、轉換——全程沒有任何反向傳播。

正因為它如此便宜,而且往往只損失不到一個百分點的準確率,PTQ 是你該最先嘗試的做法。只有在 PTQ 的準確率下降到無法接受時,才動用更重的機具。

量化感知訓練(QAT)

當 PTQ 的準確率損失太大時——這在極低位元寬度或嬌貴的模型上很常見——你就改用量化感知訓練(QAT)。其想法是在訓練過程中就模擬量化,讓網路能讓自己的權重去適應它在推論時將面對的捨入,而不是事後才被它伏擊。

QAT 的做法是在前向傳播中插入「假量化(fake quantize)」節點。在每個這種節點上,張量會先被量化、緊接著立刻被反量化——先捨入到整數格點,再對應回一個(此時已略有失真的)浮點數。於是網路看見了它在正式環境會遇到的確切捨入誤差,並學出對這些誤差有韌性的權重。關鍵在於:權重的全精度 FP32 主副本(master copy)會被保留並由最佳化器更新;假量化只負責塑造前向傳播所經歷的東西。這仍然是量化——我們只是趁還能學習的時候先預演一遍。

\frac{\partial L}{\partial x} \;\approx\; \frac{\partial L}{\partial x_q} \quad\text{on } [r_{\min}, r_{\max}], \qquad \text{where we set } \frac{\partial x_q}{\partial x} := 1

直通估計器:讓梯度穿過捨入,彷彿捨入是恆等函數一般。

這條式子要解決的卡點是這樣的。前向傳播用的是 x_q,也就是捨入後的值,但 round() 是一道階梯:在各步之間是平的,在邊界處有垂直的跳躍。在每一段平台上它的斜率恰好為零,而在跳躍處則沒有定義。所以它真正的導數 ∂x_q/∂x 幾乎處處為零。如果我們老老實實地對它做反向傳播,傳到權重的梯度會被乘上零——網路得不到任何學習訊號,訓練會完全停滯。直通估計器(STE)用一個刻意的謊言繞過這點:在反向傳播時,它假裝捨入是恆等函數,對落在可表示範圍內的值令 ∂x_q/∂x := 1(對被截斷在範圍外的值則令其為 0)。用符號寫就是 ∂L/∂x ≈ ∂L/∂x_q——梯度原封不動地直接穿過假量化節點。前向傳播照樣感受到捨入,反向傳播卻仍然能學習

QAT 仍然是用梯度步伐沿著損失曲面下降;直通估計器正是讓這些梯度得以穿過不可微分的捨入、繼續流動的關鍵。

一個彎曲的損失曲面,上面有一連串梯度下降的步伐沿坡而下、朝著最小值前進。

def fake_quantize(x, s, z, q_min, q_max):
    # forward: quantize, then immediately dequantize
    q   = clamp(round(x / s) + z, q_min, q_max)
    x_q = s * (q - z)
    return x_q

# Straight-through estimator (conceptual autograd):
#   forward:  return x_q        (the rounded/clamped value)
#   backward: grad_in = grad_out  # pass straight through, as if identity
#   (gradient is zeroed only where x was clipped outside [r_min, r_max])
一個假量化節點:前向傳播時有損,反向傳播時透明。

QAT 要付出實實在在的訓練時間與一條能運作的訓練流程,所以要審慎地花。它在以下情況才物有所值:低位元目標(INT4 以下)、對準確率極敏感而連半個百分點的下降都計較的模型,以及 PTQ 處理得很糟的架構。如果單純的 PTQ 就已落在容忍範圍內,那 QAT 就是你不需要付出的力氣。

INT8 推論的實務

量化誤差並非均勻散佈在整個網路——只有少數幾層造成了大部分傷害。常見的嫌疑犯有:第一層卷積(它看見的是範圍寬而不均的原始像素)、最後的分類器(細微的 logit 差距就決定了答案,所以粗糙捨入會翻轉預測)、注意力與 softmax 區塊(指數運算對輸入尺度很敏感),以及深度可分離卷積(depthwise)(每個通道很薄,逐張量縮放套不準)。事先知道這些,就知道當 int8 推論令人失望時該先往哪裡找。

務實的對策是混合精度(mixed precision):把網路的絕大部分量化成 INT8,但讓那少數脆弱的層保持 FP16、甚至 FP32。你在那幾層上讓出一點速度與記憶體的好處,換回大部分流失的準確率。一個 95% 是 INT8 的模型,幾乎仍能擷取到量化的全部好處。

那要怎麼找出有罪的層?逐層誤差分析。把 FP32 模型與量化模型並排,餵相同的輸入,再逐層比較它們的輸出——用像「訊號對量化雜訊比」或單純的均方誤差這類指標——看看哪裡的偏離急遽飆高。這正是第 1 篇那套剖析(profiling)的心態,只是現在瞄準的是準確率而非延遲:要量測,別用猜的。會回報逐層誤差的工具,能讓你精準鎖定該把哪些層調回較高精度(參見模型剖析PTQ 工具)。

量化之後,務必在保留集(held-out)資料上重新檢查像精確率(precision)與召回率(recall)這類任務指標——即使平均誤差很小,仍可能移動決策門檻。

一條精確率-召回率曲線,說明如何量測模型在準確率上的取捨。

  1. 先量化,再在保留集上量測端到端的準確率——絕不盲目信任轉換的結果。
  2. 若準確率下降,就做逐層誤差分析,把最糟糕的元兇排出名次。
  3. 把名列前茅的元兇(通常是第一層卷積、最後的分類器、注意力)保留為 FP16/FP32。
  4. 在動用任何更重的手段之前,先把權重改成逐通道。
  5. 用基於百分位數的範圍截斷來馴服激活值離群點。
  6. 每改一項就重新量測一次,這樣你才知道究竟是哪個修法真正奏效。

選擇你的路徑:PTQ vs QAT、INT8 vs FP16

把一切整合起來,這裡有一道梯子,你只需要爬到非爬不可的高度。每一階都比上一階更花力氣,所以一旦準確率與延遲目標達成,就立刻停手。

  1. 先試 FP16。從 32 位元砍半到 16 位元幾乎是免費的,極少傷及準確率,而且許多加速器能以全速執行它。常常光是這樣就夠了。
  2. 進到搭配良好校準的 PTQ INT8。餵一組具代表性的校準集,然後量測。4 倍的體積大勝與整數硬體的加速,主要就在這一步落袋。
  3. 把權重改成逐通道。如果逐張量的 INT8 損失太多,逐通道的權重縮放通常能以幾乎零成本把大部分救回來。
  4. 加上混合精度。以逐層誤差分析為指引,把那少數敏感的層保留為 FP16/FP32。
  5. 最後才動用 QAT。只有在準確率仍然不足、或你需要 INT4 以下時,才付出量化感知重訓的代價。

請留意這條貫穿的主線:每一階都用你更多的力氣去換模型更多的準確率。訓練後量化幾乎不花工就給你大部分的獎賞,所以它位在梯子的底部附近;量化感知訓練把最後零點幾個百分點買回來,卻要求一次完整的訓練,所以它位在頂端。

讓硬體主導每一個選擇。一種精度唯有在晶片會加速它時才會快——有些 NPU 只支援 INT8、無視 FP16;有些 GPU 偏愛 FP16 與 BF16、卻從 INT8 得不到多少好處;最新的零件則加上了 INT4 或 FP8。把 int8 推論部署到一塊跑它不比浮點快的板子上毫無意義,付出 QAT 去達到一個硬體加速器根本無法執行的精度也是白搭。永遠讓你的目標精度去對齊部署晶片真正會回報的東西——這正是第 1 篇談加速器時的那個重點。

本篇一直默默假設的一件事是:真的有某個東西把你的量化模型跑得很快。那個東西就是執行期(runtime)或推論引擎(inference engine)——而同一張 INT8 計算圖,在某個引擎上可能比另一個快上一倍。下一篇就要打開那個盒子:ONNX、TensorRT,以及把這些量化模型化為真實速度的整套硬體堆疊。