为了节约大模型的部署资源,最有效的方法就是“量化(quantization)”,这儿的量化可不是量化投资(开玩笑,🐶)里的量化,是指降低模型参数的精度,即将原本的高精度浮点数(如32位或16位浮点数)转换为更低位数的整数或更低位的浮点数(如8位整数或浮点数),从而减少存储需求和计算时间的方法。想要弄懂量化首先需要理解数据的精度。

从FP32开始

计算机处理数字类型包括整数类型和浮点类型,IEEE-754标准定义了浮点类型数据的存储结构。一个浮点数由三部分组成,以最常见的FP32(Float Point 32)为例:

Sign:最高位用1位表示符号位,1表示负数,0表示正数,记为S\mathrm{S}
Exponent:中间8位表示指数位,记为E\mathrm{E}
Mantissa:低位23位表示小数位,记为M\mathrm{M}

【举个例子】
我们拿12.75举例,来看十进制和FP32二进制之间的转换。
首先,分为整数位和小数位,整数位是12,转换为二进制是11001100(因为12=23+2212=2^3+2^2,换底之后=103+10210^3+10^2)。然后是小数位0.75=21+222^{-1}+2^{-2},转化为二进制后就是0.110.11。小数部分的转换也可以用乘2取整数部分,如果整数为1那么该指数位记一次,剩余的小数部分再继续进行判断,一直迭代循环直到小数部分为0。把整数和小数的二进制加起来就是总的二进制转换结果:1100.111100.11,再转换成科学计数指数形式:1.10011×231.10011\times2^3。这儿的指数级数3就是原始指数位,不过再根据IEEE-754的规范,FP32的指数部分要加127偏移,调整为3+127=130,然后再转为二进制,最终指数位为10000010。小数部分10011补齐为23位后得到最终小数位,符号位为0,三个部分拼起来就是

0 10000010 100110000000000000000000\ 10000010\ 10011000000000000000000

使用转换工具验证,结果一致。工具的上面把三个部分都表示出来了,复选框打勾表示对应位置二进制数为1,否则为0。

将FP32复原为十进制,那么把上面的转换逆转过来,可以有公式:

x=(1)S×2g(E)127×g(1.M)x=(-1)^{\mathrm{S}} \times 2^{g(\mathrm{E})-127} \times g(1.\mathrm{M})

其中gg表示二进制转十进制。
那么回到12.75的例子,10000010转十进制就是130,1.10011转十进制为1+21+24+25=51321+2^{-1}+2^{-4}+2^{-5}=\frac{51}{32}。所以最终x=2130127×5132=514=12.75x=2^{130-127}\times \frac{51}{32}=\frac{51}{4}=12.75
FP32搞清楚,FP16、FP64类似,只是指数位和小数位的长度不一样:

类型 符号位长度 指数位长度 小数位长度 偏移
半精度FP16 1 5 10 15
单精度FP32 1 8 23 127
双精度FP64 1 11 52 1023

所以12.75的FP16的表示为:

0 10010 10011000000\ 10010\ 1001100000

FP64的表示为:

0 10000000010 10011000000000000000000000000000000000000000000000000\ 10000000010\ 1001100000000000000000000000000000000000000000000000

不同精度的优劣

对比FP32和FP64,其明显优势是:

  1. 节约储存空间。由于FP32只占32位,是FP64的一半,所以也只会占用一半的存储空间。在相同的GPU下,能装的模型参数的就变多了,或者相同模型参数训练/推理时的batch_size可以更大。
  2. 提高计算速度。同样的两个数计算,位数少了,计算量也就相应降低了,所以算得更快。

那么,其劣势也很明显:

  1. 精度问题。小数位数少了,能表达的精度(小数点后的位数)就降低了。比如同样是表示1/3,FP16、32、64转换出的精度分别如下:

    精度越高,数据表示和计算越准确,模型训练拟合出来的参数更精准,这个需要看我们对具体模型精度的要求。

  2. 溢出问题。指数位少了,那么能表示的整数范围也变小了,有些很大的整数可能就无法表示了。能表示的范围见下图,比如FP16就只能表示[-65504,65504]之间的数。

混合精度

既然FP32和FP16长短各有优缺点,那我们就可以采取混合使用的方法,在模型训练的不同步骤使用不同的精度:

  1. 把神经网络权重参数由初始化的FP32转为FP16;
  2. 用FP16进行前向和后向计算,并进行梯度计算;
  3. 把FP16的梯度转为FP32;
  4. 使用FP32的梯度和学习率learning rate相乘;
  5. 使用FP32更新网络权重,得到FP32的更新后的权重。

以上步骤不断循环进行。简单来讲就是使用梯度更新权重的时候用FP32,因为梯度乘上学习率后一般数值都比较小,使用FP32能防止精度不够。

混合使用精度的时候,有一个"损失缩放"的技术,在反向计算前,将得到的损失扩大一个倍数,避免数据太小精度不够变为0了,扩大后在FP16可表达的范围内,反向计算后,再把梯度缩小同样的倍数,确保最后数值是对的。

混合精度可以让模型训练的速度更快,资源占用更低,同时保持一个不错的准确度。

BF16、TF32

FP16的指数位只有5位,小数位10位,能表示的整数范围有点小,于是谷歌为了深度学习特别是他们的TPU定义了一种新的格式Brain Floating Point 16,简称BF16。和FP16比,总长度都是16位,只是把指数由5位变为了8位(和FP32一样,能有其相同的整数范围),小数位数缩短到了7位。

英伟达根据其GPU的需要定义了TF32(Tensor Float 32),指数位8位(和FP32、BF16一样),小数位10位(和FP16一样,比BF16长),其实就是比BF16多了3个小数位。虽然叫TF32,但是并不是32位的,有点‘高效’32位的意思,范围搞到FP32,同时精度保持FP16。

INT8量化

上面介绍完浮点数的类型和精度,我们可以知道使用FP16可以比FP32消耗的显存缩减一半,同样的我们可以进一步降低精度,使用比如FP8来继续减半显存的消耗,不过这样带来的模型输出准确性的降低可能是灾难性的。为了缓解这种准确性的降低同时可以进一步降低资源消耗,引入了所谓的量化技术,我们来看一下Int8的量化。

量化的核心思想就是放缩+rounding,将原始的数值通过放缩+rounding转换成一个范围内的整型数值(INT)来表示原数值。INT8就是转换到[-128,127]范围内。公式表示:

xq=Clip(Round(xfscale))x_q = \mathrm{Clip}(\mathrm{Round}(x_f*\mathrm{scale}))

其中 Round 表示四舍五入都整数,Clip 表示将离群值(Outlier) 截断到 [-128, 127] 范围内。对于 scale 值,通常按如下方式计算得到:

scale=127/max(xf)\mathrm{scale}=127/\max(|x_f|)

反量化的过程为:

xf=xq/scalex_f^\prime = x_q/\mathrm{scale}

以下是一个INT8量化与反量化的例子:

当进行矩阵乘法时,可以通过组合各种技巧,例如逐行或逐向量量化,来获取更精确的结果。举个例子,对矩阵乘法,我们不会直接使用常规量化方式,即用整个张量的最大绝对值对张量进行归一化,而会转而使用向量量化方法,找到 A 的每一行和 B 的每一列的最大绝对值,然后逐行或逐列归一化 A 和 B 。最后将 A 与 B 相乘得到 C。最后,我们再计算与 A 和 B 的最大绝对值向量的外积,并将此与 C 求哈达玛积来反量化回 FP16。

同时,上述的量化方式如果遇到离群点将会导致scale变得很小,那么大部分正常点转换后就会集中在一坨或者一个点上,反量化后会有非常大的误差。那么一种方法是在矩阵运算的时候,先根据异常值进行分解,包含异常值的那部分矩阵进行正常的FP16/FP32的运算,无异常值的部分进行量化运算,之后再把结果聚合起来。具体的一些介绍可以看这篇blog

INT8量化后的模型大小会变成FP16的一半,其真实计算时的参数数据又可以反量化到FP16/32,保持了计算精度。在此基础上,想进一步再降低资源消耗,那么可以进行INT4的量化,道理和INT8一样,只不过放缩的表示区间缩到了[-8,7]。