当前位置: 首页 > news >正文

大模型量化加速

最近在研究大模型压缩的各种流派和好用的方法,然后发现模型稀疏分解、剪枝、蒸馏这些虽然学界研究得很多,但实际上效果都比不上量化。因此在这里整理一下对于大模型量化的一些基础。

概念

所谓模型量化,简单来说就是用更低位宽格式来表示模型的权重参数。

在我们学习C或Java的编程入门的时候,我们学过浮点数的表示有单精度的float和双精度的double,前者是用32个比特位来表示一个小数,后者使用64个比特位来表示一个小数。很多时候,我们用单精度就足够了,只有在少数科学计算场景才会需要用到双精度的浮点数。也就是说,在大多数对数字精度不敏感的场景,使用float比使用double节省了一半的存储,而且计算的准确度上也没有影响。

遵循同样的想法,我们发现对于大模型来的参数来说,连32位的单精度格式都有点太多了,完全可以用更低比特位的格式来存储。比如说,现在的发现是,使用16比特的半精度格式(torch.float16),基本不会影响模型推理的准确度。那么剩下来的问题则是:到底可以用多么低的精度格式去表示和存储大模型的权重参数?或者说

  • 对于哪些模型可以用更低的精度格式?
  • 有什么方法可以让大模型的参数用更低比特位去表示?

数值格式

自从本科毕业以后,更准确来说是大二以后,我就基本每怎么再去了解过数值格式这样底层的计算机原理。本来以为我以后也不怎么会用到这些知识,没想到搞AI后还是会被callback。因为大模型量化和数值格式的关系千丝万缕,我还因此重读了一遍CSAPP的第二章(写得真好)。

int

计算机内部对于整数的表示有两种,分别是有符号整数和无符号整数。前者只能表示非负整数,对应数据类型里的uint8, uint16, uint32等,后者则能够表示负数,对应int8, int16, int32。对于无符号整数的表述非常简单,就是最朴素的二进制表示,也就是\(\sum_i^n b_i*2^{i}\),其中\(b_i\)指第\(i\)个比特位是0或者1。而有符号整数则使用二进制补码来表示,也就是最高位为1时表示负数,后面的所有位根据朴素二进制按位取反,然后加1;或者更好的解释是\(-b_n * 2^{n} + \sum_i^{n-1} 2^{b_i}\),其中\(b_n\)指第\(i\)最高位是0或者1。

写成代码就是

def bitstr_to_uint(bstr):return sum([int(bi) * 2**i for i, bi in enumerate(reversed(bstr))])def bitstr_to_int(bstr):return -int(bstr[0]) * 2**(len(bstr)-1) + \sum([int(bi) * 2**i for i, bi in enumerate(reversed(bstr[1:]))])

float

整数的二进制表示相当直观,而小数的表示则没有那么简单了。最直观的小数表示方法是用定点数,也就是固定小数点前后分别能够表示多少位。但这样对于高精度任务,小数位总会不够,而对于低精度任务,小数位总会浪费,因此大家都转向小数点能够浮动的格式,也即是浮点数。早期各家计算机厂商有自己的一套浮点数格式,互不兼容,直到后来IEEE-754标准出来后统一了各家的浮点数格式。

IEEE-754格式定义的浮点数表示以科学计数法位基础:

\[(-1)^s * 2^E * M \]

在二进制表示中,其中\(s\)代表最高的一位,是符号位,\(E\)是中间的指数位,\(M\)是最后的尾数位。指数位和尾数位的数字本身可以用朴素的二进制整数来解释:

\[E = \sum_i^n b_i * 2^i \]

位数位也是用类似的方式解读

\[M = \sum_i^n b_i* 2^{-i} \]

不过要注意的是\(i\)的递增方向与整数相反:整数是从右向左数,而小数是从左往右数,所以1000表示\(2^{-1} = \frac{1}{2}\),1111表示\(2^{-1}+2^{-2}+2^{-3}+2^{-4} = \frac{15}{16}\)

img

IEEE-754标准中,单精度格式用8位表示指数,23位表示尾数,也就是E8M23;双精度格式用11位表示指数,52位表示尾数,也就是E11M52。在2008年的修订版,还定义了半精度格式E5M10和4倍精度格式的E15M112。

img

除此以外,其他精度格式用几位E几位M都没有标准定义,所以又有点回到了早期各厂自定义格式的味道。比如说Google认为E5M10的指数位太少了,能表示的范围过于狭窄,无法表示在训练过程中产生大数字,而相对来说尾数位代表的精细程度没有那么重要,所以推出来了bfloat16,也就是E8M7用于表示模型训练过程中的权重参数。同样,8位的fp8也有E4M3、E5M2、E3M4等各种格式,甚至UE8M0,8个比特位全部表示指数,没有符号位和尾数位,尽可能扩大表示范围。

IEEE-754除了规定每一款格式用几位表示以外,还规定了一些细节,对于不同情况的二进制表示,有不同的解读方式:

规范数
当指数位不是全0或不是全1的时候,浮点数的解读方式为\((-1)^s * 2^{E-bias} * 1.M\),其中\(bias=2^{k-1}-1\),而\(k\)指代指数是的位数,比如单精度的fp32用8个指数位,那么\(bias=2^{8-1}-1=127\);半精度的fp16用5个指数位,那么\(bias=2^{5-1}-1=15\)。这里面其实用了两个trick:加入\(bias\)可以用无符号整数来表示有符号整数得到同时又不需要在电路层面实现二进制补码的逻辑;使用\(1.M\)而不是\(0.M\),可以扩大表示范围。

非规范数
当指数位是全0的时候,浮点数的解读方式为\((-1)^s * 2^{1-bias} * 0.M\)。当指数位全都为0的时候,表示的数字集中在0附近。考虑到实际使用中对于这些0附近的数字会需要更细致的表示,所以IEEE-754标准将$ 1.M$改成\(0.M\),使得表示的数字更加细致;而将\(2^{1-bias}\)改成\({1-bias}\)(而非\(0-bias\))则使得便是范围的更加平滑,而不会出现突变(中间有一大段无法表示)。

无穷大
当指数位是全1,且尾数位是全0的时候,代表(正负)无穷大

NaN
剩下的情况,也就是当指数位是全1,且尾数位不是全0的时候,代表无意义的NaN(Not a Number)

img

上面的数学表达写成代码以后就是

def bitstr_to_float(bstr, e=5, m=10):assert len(bstr) == 1+e+m, 'length of `bstr` should equals to 1+e+m'sign_bit = int(bstr[0])exponent_bits = bstr[1:1+e]mantissa_bits = bstr[1+e:1+e+m]if '0' not in exponent_bits:    # 全1的指数if '1' not in mantissa_bits: # 全0的尾数return float('-inf') if sign_bit else float('inf')else:                     return float('nan')exponent = int(exponent_bits, 2)mantissa = int(mantissa_bits, 2) / (1 << m)bias = 2^(e-1) - 1 if exponent == 0:  # 非规范化数value = mantissa * (2 ** (1 - bias))else:  # 规范化数value = (1 + mantissa) * (2 ** (exponent - bias))return -value if sign_bit else value

量化原理

对于一组高位宽格式的模型权重\(W\),把它量化到低位宽格式\(W_q\)的方法可以见到那描述为下面的公式:

\[W_q = \texttt{clamp}(\texttt{round}(\frac{W-zp}{sc}), q_{min}, q_{max}) \]

其中

  • \(sc\)\(zp\)是两个最重要的量化参数:缩放因子和偏移权重。一般来说,偏移零点\(zp\)相对不常用,可以直接设为0:\(zp\)=0,而缩放因子则

    \[sc=\frac{\texttt{max}(W)}{q_{max}} \]

  • \(q_{min}, q_{max}\)分别是目标低位宽格式所能表示的最小和最大值。比如,对int8格式来说\(q_{min}=-64, q_{max}=63\),对于int4来说\(q_{min}=-8, q_{max}=7\),对于E4M3格式的fp8来说\(q_{min}=-448, q_{max}=448\),而对于E5M2的fp8来说,\(q_{min}=-57344, q_{max}=57344\)
  • \(\texttt{round}\)函数把缩放后的数值取到它最近的可表示数字(如果是量化成整数的话,其实就是取整),而\(\texttt{clamp}\)函数把缩放后依然超过目标位宽格式表示范围的部分截断。

\[\texttt{clamp}(n, q_{min}, q_{max}) = \left\{ \begin{array}{ll}n & q_{min} \leq n \leq q_{max} \\q_{min} & n \leq q_{min} \\q_{max} & n \geq q_{max}\end{array} \right. \]

无论是量化成更低位宽的浮点数还是整数,基本原理都是向上面一样。如果需要把量化后的低位宽数值还原成原来的高位宽数值,我们要做的就是把量化后的数乘上之前的缩放因子:

\[\hat{W} = W_q * sc + zp \]

可以看出来,缩放这一步是可逆的,然而量化过程中还有\(\texttt{round}\)\(\texttt{clamp}\),这两步则是不可逆的。因此,如果不是我们要用的低位宽恰好能完整表示所有高尾宽的权重参数这种理想状况,那么量化过程就是造成损失的。不过,如果模型足够大,那么这些量化损失则是可以容忍的。一般来说,越大的模型对于量化的损失容忍度越高。比如说,像Deepseek-V3这种671B的超级大模型,量化到fp8基本上是看不出损失的,甚至英伟达做的fp4量化,也是看不出明显损失的。值得一提的是,现阶段的计算卡主要还是对int类型支持比较多、比较成熟,连英伟达的A100都不支持对fp8的数据做运算。想要使用f8量化,得用比较新的计算卡,比如英伟达的Hopper系列芯片。而英伟达的Blackwell系列甚至支持fp4的运算。

把权重对称量化成整数形式,用python/pytorch写成代码就是:

import torch
def quant_int_symm(W: torch.tensor, bitwidth=8):'''symmetric quantization to integers, no zero-point'''q_min, q_max = -2**(bitwidth-1), 2**(bitwidth-1) - 1 scales = W.abs().max() / q_maxq_W = torch.clamp((W/scales).round(), q_min, q_max)return q_W, scalesdef dequant_int_symm(W, scales):return W * scales 

种类变体

量化的原理听起来很简单,不过在实践中却有很多细节要考虑。

对称量化 vs 非对称量化

上面的量化原理简单粗暴地设置了偏移零点为0,但其实这在绝大多数情况下都是能用的,因为现在的大模型内部内部都有很多Norm层,把数据变换成以0为均值的正态分布形式,而权重参数的分布也是以0为均值的正态分布。所以使用偏移零点为0的对称量化是合理的。然而,不排除有的权重数据分布就不是正态的,那这时候就需要考虑是不是要使用非对称量化,其缩放因子和偏移零点跟对称量化有所差别。我们做量化所希望的效果就是量化前的最大最小值能够哦去目标精度格式的最大最小值对应得上,也就是:

\[q_{max} = \frac{W_{max}}{sc} + zp \\ q_{min} = \frac{W_{min}}{sc} + zp \]

上面得公式忽略了\(\texttt{round}\)\(\texttt{clamp}\),但原理大差不差。解上面的二元一次方程组,可以得到:

\[sc = \frac{W_{max} − W_{min} } {q_{max} − q_{min}} \\ zp = \frac{q_{min}-W_{min}}{sc} \]

仅权重量化 vs 权重激活量化

之前我们一直在再说对权参数量化,其实在我们也可以对大模型计算过程中产生的激活值(激活函数输出的结果)做量化。这样可以进一步压缩模型计算过程对于显存的需求,还能加快计算核心的运算速度。我们称对模型权重和激活值都做量化的方法为权重激活量化,而只对模型权重量化的方法为仅权重量化。常见的W8A8量化就是说把权重(Weight)和激活值(Activation)都量化到8位的数值,而W8A16则是说只把权重量化到8位,激活值既然用fp16来表示。显然,前者能进一步加速模型的计算过程和减少显存占用,但造成的损失会更大。研究发现,对于权重量化到int8基本不会有影响,但是对激活值量化到int8则会引发显著得模型能力下降。

img

值得注意的一点是,仅权重量化并不能加速计算核心的运算过程。在计算核心内部,要与依然是fp16表示的激活值做运算,量化后的权重要反量化回fp16才行。如此一来,仅权重量化不仅无法加速运算过程,反而还有额外的反量化开销。不过因为把量化后的权重从显存搬运到计算核心的缩短了,所以如果增加的反量化时延比减少的搬运时延要少,那么仅权重量化是很值得做的,尤其是在模型推理的decoding阶段,每生成一个token都要把所有模型权重从显存搬进去计算核心一次,那么仅权重量化就很值得做。

动态量化 vs 静态量化

由于激活值是在模型计算过程中才产生的,所以提前算好的一套缩放因子和偏移零点可能无法很好地适用与所有激活值。因此,在推理过程中根据生成的激活值动态地计算对应地缩放因子和偏移零点,能减少量化造成地损失。然而,动态量化计算参数肯定会造成一定的时延开销,相对来说肯定不如提前算好一组量化参数用于所有激活值的静态量化要快,所以这也是一个trade-off。

per-tensor vs per-token/per-channel vs per-group

在上面的量化公式里,W是一组大模型的权重参数。然而,这一组参数到底是哪一组?我们可以对一个大模型中每层的每一个变换做量化,比如自注意力模块里的\(W_Q,W_K,W_V,W_O\),以及前馈模块里的\(W_{gate},W_{up},W_{down}\)。这些实际上都是矩阵,而以前的卷积神经网络里的卷积核则是3维的张量,所以沿用以前的名字,对这种针对一整个变换的权重参数量化称为per-tensor量化,每一个tensor贡献一套缩放因子\(s\)和偏移零点\(z\)。对一个变换中的每一个channel(权重矩阵中的每一列)做量化,就叫做per-channel量化。相应的,对一个激活值输出的每一个token做量化(激活值矩阵中的每一行),成为per-token量化。per-group量化则是最自由的尺度,可以选取权重矩阵中任意形状的子矩阵来做量化。

具体算法

虽然模型量化的原理听起来简单,的那具体使用的时候还是又不少细节需要考虑,尤其是想要既获得加速收益又不损害模型能力的话,还得在量化前或量化后做一些特殊处理。

SmoothQuant

之前在讨论W8A8的量化时也提到过,对于激活值的量化能比仅权重的量化进一步加速大模型的计算过程,然而也会造成更大的损失。这个源因不仅仅是来自于量化过程中的\(\texttt{round}\)\(\texttt{clamp}\)造成的不可逆信息丢失,更主要的是来自于激活中里出现的离群值(Outliners)现象——大模型中的激活值会出现一些通道(矩阵中的列)的值异常的高,这些值的大小比其他通道的值大成百上千倍,但在所有激活值中占比不到1%。

img

然而,因为这些激活值的存在,如果要做per-tensor/per-token量化,这些激活值会拉高整组数据的最大值,导致缩放因子也变大\(sc=\frac{\texttt{max}(A)}{q_{max}}\),那么量化缩放时\(A_q = \frac{A}{sc}\)激活值以外的其他值都被一视同仁地压缩得很低,这些值之间的细微差异就在缩放过程中被抹掉了。这才是对激活值量化极其困难的原因。下面用简单的代码做了一个演示:只要一组权重中出现了一个数值特别大的通道,那么量化以后的其他通道的数值就都变成0、1、2之类很小的数值,原本的数值之间微妙的大小关系都被抹掉了。

>>> acts = torch.rand([3, 5]); acts
tensor([[0.5931, 0.4814, 0.4263, 0.9391, 0.7637],[0.0723, 0.4516, 0.1161, 0.6724, 0.3735],[0.8186, 0.6950, 0.4087, 0.6330, 0.7640]])>>> quant_int_symm(arr)  # quantization without outliners
(tensor([[ 80.,  65.,  58., 127., 103.],[ 10.,  61.,  16.,  91.,  51.],[111.,  94.,  55.,  86., 103.]]),tensor(0.0074))>>> acts[:, 1] = acts[:, 1] * 100; acts  # make an outliner channel
tensor([[ 0.5931, 48.1401,  0.4263,  0.9391,  0.7637],[ 0.0723, 45.1641,  0.1161,  0.6724,  0.3735],[ 0.8186, 69.5046,  0.4087,  0.6330,  0.7640]])>>> quant_int_symm(arr)  # quantization with a significant outliner
(tensor([[  1.,  88.,   1.,   2.,   1.],[  0.,  83.,   0.,   1.,   1.],[  1., 127.,   1.,   1.,   1.]]),tensor(0.5473))

既然找到了问题的主因,那么解决激活值里的离群值现象导致的问题就成了对于量化算法研究的热门领域,而这其中的SmoothQuant因其简单有效,成为最常用的算法。SmoothQuant的核心思想是对激活值做一次平滑(Smoothing),使得激活值中的离群值变得不那么离群,因此变得更加容易量化。具体的做法是先对激活值矩阵的每一个通道都除以一个数,称为平滑因子,整体值越大的通道除以一个越大的数,这样激活值矩阵的所有数都会整体变小,但离群值变小的幅度会更大,使得离群值与其他值的离群程度变小,因而变得相对更容易量化。就比如说,我们先算出每一个通道的均值作为平滑因子,量化前先让激活值矩阵每个通道除以这个平滑因子:

>>> smooth_factors = acts.mean(dim=0); smooth_factors
tensor([ 0.4947, 54.2667,  0.3170,  0.7482,  0.6337])>>> acts / smooth_factors
tensor([[1.1990, 0.8871, 1.3447, 1.2552, 1.2051],[0.1462, 0.8322, 0.3662, 0.8987, 0.5894],[1.6549, 1.2807, 1.2891, 0.8461, 1.2056]])>>> quant_int_symm(acts / smooth_factors)
(tensor([[ 92.,  68., 103.,  96.,  92.],[ 11.,  64.,  28.,  69.,  45.],[127.,  98.,  99.,  65.,  93.]]),tensor(0.0130))

当然,既然激活值先除以了一个数,那么相对应的权重就得乘上这个数,来确保整个过程数学上的等价性。写成数学公式就是:

\[Y = (X \cdot \texttt{diag}(s)^{-1}) \cdot (\texttt{diag}(s) \cdot W) = XW \]

上面的公式把平滑因子用对角矩阵\(\texttt{diag}(s)\)来表示,还用了计算了矩阵的逆,因此整个平滑过程可以用GPU中的矩阵乘电路来高效实现。从上面的公式我们可以看到,虽然激活值除以平滑因子以后变得容易量化,但是权重矩阵要相应地乘上平滑因子,所以权重矩阵就又出现了离群值。因此,我们地整个思路其实算是把激活值里的离群程度迁移到权重去。

如果我们以每个通道的均值或者最大值来作为平滑因子,那么就相当于把激活中的全部离群程度迁移到权重来了,这么一来激活的量化是变容易了,但权重的量化就又变得极其困难了。所以SmoothQuant引入了一个超参数来控制我们到底迁移多少激活值中的离群程度到权重矩阵中,使得无论激活值还是权重值都相对比较好量化。最后,SmoothQuant定义的平滑因子就是每个通道的最大值的\(\alpha\)次方除以对应权重通道的最大值的\(1-\alpha\)次方:

\[s_j = \frac{\texttt{max}(|X_j |)^{\alpha}} {\texttt{max}(|W_j |)^{1−\alpha}} \]

通常来说,\(\alpha=0.5\)在大多数情况下都够用了。这么一来,通过把一部分离群程度从激活值迁移到权重,使得无论是激活还是权重都能相对比较容易量化,亦即W8A8以后的模型能力损失控制在最低。

img

AWQ

前面所说的SmoothQuant主要把激活值和权重都量化到int8的类型,技能降低权重和激活值对显存的占用,又能减少把激活值和权重从显存搬运到计算核心的时间,还能加速两者在计算核心里的矩阵乘运算。这对于模型推理的prefilling阶段是很好的。不过对于decoding阶段的话,就要另外估量了。因为decoding阶段的计算过程只需要处理单个token的激活值,不像prefilling那样一下子处理输入prompt的所有token,因此decoding阶段的计算量其实并不多,它更多的时间是消耗在把模型权重一遍又一遍从显存搬运到计算核心。因此,想要加速大模型的decoding阶段,主要应该减少把模型权重搬运到计算核心的时延,也就是进一步压缩模型权重的大小。这样分析下来,也许W8A8的效果就不如W4A16、甚至W3A16的效果好了。

然而,不同于int8量化,对权重更进一步的int4的量化,如果不做任何处理的话,模型的能力损失还是很明显的。这损失的根源还是来自激活值里的离群值通道。虽然模型权重本身的分布相当平坦,但是。

因此,激活值里的离群值通道对应的权重通道就是比较重要的权重值。如果这些通道的权重量化损失太大,会导致整个模型的能力损失太大。那么相应的解决思路就是保护这些通道的权重,使他们在量化过程中的损失最小化。这一点最直接的证明就是,如果不对这些通道做量化,用fp16的格式保留他们,那么大模型是基本没有能力损失的。可惜只对部分权重使用fp16格式对于模型的推理速度来说并非最优,对现在的硬件也不友好。不过,我们可以换一个思路每一种跟SmoothQuant相反的思路——在量化前先让这些重要的权重通道乘上一个凸显因子\(s (s>1)\),让这些通道的权重相对于其他通道变得更大,那么在量化过程中这些权重的损失就会相对少一点。当然,为确保整个过程数学上的等价性,相应的激活值也要除以这个凸显因子。如此一来,就做到了对重要权重通道的保护,这也就是AWQ算法的核心思想。AWQ的数学表达其实跟SmoothQuant在形式上是一样的,都是\((X \cdot \texttt{diag}(s)^{-1}) \cdot (\texttt{diag}(s) \cdot W)\),区别是在于内容上:SmoothQuant的重点是对激活的离群通道做平滑,而AWQ的重点是对权重的重要通道做凸显。

img

现在,剩下的问题是怎么确定这个凸显因子了。不同于SmoothQuant有一个简单的经验法则可以直接算出平滑因子,AWQ的凸显因子需要用到一批数据集来搜索出来。

\[s^* = \min\limits_{s} ||(X \cdot \texttt{diag}(s)^{-1}) \cdot \texttt{quant}((\texttt{diag}(s) \cdot W)) − XW|| \]

其中\(s^*\)就是我们希望找到的最优的凸显因子,整个过程就是用一批数据(称为校准数据)喂进大模型里获得一些激活值,然后根据这些激活值来找到最优的凸显因子\(s^*\)使得量化前后的差值最小。

AWQ的思想很简单,效果却相当得好。
img

http://www.sczhlp.com/news/32331/

相关文章:

  • 情头定制网站百度关键词优化首选667seo
  • vi设计理念和设计思路seo查询 站长之家
  • 佛山网站建设哪家评价高网络销售怎么学
  • 百度不收录网站描述seo网络推广培训班
  • 淮安做网站找哪家好怎么查权重查询
  • 物流公司网站模版厨师培训机构 厨师短期培训班
  • 泉州手机网站建设地推接单平台
  • 厦门网站建设哪家公司好淘宝关键词top排行榜
  • 阜阳恒亮做网站多少钱线上推广的方式
  • tomcat网站开发seo网站推广收费
  • 淘宝有WordPress网站搭建吗目前网络推广平台
  • 国内适合个人做外贸的网站有哪些友情链接实例
  • 凡科做网站在百度能看见吗常州百度推广代理
  • 云梦县网站开发网站内部链接优化方法
  • 海口手机版网站建设郑州seo外包阿亮
  • 网站策划包括什么南京seo优化培训
  • 做web的网站优化营商环境心得体会1000字
  • 网站维护流程seo 什么意思
  • 台州自助建站公司培训教育机构
  • 永川做网站的公司外贸网站平台
  • 雄安移动网站线上渠道推广有哪些方式
  • 建站abc登录入口青岛网站权重提升
  • 怎样做网站二维码营销软文范文200字
  • 网站做百度推广有没有效果注册百度推广账号
  • 做网页怎么在网站播放视频网络营销企业网站优化
  • 做淘宝客网站一定要备案吗软文宣传推广
  • 推广型的网站怎么做北京外包seo公司
  • 网站安全认证去哪做关键词搜索优化
  • 一台vps可以做几个网站磁力猫torrentkitty官网
  • 很大气的网站 营销如何制作一个网页链接