目錄
- 什么是量化
- 量化實現的原理
- 實戰
- 準備數據
- 執行量化
- 驗證量化
- 結語
什么是量化
量化是一種常見的深度學習技術,其目的在于將原始的深度神經網絡權重從高位原始位數被動態縮放至低位目標尾數。例如從FP32(32位浮點)量化值INT8(8位整數)權重。
這么做的目的,是為了在不影響神經網絡的精度為前提下,減少模型運行時的內存消耗,提升推理系統整體的吞吐量。
量化實現的原理
量化的實現本質上以一種基于動態縮放的數值運算,因此在量化中,有幾個重要的參數:
- 縮放系數 ( s c a l e ) (scale) (scale):用于表述從高位縮放至低位的縮放系數,如果沒有它,量化就不存在了
- x f x_f xf?:代表輸入的浮點高位值,一般是FP32或者FP64的輸入值
那么,如何計算縮放系數 ( s c a l e ) (scale) (scale)呢?
首先,我們要找出輸入值的最大值,原因是我們要找出整個輸入的的量化范圍,即:從哪里結束量化?因此,你可以使用 a m a x ( x f ) amax(x_f) amax(xf?)來算出 x f x_f xf?的極大值:
a m a x ( x f ) = m a x ( a b x ( x f ) ) amax(x_f) = max(abx(x_f)) amax(xf?)=max(abx(xf?))
找到最大值后,現在你要思考你需要多少位的量化,但通常在此之前,你需要算出你的輸入數據最大可以容納多少位數據:
n b i t = 2 ? a m a x ( x f ) n_{bit} = 2 *amax(x_f) nbit?=2?amax(xf?)
在確定了這個值之后,除以你預期你的位數所能承載的最大數據量,就得到了縮放系數:
s c a l e = n b i t / p o w ( 2 , t b i t ) scale = n_{bit} / pow(2,t_{bit}) scale=nbit?/pow(2,tbit?)
好了,現在你有了兩個量化過程中最重要的參數了,接下來就可以開始正式計算量化的結果了:
x q = C l i p ( R o u n d ( x f / s c a l e ) ) x_q = Clip(Round(x_f / scale)) xq?=Clip(Round(xf?/scale))
首先,我們需要將現有位數的輸入除以我們得到的縮放系數,即得到了目標位數的浮點數據,但別忘了:我們在量化時通常是為了將浮點值操作量化為整數值操作,因此需要將其取整為整數。
那么? C l i p Clip Clip在做什么?因為我們不希望我們量化后結果的范圍超出了目標位數的極大和極小值,因此使用 C l i p Clip Clip來裁切目標值為指定位數的極大和極小值。以INT8為例,則應該是:
x q = C l i p ( R o u n d ( x f / s c a l e ) , m i n = ? 128 , m a x = 127 ) x_q = Clip(Round(x_f/scale),min=-128,max=127) xq?=Clip(Round(xf?/scale),min=?128,max=127)
實戰
說完了原理,我們該如何在ONNX中使用靜態量化呢?
在這里,我們需要使用onnxruntime
庫來完成這個量化操作:
pip install onnxruntime-[target-ep]
其中,target-ep
代表你期望模型在哪個類型的計算設備運行,如:
- CUDA-GPU:則是
pip install onnxruntime-gpu
- DirectML:
pip install onnxruntime-directml
準備數據
在準備數據時,我們不能像以前那樣直接使用Dict[str,ndarray]
的方式來調用靜態量化,而是需要使用校準數據讀取方式來讀取:
from onnxruntime.quantization import (quantize_static, CalibrationDataReader,QuantType, QuantFormat, CalibrationMethod
)
from typing import *
# 創建一個DummpyDataReader類來繼承CalibrationDataReader類
class DummyDataReader(CalibrationDataReader):def __init__(self, calibration_dataset:List[Dict[str,np.ndarray]]):self.dataset:List[Dict[str,np.ndarray]] = calibration_datasetself.enum_data:Any = None# 重載get_next迭代函數def get_next(self):if self.enum_data is None:self.enum_data:Iterator = iter(self.dataset)return next(self.enum_data, None)
接下來我們就可以準備輸入數據了:
# 這里以Hubert Wav2Vec模型進行數據讀取(1,audio_length)
# 采樣率為16000Hz
import numpy as np
audio = np.load("./input.npy")
inputs = [{"feats": audio.astype(np.float32)},
]
執行量化
接下來我們就可以調用onnxruntime
為我們提供的quantize_static
函數了,在我們的實例中,會使用到如下的參數:
- model_input [str]:輸入的模型位置,通常為FP32的ONNX模型權重
- model_output [str]:量化權重保存的位置
- calibration_data_reader [CalibrationDataReader]:我們剛剛創建的校準數據讀取類
- quant_format [enum]:量化的格式,對于我們的實例中,使用
QDQ(Quantize => Dequantize)
,即顯示量化和反量化格式,因為我們不希望自己手動去算量化,對吧?事實證明使用這個模式的情況ONNXRuntime會自動幫你料理 s c a l e scale scale和零值點的計算,以及后續的反量化等。 - activation_type [enum]:指定模型內部相關的激活函數使用什么數據類型來完成計算,在我們的例子中,
QINT8
相對合適,因為Wav2Vec
是從音頻中來提取特征表述,因此有符號比無符號效果會好很多。 - weight_type [enum]:指定模型的權重是以什么數據類型來保存的,通常來說,如果你使用的是
quantize_dynamic
時,ONNXRuntime為了考慮兼容性,默認只會為你量化權重,而不會去管激活函數的量化。 - calibrate_method [enum]:校準方法,指定在反量化階段以什么方式來完成數據校準,ONNXRuntime支持下述的校準方式:
- MinMax:極大極小值,這種校準方式適合基于特征表述的神經網絡,如視覺模型,向量機
- Entropy:基于熵,這種校準方式更適合于不確定性量化,即模型復雜度高,無法直接觀測模型內部數據變化的神經網絡,例如Transformer。適合處理高維度數據,對于我們這次示例中的Hubert十分有效,因為Hubert最終輸出的特征向量大小是 ( b × n × 768 ) (b \times n \times 768) (b×n×768)
- Percentile:基于百分位的數據校準模式,可以顯著降低因量化產生的干擾值,但缺點就是容易***一刀切***,進而丟失數據
- Distribution:基于分布的數據校準模式,當你看到***分布***這兩字兒,你大概心里也應該有個譜了:沒錯,它是基于數據在FP32狀態下的分布狀態來進行對應比例的縮放校準的,而這也正是它的問題所在,即每進行一次校準時都有參考來FP32狀態下的數據分布從而計算出INT8下可能的數據分布,因此對于時延要求不大的任務:如Diffusion可以用這類校準。
接下來我們就可以調用quantize_static()
來執行靜態量化了:
quantize_static(model_input="./hubert.onnx",model_output="./hubert_int8.onnx",calibration_data_reader=reader,quant_format=QuantFormat.QDQ,activation_type=QuantType.QInt8,weight_type=QuantType.QInt8,calibrate_method=CalibrationMethod.Entropy
)
之后你會看到這樣的日志:
Collecting tensor data and making histogram ...
Finding optimal threshold for each tensor using 'entropy' algorithm ...
Number of tensors : 712
Number of histogram bins : 128 (The number may increase depends on the data it collects)
Number of quantized bins : 128
這在說明ONNXRuntime正在計算每個張量的最佳閾值和分布大小。
驗證量化
接下來我們就可以正常讀取這些模型寫模型來看看不用位數下的輸出精度了:
import onnxruntime as ort
# 加載FP32模型
model_fp32 = ort.InferenceSession("./hubert.onnx")
# 加載FP16模型
model_fp16 = ort.InferenceSession("./hubert_fp16.onnx")
# 加載INT8模型
model_int8 = ort.InferenceSession("./hubert_int8.onnx")
# 預測FP32
fp32_result = model_fp32.run(None,input_feed={"feats": audio.astype(np.float32)}
)
# 預測FP16
fp16_result = model_fp16.run(None,input_feed={"feats": audio.astype(np.float16)}
)
# 預測INT8
int8_result = model_int8.run(None,input_feed={"feats": audio.astype(np.float32)}
)# 繪制圖像
import matplotlib.pyplot as plt
fig, ax = plt.subplots(3, 1, figsize=(8,6))ax[0].plot(fp32_result[0][0, 0, :], label="FP32")
ax[0].set_title("FP32 Output")ax[1].plot(fp16_result[0][0, 0, :], label="FP16")
ax[1].set_title("FP16 Output")ax[2].plot(int8_result[0][0, 0, :], label="INT8")
ax[2].set_title("INT8 Output")for a in ax:a.legend()a.grid()plt.tight_layout()
plt.show()
輸出圖像如下:
從圖像也可以很明顯的看出來:INT8的數據分布會更發散,雖然ONNXRuntime已經幫我們完成了反量化這一步驟。而FP16相比INT8則好看許多,雖然在浮點上位上少了很多表示位,但精度依然還是在線的,這也是量化時要權衡的問題:速度和精度,哪個對你的場景更重要?
結語
量化是一把雙刃劍,雖然可以對比原來的推理環境實現大幅度的性能提升,但速度提升的代價就是精度的明顯下降,因此在執行量化操作一定要權衡利弊,是否量化真的對你的場景真的很重要?你的任務是否真的很依賴那點兒因為降低精度而換回來的速度?