LLM INT8量化为何失效:异常特征与混合精度分解 简介面向大型语言模型推理部署与量化研究的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.7B8 位模型的困惑度反而比更小的 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(axis1, keepdimsTrue) # 形状 [s, 1] c_w 127.0 / np.abs(W_fp16).max(axis0, keepdimsTrue) # 形状 [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 变成 127scale 恒等于 1量化等于没做而且这个 bug 不会报错只会让精度莫名其妙地差一截。第三个细节是h维的累加顺序。INT8 的 tensor core 通常要求h是 8 或 16 的倍数尾部不足要补零。补零本身无害0 乘任何数都是 0但如果补零发生在缩放之后c_w的计算范围就必须和补零后的实际参与计算的长度一致否则反量化的分母和分子的口径会对不上。这类错位在单元测试里很难暴露只有端到端跑一遍困惑度才会显形。4. 混合精度分解把 0.1% 的维度请出 INT84.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, alpha6.0): # 1. 沿特征维度 h 找异常列: X 的列索引与 W 的行索引共享同一组 h outlier_idx np.where(np.abs(X_fp16).max(axis0) 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 出头的模型换成 INT8QPS 反而下降。原因是这个规模下 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 只认 INT8FP8 在相当长一段时间里没有对应的乘加指令只能靠软件模拟速度反而更慢。这也解释了为什么论文反复强调只研究 INT8——不是没考虑过 FP8而是那一刻的硬件只有这一个能落地的低精度路径。至于 FP8 本身它的指数位多、尾数位少小幅度值精度高、大幅度值误差大与异常值这种大而稀疏的分布并不天然契合真要迁移那套混合精度分解的思路能不能直接搬过去还是个开放问题。另一个被主动放弃的方向是注意力函数本身。注意力计算不涉及参数量化它省不出显存只能省带宽和算力而它的数值特性比线性层更敏感——softmax 之前的分数一旦量化归一化会把误差重新分配。这是一个明确的性价比判断收益小、风险大先不做。同理训练和微调也没有纳入因为训练时异常值随梯度动态变化静态的分离集合会立刻过期而且 INT8 累加与优化器的尺度假设根本不兼容。模型规模量化收益建议小于 2.7B几乎无可能变慢不量化或只做 weight-only2.7B 到 6.7B显存有收益速度中性量化可选重点看显存是否吃紧6.7B 以上显存近 2 倍速度提升混合精度分解基本是必选项100B 以上决定能不能跑混合精度分解异常维度单独 FP16那张表里最该被记住的是第三行和第四行的分界。6.7B 不是一个魔法数字它是相变发生的典型位置而这个位置会随着模型能力的提升而移动——今天在 6.7B 出现的东西明天可能在小模型上也出现因为真正的驱动变量是困惑度而非参数量。所以我判断用量化方案时从不去看参数量这一个指标而是先跑一遍激活统计看异常维度的数量和幅度到了什么水平再决定用哪套策略。从那以后我每次接入一个新的量化路径都强制走一遍激活列最大值直方图 → 分离阈值标定 → 困惑度 零样本双指标配对验证这三步少一步就会在某次扩容时翻车。这套流程不优雅但它把玄学变成了可复现的检查项。希望帮到你。本文还有配套的精品资源点击获取