☰
LLM INT8量化为何失效:异常特征与混合精度分解
2026/11/3 12:14:51 网站建设 项目流程

简介:面向大型语言模型推理部署与量化研究的LLM.int8()中文版论文,适合NLP方向研究生、算法工程师及做毕业设计的学生阅读。包内仅含1个PDF文件,约4.01MB,为论文中文译版全文,涵盖背景动机、既有量化技术的局限、矢量量化与混合精度分解两部分方法、WinoGrande、HellaSwag、PIQA、LAMBADA等数据集上的零样本实验以及结论展望,并保留原理示意图与OPT模型精度对比图表。目前已有215人学习。读者可据此理清8位矩阵乘法的实现思路:常规量化为何在6.7B参数规模后失效、异常值特征维度如何被隔离到16位计算、为何仍有超过99.9%的数值以8位相乘,也可参照文中方案复现bitsandbytes相关实验,或在消费级GPU上部署OPT-175B、BLOOM等大模型。

1. 一个特征维度,凭什么能毁掉整张 INT8 矩阵乘法

大语言模型推理的显存瓶颈,常被简化成"参数太多"四个字,于是 8 位量化看起来像一道线性题:权重从 16 位压到 8 位,显存减半,精度掉一点点,可以接受。LLM.int8() 给出的结论与这个直觉相反——模型规模爬到 6.7B 参数附近时,网络里会冒出一小撮幅度比同伴大二十倍的特征维度,它们只占全部特征维度的 0.1%,却足以让常规 INT8 量化的困惑度翻上数倍;而把这 0.1% 单独拎出来用 FP16 计算,剩下 99.9% 的数值依旧走 INT8,端到端性能可以一点不掉。

真正决定量化成败的不是平均误差,而是极端值落在哪个维度上。搞清这一点,你才会明白"每张量一个 scale"为什么在十亿参数级别注定走不通,也才能看懂后面那套混合精度分解到底在补哪个洞。

2. 缩放常数为什么会被一个离群值击穿

2.1 absmax 与零点量化:两种把浮点塞进 256 个格子的方式

INT8 能表示 256 个整数,量化要做的就是给浮点数设计一个从连续区间到 256 个格子的映射。最常用的 absmax 量化只干一件事:找出整个张量里绝对值最大的元素,让这个元素正好落在 127 上,其余元素按同一比例缩小。

import numpy as np def absmax_quantize(X_fp16): # X_fp16: 任意形状的 fp16 张量 s_x = 127.0 / np.abs(X_fp16).max() # 一个标量 scale,覆盖整张量 X_i8 = np.clip(np.round(X_fp16 * s_x), -127, 127).astype(np.int8) return X_i8, s_x def absmax_dequantize(X_i8, s_x): return X_i8.astype(np.float16) / s_x # 反量化:除以同一个 scale

参数只有两个:s_x是缩放常数,127是 INT8 正半轴的上界。这套映射等价于"除以无穷范数再乘 127",计算极便宜,GPU 上一次规约加一次逐元素乘就完事,所以绝大多数推理框架的默认量化都是它。

零点量化换了个思路。它先算出张量的动态范围nd_x = 255 / (max(X) - min(X)),再用一个零点zp_x把整个分布平移到[-127, 127]的完整区间上。好处很直接:ReLU 输出这种全非负的分布,在 absmax 下[-127, 0)这一半格子完全浪费,零点量化能把它们用起来,量化误差更小。代价是每次矩阵乘都要处理零点偏移——要么靠一条把零点加进 INT8 数据的专用整数指令,要么把乘法拆成四项展开,后者在有 GPU 上没有对应指令时会明显变慢。

2.2 一个 60 倍离群值如何吃掉 255 个量化格

问题出在 scale 只有一个。假设某个隐藏状态的一行有 4096 个元素,绝大多数落在[-1, 1],唯独一个是 60。absmax 会算出s_x = 127/60 ≈ 2.12,也就是每个量化格代表约 0.0167。回头看那 4095 个正常元素,它们的取值范围只有[-1, 1],映射过去落在[-2.12, 2.12],实际只占用-2, -1, 0, 1, 2这五个格子。剩下 250 个格子空着,而那 4095 个元素全部被压进 5 个离散值里——小于半个格子的值直接归零,本质上是被抹掉了信息。

这就是量化文献里反复讲的"量化箱浪费"。它不是精度掉一点点,而是分辨率被结构性摧毁:一个极端值的存在,让同一张量里 99.9% 的正常值共享了 5 个可用档位。transformer 的矩阵乘法恰恰是把这些被压扁的激活和权重做内积,几千维累加下来,误差不会互相抵消,只会朝同一个方向放大。

行量化(row-wise)是第一个补丁:不再给整张量一个 scale,而是给每一行一个。这样一行里的离群值只污染自己那一行,其他行照常获得 127 个格子。这个改进在 2.7B 以下效果明显,但它有一个隐含前提——离群值是随机散布的。而实际测量出来的分布不是这样。

2.3 从 125M 到 13B:三种方法在哪一档开始崩

论文用一个很敏感但很稳的指标来画这条曲线:在公开语料验证集上测语言建模困惑度。之所以不用零样本准确率做主线,是因为困惑度对量化噪声的响应更连续,能看清"从哪一档开始劣化",而不是等到准确率掉成随机才后知后觉。

把模型从 125M 一路拉到 13B,几种方法的失效点分成三档。absmax 和行量化最早出问题:过了 2.7B,8 位模型的困惑度反而比更小的 8 位模型还差,也就是说量化噪声的增速超过了参数带来的收益。零点量化靠非对称映射撑得更久,因为那些离群值有很强的单边性——要么恒正要么恒负,零点量化正好把这种偏斜分布摊到全量程上,所以在 6.7B 之前它是最强的基线。但到了 6.7B 往上,零点量化同样失效,13B 的 8 位困惑度比 6.7B 的 8 位还糟。

这条"越大越差"的曲线本身就是个信号:问题不是量化算法写得不够精细,而是模型内部出现了某种随规模突变的分布结构,任何单一 scale 的方法都覆盖不了。顺着这个线索往下挖,挖出来的就是异常特征。

3. 矢量量化:把缩放粒度从张量降到内积

3.1 矩阵乘法是一串独立内积,那就给每个内积配一把尺子

矩阵乘法X[s,h] @ W[h,o]的输出元素Out[i,j]是X的第 i 行和W的第 j 列的内积,不同(i,j)之间互不干扰。既然如此,缩放常数也没必要共享:给X的每一行配一个常数c_x,给W的每一列配一个常数c_w,每个内积用自己的尺子量,量化误差就被限制在单个内积内部。

这套做法叫矢量量化(vector-wise),比行量化更细一档。它记账的方式很优雅:反量化不需要逐个内积去除,因为Out[i,j]要除的是c_x[i] * c_w[j],这正好是向量c_x和c_w的外积。

def vectorwise_matmul(X_fp16, W_fp16): # X_fp16: [s, h] 激活; W_fp16: [h, o] 权重 c_x = 127.0 / np.abs(X_fp16).max(axis=1, keepdims=True) # 形状 [s, 1] c_w = 127.0 / np.abs(W_fp16).max(axis=0, keepdims=True) # 形状 [1, o] X_i8 = np.clip(np.round(X_fp16 * c_x), -127, 127).astype(np.int8) W_i8 = np.clip(np.round(W_fp16 * c_w), -127, 127).astype(np.int8) out_i32 = X_i8.astype(np.int32) @ W_i8.astype(np.int32) # INT8 输入, INT32 累加 return out_i32 / (c_x * c_w) # 外积反量化, 输出 [s, o]

三个参数值得盯住。c_x的形状是[s, 1],c_w是[1, o],两者相乘会自动广播成[s, o],正好对上输出矩阵的每个位置,这就是"用外积反归一化"在代码里的样子,不需要任何循环。out_i32必须是 INT32:单个乘积最大是127 * 127 = 16129,超过 INT16 的上界 32767,隐藏维h只要累加三个以上元素就会溢出,所以累加器位宽不是可选项而是硬约束。最后除以c_x * c_w时用的是浮点除法,不在 INT 域里做,避免二次舍入。

这一层改进之后,2.7B 以下的模型基本能做到困惑度不掉。但再往上,矢量量化也顶不住了。

3.2 行缩放救不了列方向的异常值

关键在于异常值出现的方向。隐藏状态X的形状是[序列长度 s, 特征维度 h],而异常值是沿h方向系统出现的——也就是矩阵的列。矢量量化给X的每一行一个 scale,等于沿s方向做了切分。方向不对,努力白费:如果某一行的 4096 个特征里恰好藏着那个 60 倍异常值,这一行的 scale 依旧被它拉爆,整行退化;而如果该行不含异常值,缩放很精确,但异常值本身并没有被更精确地表示,它仍被塞进 127 这个格子里。

这就解释了实验里一个反直觉的结果:行量化和矢量量化在 6.7B 之后失效的方式几乎一样。它们的粒度差别只影响"污染扩散范围",不影响"污染源本身"。要治异常值,必须在特征维度h上做分离,而不是在序列维度s上做细分。

论文里另一个观察是,加上混合精度分解之后,零点量化相对 absmax 的优势消失了,但矢量量化相对行量化的优势还在。这说明被分离出去的那部分确实是不对称分布,而剩下的主体分布是对称的;同时也说明权重侧的列缩放仍然在贡献精度——W的列恰好是输出维度o,给每列一个 scale 是有效的。这两条线索共同指向同一个结论:需要的是"按维度分离",而不是"按行细分"。

3.3 一次反量化的外积:实现层面的关键三行

真正落到 kernel 上,矢量量化最容易被写错的地方不是量化本身,而是反量化的时机与位置。一个常见的错误写法是把out_i32先整体转成 FP16 再除,看起来没问题,但当c_x * c_w跨度过大时,INT32 到 FP16 的转换会在高动态范围下丢尾数,尤其是接近 INT32 上界的累加结果。更稳的做法是保持 INT32 直到最后一次缩放。

第二个细节是c_x的计算必须在量化之前对原始 FP16 张量做规约,不能对已经量化过的结果取 max——后者会因为舍入把 max 变成 127,scale 恒等于 1,量化等于没做,而且这个 bug 不会报错,只会让精度莫名其妙地差一截。

第三个细节是h维的累加顺序。INT8 的 tensor core 通常要求h是 8 或 16 的倍数,尾部不足要补零。补零本身无害(0 乘任何数都是 0),但如果补零发生在缩放之后,c_w的计算范围就必须和补零后的实际参与计算的长度一致,否则反量化的分母和分子的口径会对不上。这类错位在单元测试里很难暴露,只有端到端跑一遍困惑度才会显形。

4. 混合精度分解:把 0.1% 的维度请出 INT8

4.1 什么样的特征算"异常":α=6.0 与三条筛选规则

"异常值"这个词听起来很模糊,实际定义是一组可复现的阈值。给定 transformer 第 l 层的隐藏状态X_l ∈ R^{s×h},把某个固定的特征维度h_i横跨所有层追踪,如果满足三个条件就记为异常特征:至少有一个值的幅度达到 6.0 以上;这个现象出现在至少 25% 的层里;并且出现在至少 6% 的序列位置上。

三个阈值各有用处。α = 6.0是从"把 6 及以上的维度分离出去之后,困惑度劣化基本归零"这个经验结果反推出来的,它不是一个理论常数,而是一个工程上刚刚够用的分界线。25% 这个层比例是为了过滤噪声:小模型里异常值偶尔会在单层蹦出来,那是概率性的;而到了大模型,异常值要么在绝大多数层里整齐出现,要么根本不出现,是系统性的。6% 的序列比例同理,用来排除极个别 token 造成的偶发极值。

真正让人意外的是稀疏程度。一个序列长度 2048 的 6.7B 模型,每个序列里能数出大约 15 万个异常值,但它们只集中在 6 个不同的特征维度上。也就是说,异常不是"很多值很大",而是"极少数维度在所有 token 上一致地大"。对 13B 以内的模型,异常维度数不超过 7 个。

4.2 分离集合 O 的数学形式

既然异常维度这么少又这么稳定,最自然的处理就是把它们单独拎出来算。设O = {i | 在 X 的维度 i 上存在幅度 ≥ α 的值},那么一次矩阵乘法可以拆成两块:O内的维度用 FP16 高精度乘,O外的维度继续走 INT8 矢量量化,最后把两部分输出相加。

def llm_int8_matmul(X_fp16, W_fp16, alpha=6.0): # 1. 沿特征维度 h 找异常列: X 的列索引与 W 的行索引共享同一组 h outlier_idx = np.where(np.abs(X_fp16).max(axis=0) >= alpha)[0] normal_idx = np.setdiff1d(np.arange(X_fp16.shape[1]), outlier_idx) # 2. 主体: 99.9% 的维度走 INT8 矢量量化 out = vectorwise_matmul(X_fp16[:, normal_idx], W_fp16[normal_idx, :]) # 3. 异常维度: 约 0.1% 的维度单独做 FP16 矩阵乘, 结果累加 if outlier_idx.size: out = out + X_fp16[:, outlier_idx] @ W_fp16[outlier_idx, :] return out

索引必须同时作用在X的列和W的行上,因为它们共享同一个特征维度h。这一步写错是最隐蔽的 bug:形状对得上、不报错、结果也像模像样,但精度就是比论文差一截,原因是被分离出去的那几维在权重侧没有对应地切出来,等于拿错位的权重做了两次乘。

异常维度的提取在实现上不是花式索引。虽然|O|很小,但异常在序列维度s上是稠密的——几乎每个 token 都有——所以 gather 出来是一个[s, |O|]的完整稠密块,可以直接丢给一次小 GEMM。计算量比例大约是|O| / o,对 175B 级别的模型,|O| ≤ 7而o在万级,比例在千分之一量级,几乎可以忽略。

4.3 内存账与延迟账:1.96 倍是怎么来的

理论上的收益是 2 倍:FP16 每参数 2 字节,INT8 每参数 1 字节。实测下来对 176B 的多语言模型,内存占用减少 1.96 倍,差的那一点点就是被保留成 FP16 的异常维度和各种非量化开销——嵌入层、LayerNorm 参数、偏置项这些本来就不参与量化,它们在大模型里占比很小但并非为零。

延迟的账要分规模看,这是容易被误读的一段。对于 6.7B 以下的模型,量化引入的规约、舍入、反量化开销会盖过 INT8 矩阵乘的收益,推理反而变慢;而且这个规模的模型本来就塞得进单卡,量化的必要性不高。到了 175B 量级的大型矩阵乘,INT8 版本大约快两倍,因为此时计算完全受限于显存带宽和 GEMM 吞吐,低精度带来的带宽节省能真正兑现成速度。

还有一笔隐性收益值得单独提:能装下本身就是最大的加速。一个原本需要多卡切分才能加载的模型,量化后能塞进单台多卡服务器,省掉的跨卡通信往往是数量级的。

4.4 6.7B 处的相变:不是渐变,是断层

把"受异常值影响的层占比"对模型规模作图,得到的不是一条平滑上升的曲线,而是一条在 6B 到 6.7B 之间几乎垂直的断层:受影响层从 65% 跳到 100%,受影响序列维度从 35% 跳到 75%。这个跳变点与量化方法开始失效的位置精确重合。更细的观察是,跳变之后异常值的中位幅度还在快速攀升。

但换一个横轴,把模型规模换成困惑度,断层就变成了平滑的指数曲线。这个对比信息量很大:决定相变的不是参数量本身,而是模型达到的语言建模能力。参数量只是通往更低困惑度的一条路,训练数据量、数据质量同样在推这根轴。这也解释了一个实践中的现象——同样 7B 的两个模型,量化表现可能差很多,因为它们的困惑度不在同一档。

顺带一个值得警惕的推论:如果异常值的幅度能到 60 这个量级,那么 FP16 训练本身也存在溢出风险。FP16 的最大值是 65535,一次内积里只要几个大值相乘再累加,就越界了。这大概是训练侧必须用 BF16 或 FP32 累加的原因之一。

这些异常维度对模型不是可有可无的噪声。把它们置零,top-1 的 softmax 概率质量从约 40% 掉到约 20%,验证困惑度上涨 600% 到 1000%;而随机删掉同样数量的维度,top-1 只掉 0.02% 到 0.3%,困惑度涨 0.1%。相差三个数量级。所以对这几维做高精度计算不是"补丁",是在保住模型真正依赖的那部分信号。

5. 复现时的五种翻车方式:从精度排查到工程细节

5.1 精度侧的三种翻车

第一种:只量化了权重,激活还是 FP16。现象是显存省了但没省那么多,困惑度几乎不掉,于是误以为"这个方法效果一般"。原因是 LLM.int8() 的收益来自权重和激活同时量化(W8A8),只做 weight-only 时激活占用的显存一点没动,而大 batch 长序列推理下激活的显存占比相当可观。解决办法是先确认自己的量化路径到底覆盖了哪一侧,再看内存曲线,别拿一个不匹配的实现去对标论文数字。

第二种:α 直接照抄 6.0。现象是分离出来的异常维度数量异常——要么是 0,要么几十上百个,困惑度要么不降要么反而升。原因是 6.0 是在特定模型族、特定激活分布上标定出来的经验阈值,换语言、换架构、换训练数据都会漂移。解决办法是在目标模型上先跑一遍统计:把每层激活的列最大值分布打出来,看幅度的自然断点落在哪,再定阈值,并且用困惑度验证,而不是用"看起来像异常值"来验证。

第三种:只测困惑度就宣布成功。现象是困惑度和 FP16 基线几乎重合,但下游零样本任务掉了一两个点。原因是困惑度是平均意义上的指标,对少数维度的精度损失不敏感;而那几个异常维度恰恰主要影响注意力分布的形状。解决办法是至少加一组零样本任务做交叉验证,两个指标同时持平才敢说无损。

5.2 工程侧的两种翻车

第四种:期待所有规模都变快。现象是把一个 1B 出头的模型换成 INT8,QPS 反而下降。原因是这个规模下 GEMM 本身不是瓶颈,量化的规约、舍入、反量化和额外的 kernel 启动才是主要开销;同时小模型显存本来就够,量化没换来任何东西。解决办法是按规模分层决策:小于 6.7B 的模型先别急,真要压显存考虑更粗的 weight-only 方案;把 INT8 的收益留给定居在单卡放不下的那些模型。

第五种:在拼接后的 KV 缓存或变长 batch 上做分解。现象是单条短序列测试完全正常,一上变长 batch 就出现个别请求输出乱码。原因是异常维度的 gather 索引是按当前 batch 的激活统计出来的,不同请求的索引集合不一致;如果实现里为了 kernel 效率把整个 batch 拼成一个大矩阵再统一 gather,就会把 A 请求的异常列套到 B 请求上。解决办法是把索引计算限制在单个请求内部,或者在 batch 维度上取并集并保证所有请求共享同一组列索引——后者在 batch size 较大时会略微增加 FP16 部分的比例,但换来的是正确性。

还有一条元教训:不要用单元测试验证量化。量化 bug 的特征是形状永远对、数值永远"差不多"、错误只在规模上暴露。唯一可靠的验证手段是端到端跑一遍困惑度,并且和同规模 FP16 基线做配对比较。

6. 取舍的边界:INT8 之外还有多少空间

沿着这套机制往下想,最自然的疑问是:既然异常维度只有个位数,为什么不干脆全部用 FP16,或者换 FP8?

答案是硬件约束,而不是算法偏好。GPU 的整数 tensor core 只认 INT8,FP8 在相当长一段时间里没有对应的乘加指令,只能靠软件模拟,速度反而更慢。这也解释了为什么论文反复强调"只研究 INT8"——不是没考虑过 FP8,而是那一刻的硬件只有这一个能落地的低精度路径。至于 FP8 本身,它的指数位多、尾数位少,小幅度值精度高、大幅度值误差大,与异常值这种"大而稀疏"的分布并不天然契合,真要迁移,那套混合精度分解的思路能不能直接搬过去还是个开放问题。

另一个被主动放弃的方向是注意力函数本身。注意力计算不涉及参数,量化它省不出显存,只能省带宽和算力,而它的数值特性比线性层更敏感——softmax 之前的分数一旦量化,归一化会把误差重新分配。这是一个明确的性价比判断:收益小、风险大,先不做。同理,训练和微调也没有纳入,因为训练时异常值随梯度动态变化,静态的分离集合会立刻过期,而且 INT8 累加与优化器的尺度假设根本不兼容。

模型规模量化收益建议
小于 2.7B几乎无,可能变慢不量化,或只做 weight-only
2.7B 到 6.7B显存有收益,速度中性量化可选,重点看显存是否吃紧
6.7B 以上显存近 2 倍,速度提升混合精度分解基本是必选项
100B 以上决定能不能跑混合精度分解,异常维度单独 FP16

那张表里最该被记住的是第三行和第四行的分界。6.7B 不是一个魔法数字,它是相变发生的典型位置,而这个位置会随着模型能力的提升而移动——今天在 6.7B 出现的东西,明天可能在小模型上也出现,因为真正的驱动变量是困惑度而非参数量。所以我判断用量化方案时,从不去看参数量这一个指标,而是先跑一遍激活统计,看异常维度的数量和幅度到了什么水平,再决定用哪套策略。

从那以后我每次接入一个新的量化路径,都强制走一遍"激活列最大值直方图 → 分离阈值标定 → 困惑度 + 零样本双指标配对验证"这三步,少一步就会在某次扩容时翻车。这套流程不优雅,但它把玄学变成了可复现的检查项。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询