 8位量化:异常值与混合精度分解实战)
简介面向自然语言处理研究者、大模型推理部署开发者及毕业设计选题学生的中文版论文聚焦数十亿参数Transformer在推理阶段显存占用过高的问题系统讲解LLM.int8()的8位矩阵乘量化方案。压缩包仅含1个PDF文件约4.01MB正文围绕矢量量化、混合精度分解、6.7B规模后出现的系统性异常值特征以及OPT、BLOOM等模型在WinoGrande、HellaSwag、PIQA、LAMBADA上的零样本实验结果展开并配有原理示意与精度对比分析。已有215人学习适合用作大模型压缩与高效推理方向的入门研读材料也可为课程设计、实验报告和工程部署中的显存优化提供参考。1. 8 位推理为什么在 6.7B 处突然崩掉先看清这份资源解决的问题把 175B 参数的模型塞进一台普通显卡服务器靠的不是更聪明的网络结构而是一份讲清楚了「哪些数不能压、哪些数可以压」的量化方案。这份 LLM.int8() 中文版论文的价值不在公式推导而在它把一个工程事故讲透了语言模型做 8 位量化在 6.7B 参数附近会突然失效——困惑度从个位数跳到几十零样本准确率掉到随机水平而模型权重看上去一切正常。作者没有停在「我们的方法更好」而是把失效原因定位到了隐藏状态里极少数幅值高达 20 倍的异常特征维度上然后针对这 0.1% 的维度单独走 16 位矩阵乘。对做毕业设计、要在一台机器上跑通大模型推理的人来说这份材料的可复现性很高方法只依赖线性层凡是用了线性层的自回归模型都能套用对已经在上量化的熟手它的价值在于给出了一条明确的判据——你的模型有没有跨过那个相移点。2. 把论文拆成两张图矢量量化与混合精度分解到底在算什么2.1 从 absmax 到逐行量化缩放常数放在哪一层决定了精度上限先要理解量化误差是怎么产生的。8 位整数的表示范围是 [-127, 127]要把一个 FP16 张量塞进去必须先把它的动态范围压到这个区间压缩用的分母就是缩放常数。最常用的做法是 absmax取整个张量绝对值的最大值用 127 除以它得到缩放因子。这个做法的问题是它是一个全局操作张量里只要有一个值特别大分母就被它撑大了其余 99.9% 的正常值被压到很窄的量化格子里大量小数直接被舍入成 0信息就没了。这正是论文里那句「量化分布的范围太大大多数量化格子是空的」的工程含义。改进方向很清楚把缩放常数拆细不要一个张量一个而是一行一个、一列一个。矩阵乘法本身可以看成一串独立的内积隐藏状态 X 的每一行一个 token 的向量和权重 W 的每一列做内积得到一个输出元素。既然内积是独立的那么完全可以让 X 的每一行有自己的缩放常数 c_xW 的每一列有自己的 c_w。反量化的时候把输出矩阵按外积 c_x ⊗ c_w 乘回去就恢复了。这就是矢量量化也是 LLM.int8() 的第一部分。它在 2.7B 参数以内可以把精度损失压到几乎为零但再往上就顶不住了——因为异常值出现在特征维度列上而逐行量化是沿着序列维度行做归一化的方向刚好错开。下面这段代码用 numpy 把这套流程走一遍目的是让你看清缩放常数在哪个位置生效、外积反量化是怎么还原的而不是给你一个能上生产的实现。import numpy as np def quantize_absmax(x): # 单个张量一个缩放常数一个离群值会拖垮整张量 scale 127.0 / np.abs(x).max() x_int8 np.clip(np.round(x * scale), -127, 127).astype(np.int8) return x_int8, scale def quantize_vectorwise(x, axis): # 按行(axis1)或按列(axis0)各自算缩放常数 max_abs np.abs(x).max(axisaxis, keepdimsTrue) scale 127.0 / np.maximum(max_abs, 1e-8) x_int8 np.clip(np.round(x * scale), -127, 127).astype(np.int8) return x_int8, scale # scale 形状为 (rows,1) 或 (1,cols) # 模拟一次 int8 矩阵乘X(4x8) W(8x16) rng np.random.default_rng(0) X rng.normal(0, 1, (4, 8)).astype(np.float16) W rng.normal(0, 1, (8, 16)).astype(np.float16) # 人为植入一个异常值幅值是正常值的 40 倍 X[0, 3] 40.0 X_i8, cx quantize_vectorwise(X, axis1) # 每行一个常数 W_i8, cw quantize_vectorwise(W, axis0) # 每列一个常数 # int32 累加避免中间结果溢出 out_i32 X_i8.astype(np.int32) W_i8.astype(np.int32) # 反量化外积 cx(4x1) * cw(1x16) - (4x16) out_fp16 out_i32.astype(np.float16) / (cx * cw) ref X.astype(np.float32) W.astype(np.float32) print(相对误差:, np.abs(out_fp16 - ref).max() / np.abs(ref).max())代码里有两个关键点值得盯住。第一quantize_vectorwise的axis参数决定了缩放常数的粒度X 按行、W 按列方向不能搞反搞反了就是论文里说的「方向错开」异常值照样毁掉精度。第二反量化那一步是除法而不是乘法因为量化时乘了 scale还原时要除回去用外积是为了让每个输出元素对应到唯一的一对 (c_x[i], c_w[j])这一点在多卡切分权重的时候尤其容易写错。参数上真正要调的只有一处如果你把np.float16换成np.float32做参考对比会发现相对误差从 1e-2 量级降到 1e-3 量级说明误差主要来自累加后的舍入而不是量化本身。这也解释了为什么论文坚持输出累加到 int32 再反量化——中间用 int8 累加会直接溢出。2.2 混合精度分解0.1% 的异常维度为什么要单独走 16 位矢量量化解决不了的问题必须换思路。论文的观察是模型大到一定程度后隐藏状态里会出现极少数幅值巨大的特征维度它们的分布极其系统性——在 6.7B 模型上一条 2048 token 的序列里能找出约 15 万个异常值但这些异常值只集中在 6 个隐藏维度上。把这些维度置零top-1 的 softmax 概率质量从约 40% 掉到约 20%验证困惑度涨 600% 到 1000%而随机删掉同样数量的维度概率只掉 0.02% 到 0.3%困惑度涨约 0.1%。差距是三个数量级。既然这 0.1% 的维度承载了这么重要的信息就不能让它们和普通值挤在同一套量化格子里。混合精度分解的做法是先扫一遍输入矩阵 X找出所有幅值超过阈值 α 的特征维度记成集合 O把 X 里属于 O 的那些列单独切出来和 W 里对应的行组成一个小矩阵用 16 位精度做乘法剩下的列和行照常用 8 位乘。两路结果最后累加到 16 位浮点输出里。这套分解的工程成本很低因为 |O| 极小——论文实测在 13B 参数以内异常维度的数量不超过 7 个。也就是说多出来的 16 位矩阵乘只占整体计算的千分之一量级额外内存开销约 0.1%。作为对比BLOOM-176B 上整体内存占用降了 1.96 倍仍然保住了接近一半的节省。下面这段代码演示分解的骨架重点看outlier_idx是怎么被挑出来、以及两路结果怎么累加的。import numpy as np def split_outliers(x, alpha6.0, min_ratio0.06): x: (seq_len, hidden) FP16 隐藏状态 alpha: 幅值阈值论文实测 6.0 足够把退化压到接近零 min_ratio: 该维度至少在多少比例的 token 上出现超阈值才认定为异常维度 返回: 异常维度下标数组 over np.abs(x) alpha # (seq_len, hidden) 布尔矩阵 ratio over.mean(axis0) # 每个特征维度上超阈值的比例 return np.where(ratio min_ratio)[0] def mixed_precision_matmul(x, w, alpha6.0): idx split_outliers(x, alphaalpha) if idx.size 0: # 没有异常维度全部走 8 位路径 return quantized_matmul_int8(x, w) # 16 位路径只算异常列 out_fp16 x[:, idx].astype(np.float16) w[idx, :].astype(np.float16) # 8 位路径把异常列置零后走矢量量化 mask np.ones(x.shape[1], dtypebool) mask[idx] False out_fp16 quantized_matmul_int8(x[:, mask], w[mask, :]) return out_fp16三个参数值得逐个说清楚。alpha是判定异常的门槛论文给的 6.0 是实测值——低于这个值会把正常的大数误判成异常16 位路径变宽、内存优势被吃掉高于这个值会漏掉真正的关键维度困惑度立刻回退。min_ratio控制的是「系统性」这个条件它要求异常不是偶发在某个 token 上而是在足够多的 token 上重复出现设得太低会把噪声当成异常设得太高会漏掉真正系统性的维度。第三个隐含参数是判定范围论文是在层级别上统计的要求同一个维度至少在 25% 的 transformer 层里都出现异常才算一个真正的异常维度。这个 25% 的阈值是为了排除小模型里的偶发点在小模型上做分析时尤其要注意别把它调低。2.3 阈值 α6.0 与 |O|≤7 的工程含义这两个数字放在一起看才明白这套方法为什么能落地。α6.0 意味着异常值的判定不看相对大小而看绝对幅值——这有点反直觉但论文的解释是一旦相移发生异常值的幅值是整体抬升的中位数也在涨用一个绝对阈值反而比用分位数更稳。|O|≤7 意味着哪怕在 13B 模型上你切出来的 16 位子矩阵宽度也只有 7而 X 的隐藏维度通常是几千所以这次额外的矩阵乘在时间上几乎不可见。对做复现的人来说这两个数字还有一个用法它们是判断「你的模型到底有没有跨过相移点」的探针。拿一个 1.3B 的模型跑同样的统计你会发现异常维度的数量可能是 0 或者波动很大这时硬上混合精度分解没有意义反而会因为多一次分支判断拖慢速度。论文的建议很实在——6.7B 以下的模型本来就能塞进大多数显卡量化的收益抵不过开销。真正需要这套方案的是那些权重根本装不下的规模。3. 在 transformers 上跑通 8 位加载配置、显存核算与一次前向3.1 环境与依赖版本边界这套方法的开源实现落在 bitsandbytes 后端上transformers 通过BitsAndBytesConfig暴露配置入口。环境上有三条硬边界踩过的人都知道显卡计算能力需要 sm_75 及以上图灵架构之后低于这个门槛 8 位 kernel 根本加载不起来CUDA 版本和 bitsandbytes 的预编译轮子要匹配版本错配的典型表现是导入成功但第一次前向时报 undefined symbolPyTorch 版本不能太老2.0 以下的版本在部分算子分发上会回退到 FP32让 8 位加载看起来「生效了但显存没降」。安装顺序建议固定下来避免依赖解析把版本改乱# 先固定 torch 版本再装量化后端最后装 transformers pip install torch2.1.* --index-url https://download.pytorch.org/whl/cu118 pip install bitsandbytes0.41.0 pip install transformers4.35.0 accelerate0.24.0 # 自检能否正确识别到 8 位算子 python -c import bitsandbytes as bnb; print(bnb.__version__)版本号只是我这边的验证基线不是唯一解。真正要确认的是三件事bnb.__version__能打出来、torch.cuda.get_device_capability()返回的第二个数不小于 5、以及第一次前向不报no kernel image is available。第三条最容易被忽略因为报错发生在模型加载完之后很多人以为是权重问题其实是算子和显卡架构不匹配。3.2 用 BitsAndBytesConfig 打开 int8配置本身只有几行但每一行的默认值都藏着取舍。下面这份是我常用的基线注释里标了每一项的作用import torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig model_id your-org/your-model-7b # 替换成本地权重路径或你自己的模型标识 bnb_config BitsAndBytesConfig( load_in_8bitTrue, # 打开 8 位线性层替换 llm_int8_threshold6.0, # 异常值判定阈值与论文 α 对齐 llm_int8_has_fp16_weightFalse, # 权重是否保留 FP16 主副本训练场景才需要 llm_int8_skip_modulesNone, # 指定不量化的模块名列表 llm_int8_enable_fp32_cpu_offloadFalse # 卸载到 CPU 的部分是否用 FP32 ) tokenizer AutoTokenizer.from_pretrained(model_id, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained( model_id, quantization_configbnb_config, device_mapauto, # 按显存自动切分到多卡 torch_dtypetorch.float16, trust_remote_codeTrue, ) model.eval() inputs tokenizer(量化后的第一句话, return_tensorspt).to(model.device) with torch.no_grad(): out model.generate(**inputs, max_new_tokens32, do_sampleFalse) print(tokenizer.decode(out[0], skip_special_tokensTrue))逻辑上是三步先把配置对象声明出来from_pretrained在加载权重时按配置把每个nn.Linear替换成 8 位线性层最后正常调用generate。device_mapauto会先用空权重跑一遍前向估算每层的显存占用再决定切分方案所以首次加载会比后续加载慢一些这是正常现象。参数里最需要盯的是llm_int8_threshold。它的语义不是「大于 6 的值才量化」而是「幅值超过 6 的特征维度被判定为异常走 16 位分解路径」。调小到 3.0 会让更多维度走 16 位显存和速度都会退调到 10.0 以上则会漏判长文本生成时容易出现重复和逻辑断裂——这种退化很隐蔽短 prompt 测不出来一定要用几百 token 的续写去验。3.3 显存账怎么算为什么 175B 仍然需要多卡很多人对 8 位量化的预期是「显存直接除以 2」实际算下来会发现降幅只有 1.9 倍左右有时更少。原因有三个嵌入层和输出头通常不参与量化它们占的参数比例在中小模型上不小8 位线性层仍然需要保存反量化的缩放常数这部分是 FP16最重要的是激活值没有量化序列长度一大激活占用会顶上来。论文给的实测数据是 BLOOM-176B 上内存占用降 1.96 倍这和理论上限 2 倍之间的差额就是上面三项吃掉的。落到实操可以用下面这个粗略公式先估一遍再决定要不要多卡组成计算方式说明线性层权重参数量 × 1 字节8 位占总参数约 95%嵌入层与输出头参数量 × 2 字节默认不量化FP16缩放常数约 线性层参数量 / 隐藏维度 × 4 字节每行一个 FP32 常数量级很小激活峰值batch × seq_len × hidden × layers × 2 字节与序列长度线性相关最容易失控KV cachebatch × seq_len × layers × 2 × hidden × 2 字节长上下文推理的主要增量一个 7B 模型按这张表算权重约 7GB嵌入和输出头约 1GB激活和 KV cache 视序列长度再吃 2 到 6GB总计 10 到 14GB单张 24GB 卡很宽裕。175B 的账就完全不一样了权重约 175GB再怎么切也不是单机能扛的量论文说的「单台服务器」指的是多张消费级卡拼起来。所以看到「消费级 GPU 可跑 175B」这类说法先问清楚是几张卡、什么卡。3.4 精度对齐验证困惑度与零样本跑分对比量化之后必须验而且不能只看生成结果「通不通顺」。论文用的核心指标是 C4 语料上的困惑度因为它对量化退化极其敏感——退化发生时困惑度会成倍跳而人工看生成文本可能只觉得「有点啰嗦」。第二个指标是零样本准确率跑几个常识推理数据集看是不是掉到随机水平。验证的关键是控制变量同一个 tokenizer、同一段文本、同一批随机种子只切换量化开关。下面这段是困惑度对比的最小实现import torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig def perplexity(model, tokenizer, text, stride512, max_len2048): ids tokenizer(text, return_tensorspt).input_ids.to(model.device) nlls, count [], 0 for begin in range(0, ids.size(1), stride): end min(begin max_len, ids.size(1)) chunk ids[:, begin:end] if chunk.size(1) 2: break with torch.no_grad(): loss model(chunk, labelschunk).loss # loss 是平均负对数似然乘以 token 数还原成总 nll nlls.append(loss.float() * chunk.size(1)) count chunk.size(1) return torch.exp(torch.stack(nlls).sum() / count).item() text open(eval_corpus.txt, encodingutf-8).read()[:200000] print(8bit ppl:, perplexity(model, tokenizer, text))stride和max_len决定了滑窗的重叠方式max_len是单次前向的最大长度受显存限制stride小于max_len时相邻窗口有重叠重叠部分能减少边界处的困惑度虚高代价是重复计算。评估语料建议挑和目标分布接近的中文模型用中文语料别直接拿英文语料去测——困惑度绝对值不可比但同一语料下 8 位和 16 位的差值是可比的。判断标准很简单如果 8 位和 16 位的困惑度差距在 1% 以内说明没跨过相移点量化是安全的如果差距超过 10%基本可以确定是异常值没被正确处理先回去调llm_int8_threshold再检查是不是有层被漏掉了。4. 参数怎么设阈值、模块白名单与吞吐取舍4.1 llm_int8_threshold 调大调小会发生什么这个参数是整套配置里唯一直接影响数值行为的值得单独拿出来讲。它的作用机制是在每次前向时检查隐藏状态里每个特征维度是否有超过阈值的值有就记为异常维度把这些列切出来走 FP16。所以它同时影响精度和速度方向是相反的。调小比如 4.0 或 3.0更多维度被判为异常FP16 路径变宽数值更稳但显存里要多存一份 FP16 的激活切片长序列下这个开销不可忽略速度也会掉。什么时候该调小当你发现 8 位输出在长文本上有明显的重复、掉句、逻辑断裂而短 prompt 正常时八成是阈值太高漏掉了真正的异常维度。调大比如 8.0 或 10.0FP16 路径变窄速度回升但漏判风险上升。什么时候可以调大模型规模在 6.7B 以下、异常值本来就少的时候调大能省一点分支开销或者你的序列很短小于 512异常值的影响还没累积起来。我的做法是先按 6.0 跑基线再分别用 4.0 和 8.0 各跑一遍同一段长文本对比困惑度和生成结果。如果三者差距都在 1% 以内说明这个模型对阈值不敏感那就取大值换速度如果 8.0 明显劣化就老实守在 6.0。提示改阈值只影响推理时的分解行为不会改变已加载的权重。所以可以加载一次模型、多次改配置做对比但注意配置对象是加载时读取的改完要重新构建量化层实际操作中是重新from_pretrained一遍更省事。4.2 llm_int8_skip_modules 与 lm_head 的取舍默认情况下8 位加载只替换nn.Linear嵌入层和输出头不参与。这个默认值有它的道理输出头直接决定 logits量化误差会直接反映到采样分布上而且输出头的参数量在大词表模型上不小量化收益和风险不成比例。但有两种情况需要手动干预。第一种是模型结构特殊某些自定义模块内部包含线性层但没被识别这时需要把它们加进skip_modules保持 FP16否则会报类型不匹配。第二种是你明确想让输出头也走 8 位来省显存——可以试但一定要验困惑度我见过一个中文模型把输出头量化后困惑度涨了 30%因为它的词表里有大量长尾 token量化误差把这些 token 的概率压没了。bnb_config BitsAndBytesConfig( load_in_8bitTrue, llm_int8_threshold6.0, # 显式列出不量化的模块按模块名前缀匹配 llm_int8_skip_modules[lm_head, embed_tokens, score], )列表里的名字要和模型实际的结构名对得上用print(model)看一眼层级名再填。填错不会报错只是静默不生效这是最容易翻车的地方——你以为保护了输出头其实没保护。4.3 batch 与序列长度对 8 位矩阵乘的影响8 位 kernel 的加速比和矩阵形状强相关。矩阵越大8 位相对 FP16 的优势越明显矩阵小到一个阈值以下反量化和分支判断的固定开销会盖过计算本身的节省结果就是「量化了反而更慢」。论文里给的数据是和 FP16 基线相比6.7B 以下的模型量化开销会拖慢推理175B 量级的矩阵乘大约快两倍。这个结论对配置的指导意义很直接场景建议原因参数量 6.7B单卡能装下不量化直接 FP16量化开销大于收益参数量 6.7B~13B显存紧张8 位batch 尽量凑大大矩阵摊薄固定开销参数量 30B多卡8 位 合适 device_map内存收益是主要目标长上下文8k8 位 关注 KV cache激活和 cache 才是瓶颈实际调的时候batch 不要一味求大。8 位路径的显存节省主要在权重上激活是按 batch × seq_len 线性增长的batch 翻倍激活也翻倍很容易在长序列上先炸激活。我的习惯是先固定 batch1 把最长序列跑通再逐步加 batch 直到显存占用到 85%留 15% 给碎片和临时张量。4.4 CPU offload 与多卡 device_map 的组合权重装不下时的兜底方案是把一部分层放到 CPU 内存里用到时再搬到 GPU。这需要打开llm_int8_enable_fp32_cpu_offloadTrue并且配合自定义device_map指定哪些层放 CPU。代价很直接PCIe 带宽成为瓶颈吞吐可能掉一个数量级只适合「能跑起来就行」的验证场景。# 自定义切分显存不够的部分显式卸载到 CPU device_map { model.embed_tokens: 0, model.layers.0: 0, model.layers.1: 0, # ... 中间层省略按实际层名补全 model.layers.30: cpu, model.layers.31: cpu, lm_head: 0, }手工写这个字典很痛苦通常的做法是用device_mapauto让它自己算算出 OOM 之后再从报错信息里看是哪一层炸的手动把那几层挪走。max_memory参数能给出更明确的约束比如max_memory{0: 20GiB, 1: 20GiB, cpu: 60GiB}让它在这个预算内规划。要注意 offload 到 CPU 的部分如果保持 8 位每次前向都要做一次反量化再搬回 GPU开销比直接存 FP16 还高。所以真正需要 offload 的场景往往是把那些层留在 FP16 放在 CPU 上算这又回到了skip_modules的配置上。5. 避坑与排查八位量化上线前必须过的五道坎5.1 五条血泪记录现象、原因、解决第一条加载成功但显存只降了不到 20%。现象是nvidia-smi显示占用和 FP16 差不多怀疑量化没生效。原因通常是两个一是显卡计算能力不达标8 位 kernel 静默回退到 FP16 计算路径权重是 8 位存的但算的时候反量化了显存省了一点、速度没变二是嵌入层和输出头占比大小模型上这部分能占到 20% 以上它们默认不量化。解决办法是先跑torch.cuda.get_device_capability()确认架构再看model.get_memory_footprint()的实际值如果和「参数量 × 1 字节 未量化部分 × 2 字节」对得上说明量化是生效的只是账没算对。第二条短 prompt 正常长文本生成开始重复和跑偏。这是最典型的一类原因是阈值设高了漏判异常维度或者所用版本的实现里没有启用混合精度分解。判断方法很直接把llm_int8_threshold从 6.0 调到 4.0 再跑同一段长文本如果退化消失就是漏判。如果调了没用检查是不是模型的自定义线性层没被识别——有些模型用自己实现的Linear子类不在替换名单里这些层实际上还在跑 FP16看起来「没问题」但一旦别的层被量化混合精度下的数值行为会变得不可预测。解决方式是把这些模块名加进skip_modules保证它们整层保持 FP16。第三条batch 加到 4 就 OOM但显存监控显示权重只占了一半。原因是激活和 KV cache。8 位量化只压权重激活是 FP16 的batch × seq_len 一乘就上去了。解决办法是先用 batch1、最长序列跑一遍记录峰值再按峰值 ≈ 权重 batch × 单条激活反推能开多大 batch另一个方向是开梯度检查点推理场景用不上或者缩短序列、用滑动窗口。真正需要大 batch 吞吐的场景考虑把 KV cache 也量化但那是另一套方案不在这个方法的覆盖范围内。第四条多卡device_mapauto之后某张卡 OOM其他卡还有余量。原因是自动切分是按层粒度的一层必须完整落在一张卡上如果某一层特别大比如某些模型的第一层或最后一层就会出现分配不均。解决办法是用max_memory给出每张卡的预算让它重新规划如果还是不行就手工把最大的那几层单独指定到显存最富余的卡上。这里有个坑手工device_map的 key 必须和模型的模块名完全一致差一个前缀就会报 key 不存在的错而报错信息往往只给出错的 key不告诉你正确的长什么样。第五条想拿 8 位模型做微调训练几步就 loss 爆掉。原因是这套方法只针对推理论文明确说了没有覆盖训练和微调。8 位权重在反向传播时梯度精度不够加上异常值维度的 16 位路径不参与梯度累积训练必然发散。要做参数高效微调得换另一套方案低秩适配加量化基座那一类而且基座量化的粒度和这里不同。这条不是配置问题是方法边界越早认清越省时间。5.2 上线前的自检清单把上面这些收敛成一个可以脚本化的检查流程每次换模型或换环境都跑一遍比出事之后再回滚便宜得多。import torch from transformers import AutoModelForCausalLM def sanity_check(model, tokenizer, ref_pplNone): # 1) 权重实际占用和理论值比对 footprint_gb model.get_memory_footprint() / 1024**3 print(f[1] 权重显存: {footprint_gb:.2f} GB) # 2) 统计被替换成 8 位的线性层比例 total, int8 0, 0 for name, mod in model.named_modules(): if isinstance(mod, torch.nn.Linear): total 1 if Linear8bitLt in type(mod).__name__: int8 1 print(f[2] 8 位线性层占比: {int8}/{total}) # 3) 长文本一致性同一 prompt 贪心解码两次结果应完全一致 ids tokenizer(请用三句话说明什么是矩阵乘法。, return_tensorspt).to(model.device) with torch.no_grad(): a model.generate(**ids, max_new_tokens128, do_sampleFalse) b model.generate(**ids, max_new_tokens128, do_sampleFalse) print(f[3] 贪心解码可复现: {torch.equal(a, b)}) # 4) 与 FP16 基线的困惑度偏差 if ref_ppl is not None: ppl perplexity(model, tokenizer, eval_text) print(f[4] 困惑度偏差: {(ppl / ref_ppl - 1) * 100:.2f}%)第 2 项检查最容易被跳过但它能直接暴露「某些层没被替换」的问题。第 3 项看着像废话实际上能抓出 kernel 的不确定性 bug——正常情况下贪心解码必须完全可复现如果两次结果不同说明有算子在用非确定性的累加顺序这时量化结果的可靠性就要打问号。第 4 项是最终判据偏差超过 5% 就不建议上线。注意自检脚本里的困惑度评估语料要和业务分布接近。用英文语料验一个中文模型得到的偏差数字没有参考价值甚至可能因为英文 token 少而低估退化。6. 进阶用困惑度曲线判断你的模型有没有跨过相移点真正决定这套方法该不该上的不是模型参数量而是模型有没有跨过那个相移点。论文里有个很容易被忽略的观察异常值的出现用困惑度衡量时是平滑的指数过程用参数量衡量时才显得突然。这意味着你可以用小模型的趋势去外推提前判断什么时候必须切到混合精度。具体做法是拿同一系列、不同规模的四五个检查点每个都跑一遍 8 位 absmax 量化和完整 LLM.int8()记录困惑度画两条线。判据如下观察到的现象判断处置两条线几乎重合差距 1%未跨相移点用纯 absmax 或逐行量化即可别引入分解开销absmax 线开始翘起差距 5%~20%接近相移点切到 LLM.int8()阈值守 6.0absmax 线陡升差距 50%已跨相移点必须用分解且验证两条线都陡升不是量化问题回去查加载流程、dtype、tokenizer这张表的价值在于排除误判。我见过有人把一个本来就有问题的 FP16 基线当成量化退化折腾了两天参数最后发现是 tokenizer 的 padding 侧搞错了。所以第一步永远是先把 FP16 基线跑对再谈量化。# 小模型上做相移探测统计异常维度数量随规模的变化 def probe_outliers(model, tokenizer, alpha6.0, min_ratio0.06): hits {} hooks [] def make_hook(name): def hook(mod, inp, out): x inp[0].detach().float() over (x.abs() alpha).float().mean(dim0) # 每个特征维度的超阈值比例 idx (over min_ratio).nonzero().flatten().tolist() if idx: hits[name] idx return hook for name, mod in model.named_modules(): if name.endswith(q_proj) or name.endswith(k_proj): hooks.append(mod.register_forward_hook(make_hook(name))) ids tokenizer(一段两百字左右的中文测试文本, return_tensorspt).to(model.device) with torch.no_grad(): model(**ids) for h in hooks: h.remove() all_dims set() for v in hits.values(): all_dims.update(v) return len(hits), sorted(all_dims)这段探测代码有两个细节要注意。一是只挂q_proj/k_proj这类注意力投影层因为论文的统计就集中在前馈和注意投影上把 LayerNorm 或 embedding 也算进来会污染结果。二是min_ratio在探测阶段要设得比推理时更保守一点宁可多抓几个维度也不要漏掉系统性异常——推理时漏一个可能只影响一点困惑度探测时漏一个会让你误判「这个规模还没跨相移点」。跑完的结果如果显示异常维度数量在不同层之间高度一致比如几乎所有层都是同样的那 5 到 7 个维度说明系统性已经形成这时候才真正需要混合精度分解如果每个层抓到的维度都不一样、数量还在波动那更可能是模型本身没训好或者输入分布不匹配先别急着怪量化。从那以后我每次接一个新模型不管三七二十一先跑一遍这个探测把异常维度的数量和分布记在配置文件的注释里再决定量化策略。这个习惯帮我省掉了好几次「上完量化发现效果不对、回滚重训」的来回。希望帮到你。本文还有配套的精品资源点击获取