资讯动态

大模型2/3/5/6位量化与CPU/GPU混合推理调优

发布时间:2026/10/27 15:23:46 来源:尧图企业网站定制
1. 先搞清楚“二三五六”位到底在解决什么问题1.1 位宽不是美学选择是被显存和带宽逼出来的大模型量化这件事绝大多数人第一次接触都是从 4 位开始的。8 位几乎无损但压缩率只有一半7B 模型压完还有 7GB 出头塞不进 8GB 显存的卡4 位能压到 4GB 上下正好卡在消费级显卡的门槛上。于是 4 位成了事实标准各种工具默认给你 Q4、INT4、W4A16。真到了实际部署环节你会发现 4 位这个“甜点”经常不够用模型换成 30B、70B4 位也塞不下反过来某些对精度敏感的场景4 位掉点明显8 位又太浪费。这就是 2 位、3 位、5 位、6 位这几档“非常见位宽”存在的全部理由——它们不是学术玩具是为了刚好压在你那条显存红线上多一位浪费少一位崩。我把这类方案叫作“二三五六”位量化注意这里说的位宽是档位不是说每个权重真的只占 2 个比特。真实存储是带元数据开销的2 位档实际落在 2.6 bpw 左右3 位档在 3.4 bpw 左右5 位档 5.5 bpw6 位档 6.56 bpw。这个“名义位宽”和“实际 bpw”的差值就是量化方案设计的核心战场后面会一笔一笔算给你看。搞不清楚这个差值你永远算不准自己的模型到底占多少内存也就永远配不对层卸载参数。还有个容易被忽略的点CPU、GPU 混合推理让位宽这件事变得更复杂。纯 GPU 推理你只关心显存够不够纯 CPU 推理你只关心内存够不够、带宽跑没跑满。一旦混合你面对的是两个带宽差了一个数量级的存储层级权重放在哪一侧直接决定了整机吞吐。这时候 3 位和 5 位的取舍跟纯 GPU 场景下的取舍逻辑完全不一样。1.2 混合推理的本质把算力瓶颈换成带宽瓶颈大模型的推理分两个阶段prefill 和 decode这两个阶段的硬件特性完全相反必须先分清。prefill 阶段是把整段 prompt 一次性送进网络矩阵乘法的规模是[batch×seq_len, hidden] × [hidden, hidden]计算密度高属于算力密集。这个阶段 GPU 的几千个计算核心能跑满CPU 只能干看着因为 CPU 的 FMA 吞吐差了一个数量级以上。所以长 prompt 场景下混合推理的体验会很差瓶颈完全压在 CPU 那部分层上而 CPU 恰恰最不擅长这个。decode 阶段是逐 token 生成每次只算一行[1, hidden] × [hidden, hidden]这是一个典型的 GEMV矩阵向量乘算术强度只有 1 flop/byte 左右。这时候无论 GPU 还是 CPU计算单元都在大量空转真正决定速度的是内存带宽——每生成一个 token必须把模型所有非缓存权重从存储里完整读一遍。7B 模型 3 位量化约 3.4GB生成一个 token 就要读 3.4GB如果带宽是 50GB/s理论上限就是 14.7 tok/s实际打个六七折10 tok/s 左右。这个数字跟你用什么 CPU、几个核关系不大只要带宽没跑满核再多也没用。理解这一点之后混合推理的配置逻辑就清楚了decode 阶段的速度 权重总字节数 ÷ 有效带宽GPU 和 CPU 各自的贡献按它们承载的权重比例分配。你不需要让 GPU 和 CPU 真的“同时”算它们是串行的——GPU 算完自己那几十层CPU 接着算剩下的层中间只传一个激活向量几 KB 到几 MB传输开销可以忽略。既然串行总耗时就等于两段之和那么优化目标就很明确把权重尽可能放到带宽高的那一侧。1.3 四档位宽的工程定位对照我把这四档位宽的实际用途整理成一张表你对着自己的硬件预算直接查就行。表里的 bpw 是含元数据的实际值模型体积按 7B 参数估算。档位实际 bpw7B 体积精度表现典型适用场景主要风险2 位约 2.6约 2.3GB明显掉点短问答尚可长文推理易跑偏内存极紧张的嵌入式板卡、玩具级演示重复输出、逻辑断裂3 位约 3.4约 3.0GB轻微掉点日常对话基本可用8GB 显卡跑 13B、16GB 内存跑 7B数学与代码能力下滑5 位约 5.5约 4.8GB接近无损主观体验与 8 位无差显存有余量时的首选高质量档体积比 4 位大 20%6 位约 6.56约 5.7GB几乎无损可做精度基线量化流程的对照基准压缩收益低这张表里最值得说的是 5 位。很多人的直觉是“要么 4 位省显存要么 8 位保精度”5 位卡在中间很尴尬。实际测下来完全不是这样从 4 位到 5 位体积只涨了约 20%但困惑度perplexity的差距能收窄一半以上尤其是在中文和代码任务上4 位常见的“术语漂移”现象在 5 位基本消失。而 6 位更像是给你做对照用的——当你怀疑某个掉点是量化造成的还是模型本身的问题跑一遍 6 位就知道了。提示不要迷信“位宽越高越好”。在混合推理里多出来的 1 位权重可能意味着少放 3 层到 GPU 上反而更慢。位宽选择和层卸载数量必须一起调分开调永远调不到最优点。2. 量化数学拆开看从浮点到 2/3/5/6 位2.1 分组量化、scale 与 zero-point 的基本账量化的本质是把一段连续的浮点数映射到有限个整数格点上。最基础的形式是线性量化q round(x / s) z x s × (q - z)s是缩放因子scalez是零点zero-point。对称量化令z 0只用s非对称量化两个都用。这里第一个工程决策就来了位宽越低越必须用非对称。原因很直白3 位只有 8 个格点对称量化会把 0 放在正中间正负各 3 个格点加一个 0等于浪费了动态范围的一半。而神经网络权重分布通常是以 0 为中心的拉普拉斯分布两侧尾部不对称用非对称量化能多榨出约 10% 的有效精度这在 2 位、3 位档是生死线。第二个决策是分组粒度。整个张量共用一个 scale 显然太粗一个 4096×4096 的矩阵里不同通道的数值范围能差十倍以上共用 scale 会让小通道直接被压成 0。所以按组切分每组独立算 scale。常见组大小是 32、64、128。这里有个必须算清楚的账组越小精度越好但元数据开销越大。假设每组用 fp16 存 scale16 比特组大小 32那每 32 个权重就要摊 16 比特折合 0.5 bpw 的额外开销组大小 128 的话开销降到 0.125 bpw。对 2 位量化来说0.5 bpw 意味着实际存储 2.5 bpw比名义值高出 25%这个代价相当肉疼。所以低比特方案普遍采用两级 scale组内 scale 本身也被量化成低位比如 6 比特或 4 比特再往上用一个大组超块通常 256 个权重共享一个 fp16 的超块 scale。这就是“三级结构”超块 fp16 scale → 子块低位 scale → 权重。这么设计的好处是子块 scale 的开销被压到 6/32 0.19 bpw 甚至更低同时保留了细粒度分组带来的精度。我实测下来的一组经验值2 位档用组大小 16~32 加 4 比特子块 scale3 位档用组大小 32 加 6 比特子块 scale5 位、6 位档对组大小不敏感组大小 32 就够甚至可以用更简单的单级 scale因为高位的容错空间大得多。2.2 3 位和 5 位为什么难写位打包的边界问题2 位、4 位、8 位都好办因为它们都能整除 8一个字节正好装 4 个、2 个、1 个权重取数时用移位加掩码就行SIMD 也好向量化。3 位、5 位、6 位就麻烦了它们和字节边界不对齐必须做跨字节拼接。具体来说3 位量化最自然的打包方式是8 个权重塞进 3 个字节24 比特。取第 i 个权重时需要从这 24 比特里取出偏移3×i处的 3 个比特// 3-bit unpack: 8 weights - 3 bytes, little-endian bit order static inline void unpack3(const uint8_t *p, int32_t *q) { uint32_t v (uint32_t)p[0] | ((uint32_t)p[1] 8) | ((uint32_t)p[2] 16); for (int i 0; i 8; i) q[i] (v (3 * i)) 0x7; }注意3×7 21加 3 等于 24正好不越界这是 3 位打包能对齐 3 字节的前提。5 位的话8 个权重占 40 比特 5 字节同理5×7 35加 5 等于 40刚好。6 位则是 4 个权重占 24 比特 3 字节。// 5-bit unpack: 8 weights - 5 bytes static inline void unpack5(const uint8_t *p, int32_t *q) { uint64_t v (uint64_t)p[0] | ((uint64_t)p[1] 8) | ((uint64_t)p[2] 16) | ((uint64_t)p[3] 24) | ((uint64_t)p[4] 32); for (int i 0; i 8; i) q[i] (int32_t)((v (5 * i)) 0x1F); }这两段代码看着简单但我敢说这是整个量化实现里最容易出错的地方。位序写反、大小端搞混、掩码少一位任何一个失误都会让模型输出变成一坨乱码而且不会报错只会“效果不好”排查起来极其痛苦。我的做法是写一个往返测试随机生成 100 万个整数打包再解包逐一比对跑通了再往下做。这一步花不了十分钟能省掉后面几天的怀疑人生。再往上还有 SIMD 的问题。标量解包的速度在 decode 阶段是完全不够的因为它要和内存带宽抢时间。AVX2 一次能处理 256 比特可以一次解出 32 个 3 位权重先把 96 字节的输入看成 3 组 32 字节用_mm256_shuffle_epi8做字节重排再用移位和掩码把三路数据交错拼起来。这里的细节很琐碎我建议的做法是先写标量版跑通正确性再用标量版的结果做单元测试去校验 SIMD 版不要一上来就写向量化代码。2.3 三级结构的实际字节账以 256 超块为例我把四档位宽在 256 权重超块下的字节构成完整算一遍这张账你要是算不明白就没法预估模型体积也就没法配层卸载。档位权重位子块 scale 位超块 scale合计字节实际 bpw2 位256×2 64B16 组 × 8 位 16B4Bfp16 的 d 与 dmin84B2.6253 位256×3/8 96B12B16 个子块 6 位打包2B110B3.43755 位128B 32B高位平面12B4B176B5.56 位128B 64B高位平面16Bint82B210B6.5625几个细节值得展开。2 位档里子块只有 16 个也就是每个子块覆盖 16 个权重粒度非常细——这是没办法的事2 位容错太差必须靠细粒度来兜底。而 3 位档同样 16 个子块但 scale 用 6 位精度存因为 3 位的动态范围更大scale 本身需要更细的分辨率。5 位档有个特殊结构它不是把 5 个比特连续存而是拆成“低 4 位平面”加“高 1 位平面”。原因是 4 位平面可以完全对齐字节SIMD 处理起来飞快高 1 位平面单独用位运算拼回去这样比连续的 5 位交错打包要快不少。6 位档同理拆成低 4 位 高 2 位两个平面。这是典型的用存储布局换计算效率的工程取舍你在设计自己的格式时值得借鉴位宽不整除字节的时候优先考虑拆平面而不是硬做交错。还有个小坑超块 scale 用 fp16 存取值范围必须覆盖到实际 weight 的最大绝对值。某些模型尤其是训练不充分的微调版本会出现个别通道的极值大得离谱导致 fp16 溢出成 inf一传播就是 NaN。稳妥的做法是量化前先做一遍极值裁剪按 99.99 分位数裁掉尾部或者干脆用 bf16 存超块 scale牺牲一点精度换稳定性。2.4 逐层误差补偿低比特能用的真正原因如果只是简单地做 round-to-nearest2 位和 3 位的结果基本没法用。真正让低比特可用的是逐层误差补偿业内常说的 GPTQ 路线就是这么干的。核心思路不复杂量化某一列权重时产生的误差不要扔掉而是补偿到还没量化的列上。用公式说假设某层权重 W输入激活 X量化第 i 列产生的误差是e W[:,i] - Ŵ[:,i]那么把e按X[:,i]与后续列的相关性分摊到W[:,i1:]上可以抵消一部分输出误差。# 逐列量化 误差补偿简化示意 def quantize_column(W, H_inv, col, qmax, qmin, group_scale): w W[:, col] q quantize_groupwise(w, group_scale, qmax, qmin) # 量化 err w - dequantize_groupwise(q, group_scale) # 误差 W[:, col] dequantize_groupwise(q, group_scale) # 写回量化值 # 把误差按 Hessian 逆的对应行分摊到后续列 W[:, col 1:] - (err[:, None] * H_inv[col, col 1:][None, :]) / H_inv[col, col] return WH_inv是输入激活的 Hessian 逆矩阵用校准集统计得到通常加一个阻尼项H λI保证可逆。这里的计算量不小H_inv是[hidden, hidden]对 4096 隐藏层来说就是 1600 万浮点数逆矩阵计算要几十秒。所以实际实现里会用 Cholesky 分解加分块更新再配一个按激活幅度重排act-order的技巧——把激活值大的列先量化让误差补偿有更多“腾挪空间”。我在 3 位档上的实测对比纯 RTN四舍五入的困惑度比 fp16 高出 1.2 以上加上误差补偿后差距压到 0.4 以内。这个提升是决定性的没有误差补偿的 3 位基本不可用。但误差补偿不是万能的。2 位档里即使做了补偿困惑度还是会掉 20% 以上而且掉点主要集中在注意力层的 value 投影和输出投影上。这就引出了另一个关键技巧混合位宽。不是所有层都必须用同一个位宽注意力层的敏感部分给 5 位甚至 6 位前馈层的宽矩阵给 2 位或 3 位embedding 和输出层保持 6 位。这样一个模型的平均 bpw 可能落在 3.5 左右但精度表现比全 3 位好一大截。这就是为什么你在工具里会看到“S/M/L”这种后缀——小、中、大三种混合策略本质上是不同层用不同位宽的组合。2.5 精度与体积的实测对照下面这组数据是我在某 7B 通用对话模型上跑出来的校准集用的是 256 条长度 2048 的中英混合文本评测集是自建的 200 题问答加 50 道代码题困惑度在维基类语料上算。数字是相对 fp16 基线的差值仅供你建立量级概念。档位体积困惑度增量问答准确率代码通过率主观体验fp1613.5GB0基线基线正常6 位5.7GB0.02-0.3%-0.5%无感5 位4.8GB0.06-0.8%-1.2%无感3 位3.0GB0.42-4.5%-9%长回答偶尔重复2 位2.3GB1.85-18%-31%逻辑断裂明显这组数据最值得注意的不是 2 位掉得惨而是3 位到 5 位之间那段陡峭的曲线。同样是把体积从 3GB 涨到 4.8GB困惑度增量从 0.42 掉到 0.06代码通过率从 -9% 回到 -1.2%。如果你的显存允许5 位带来的体验提升远超过它多占的那 1.8GB。反过来如果你的场景是“短问答、固定格式输出、不需要长推理”3 位的 -4.5% 准确率可能完全可以接受省下的显存能让你把更多层放到 GPU 上速度反而更快。注意困惑度这个指标对低比特量化很不敏感2 位档困惑度只涨 1.85但主观体验已经崩了。评估低比特模型一定要用生成类任务看它输出长文本时会不会自我重复、会不会中途换语言、会不会把数字算错。光看困惑度会严重高估低比特方案的可用性。3. 完整实现一条可复现的 2/3/5/6 位量化流水线3.1 校准集的选择比算法更重要很多人以为量化效果主要取决于算法我实际做下来校准集的影响至少占一半。原因不难理解误差补偿依赖 Hessian 矩阵而这个矩阵完全由校准数据决定。校准数据如果和实际推理分布不一致算出来的补偿方向就是错的越补越偏。校准集的三条原则。第一领域覆盖要匹配。你的模型上线后主要处理什么校准集就得是什么。做客服问答就用客服对话日志做代码补全就用代码片段别拿通用网页文本糊弄。第二长度要够。每条样本长度最好在 2048 token 以上太短的样本统计不出真实的激活分布尤其是注意力层的长程依赖。第三数量不用多。128 到 512 条足够再多边际收益极低反而拖慢量化速度。还有个实操细节校准集的格式要和推理时的 prompt 模板一致。如果你的推理阶段会在开头拼接系统提示词那校准集也必须带上同样的系统提示词否则第一层的激活分布就错位了。这个坑我踩过一次量化完的模型在带系统提示的场景下准确率掉了 7%排查了半天才定位到模板不一致。{text: 系统提示你是一个严谨的技术助手。/系统提示\n用户解释一下分组量化的原理/用户\n助手...} {text: 系统提示你是一个严谨的技术助手。/系统提示\n用户写一个快速排序的 Python 实现/用户\n助手...}3.2 逐层量化与重要性矩阵的落地实现我把整个流程拆成五个阶段每个阶段的产物都可以落盘方便中断续跑和对比调试。**阶段一加载与切分。**按层遍历模型把每个线性层的权重和对应的输入激活收集起来。注意不要一次性把整个模型的激活都留在内存里7B 模型在 2048 长度下的激活缓存能吃掉十几 GB。正确做法是逐层前向算完一层就把这一层的激活喂给量化器然后立刻释放。**阶段二统计 Hessian。**对每个线性层的输入X形状[n_samples×seq_len, in_features]计算H 2/n × XᵀX加阻尼H 0.01 × trace(H)/n × I。阻尼系数是个经验值太小会导致矩阵病态、量化结果数值爆炸太大则补偿效果打折。0.01 是我在多次实验后固定下来的默认值。**阶段三逐列量化与补偿。**按 3.2 节说的流程走列顺序按激活幅度降序排列act-order记录排列索引推理时再按索引把权重还原回原顺序。**阶段四分组与打包。**把量化后的整数按 2/3/5/6 位的规则打包同时生成子块 scale 和超块 scale。这一步要严格按照目标格式的位序来打包完立刻做一次解包校验。**阶段五写入文件与自检。**把所有权重、scale、配置元信息序列化成一个单文件同时生成一份校验和。# 主流程骨架 def quantize_model(model_dir, bits, group_size, calib_path, out_path): model load_model(model_dir, dtypefp16) calib load_calib(calib_path, max_len2048) acts collect_activations(model, calib) # 逐层前向按层释放 for name, layer in model.linear_layers(): X acts[name] H 2.0 / X.shape[0] * (X.T X) H 0.01 * (H.trace() / H.shape[0]) * torch.eye_like(H) H_inv torch.linalg.cholesky_inverse(torch.linalg.cholesky(H)) W layer.weight.clone() W reorder_by_activation(W, X) packed, scales [], [] for col in range(W.shape[1]): scale compute_group_scale(W[:, col], group_size) q quantize_and_compensate(W, H_inv, col, bits, scale) packed.append(pack_bits(q, bits)) scales.append(scale) layer.install(packed, scales, meta{bits: bits, group: group_size}) save_single_file(model, out_path)这里有个必须说的取舍要不要量化 embedding 和输出层。我的建议是embedding 保持 fp16 或至少 6 位输出层同理。这两个张量占总参数量的比例不小7B 模型里大概 8%但它们对精度的影响极大量化到 3 位以下会直接导致词表分布偏移表现为“生成的字是对的但用词很奇怪”。省那点体积不值得。3.3 反量化 kernel 的写法与向量化思路推理时的热路径是反量化加矩阵向量乘这部分性能决定了整个混合推理的吞吐。先给一个能跑通的标量版本。// 3-bit 分组反量化 GEMV 的标量骨架 void gemv_q3(const uint8_t *w, const float *x, float *y, int rows, int cols, int group, const float *scales, const float *mins) { for (int r 0; r rows; r) { const uint8_t *row w (size_t)r * (cols * 3 / 8); float acc 0.f; for (int c 0; c cols; c 8) { uint32_t v (uint32_t)row[(c / 8) * 3 0] | ((uint32_t)row[(c / 8) * 3 1] 8) | ((uint32_t)row[(c / 8) * 3 2] 16); int g c / group; float s scales[g], m mins[g]; for (int i 0; i 8; i) { int q (v (3 * i)) 0x7; acc (s * q m) * x[c i]; } } y[r] acc; } }这段代码的问题是内层循环把“解包”和“乘加”混在一起编译器很难向量化实际跑下来只能到理论带宽的三分之一左右。优化方向有三个。**第一解包和乘加分离。**先把一整组的整数解到栈上的临时数组然后再做向量化的乘加。这样解包部分可以手写 SIMD乘加交给编译器自动向量化或者调用 fp32 的点积指令。**第二解包部分做定点到浮点的批量转换。**3 位整数解出来是 0~7可以一次性转成 8 个 fp32再用一次乘加把 scale 和 min 应用上去y (s × q m) × x拆成s × (q · x) m × sum(x)后者在组内是常数可以预先算好这样每个组只需要一次向量乘加加一次标量修正。这个技巧能把浮点运算量砍掉将近一半。**第三注意对齐和预取。**权重的行长度是cols × 3 / 8字节如果cols不是 8 的倍数行首地址就不会 4 字节对齐SIMD 加载会退化。解决办法是在打包时把每行补齐到 4 字节边界padding 的位填 0反量化时忽略。多占的那点空间换来的对齐收益非常划算。我还试过一个更激进的思路运行时重排。因为 decode 阶段每次只算一行权重的访问模式是严格顺序的完全可以在加载模型时把权重按“同一个 scale 组的 32 个权重连续存放”重排一遍这样反量化时一个组的 scale 只需要读一次缓存命中率高很多。代价是文件不能直接 mmap 共享给其他进程但对单机单进程的部署场景来说完全值得。3.4 量化产物的自检清单量化跑完之后不要急着上评测先做这几项自检能挡住 80% 的低级错误。检查项方法通过标准位序正确性随机整数打包→解包往返100 万次全等反量化一致性单组权重量化→反量化对比原值误差 scale/2全模型前向同一 prompt 分别走 fp16 和量化模型首 token logits 余弦相似度 0.99文件完整性读回文件比对参数量与校验和完全一致中文输出让模型生成 500 字技术说明无重复、无换语言、无乱码第三项特别关键。我一般会写一个小脚本把同一段 prompt 分别喂给原始模型和量化模型导出第一层输出的隐藏状态算余弦相似度。如果低于 0.95说明量化在某一层出了严重问题可以逐层二分定位。这个方法比看最终生成结果高效得多因为生成结果的问题往往是多层误差累积后的表现很难反推是哪一层坏了。4. CPU/GPU 混合推理的配置与调优4.1 显存与内存的分配公式配置混合推理第一步是算清楚三笔账权重、KV cache、计算缓冲。少算任何一笔都会在长上下文时炸显存。权重字节数 参数量 × bpw / 8。但要注意 embedding 和输出层通常是高位宽所以要分开算W_total 主干参数 × bpw_main / 8 词表参数 × bpw_embed / 87B 模型主干约 6.4B 参数词表约 0.6B32k 词表 × 4096 隐藏3 位主干加 6 位词表6.4e9 × 3.4375 / 8 0.6e9 × 6.5625 / 8 ≈ 2.75GB 0.49GB ≈ 3.24GB。KV cache 字节数的计算公式K 2 × n_layer × n_kv_head × head_dim × ctx_len × bytes_per_elem乘 2 是因为 K 和 V 各一份。以 7B 模型为例32 层8 个 KV 头分组查询注意力head_dim 128上下文 8192fp16 存储2 × 32 × 8 × 128 × 8192 × 2 1.07GB。这个数很容易被低估。很多人按 2048 上下文配好显存上线后用户贴一篇长文上下文涨到 8192显存直接爆掉。配显存必须按最大上下文算。如果显存吃紧把 KV cache 量化到 8 位每 32 个元素共享一个 fp16 scale实际开销约 1.06 bpw体积直接减半到 0.54GB精度损失微乎其微。4 位 KV 量化能压到 0.28GB但长上下文下检索准确率会掉要谨慎。计算缓冲是经验值batch1 时大约 300~800MB跟上下文长度正相关batch 大于 1 时要乘以接近 batch 的倍数。保守取 800MB。然后算能放多少层到 GPU可用显存 M 显卡总显存 - 系统预留0.5~1GB n_gpu_layers floor((M - K - buffer) / (W_total / n_layer))举个具体例子。某张 8GB 显存的卡总显存 8GB预留 1GB可用 7GB。跑 3 位 7B权重 3.24GBKV 按 8192 上下文 fp16 算是 1.07GB缓冲 0.8GB加起来 5.11GB还剩 1.89GB每层约 101MB能再放 18 层——但总共只有 32 层所以实际上可以全放 GPU还有富余。换成 30B 模型3 位约 13GB60 层每层约 217MB。同样 7GB 预算减掉 KV30B 模型约 2GB和缓冲 1GB剩 4GB能放 18 层占 30%。剩下的 42 层留在内存里这就是典型的混合推理场景。4.2 层卸载的性能模型为什么加层几乎线性提速这是本文最想讲清楚的一件事。很多人以为混合推理里 GPU 只放 30% 的层速度就只有全 GPU 的 30%实际完全不是这样。前面说过decode 阶段是带宽瓶颈且 GPU 和 CPU 是串行的。设总层数N放到 GPU 的层数n单层权重字节wGPU 有效带宽B_gCPU 有效带宽B_c则单 token 耗时T n × w / B_g (N - n) × w / B_c假设B_g 900GB/sB_c 45GB/s两者差 20 倍。代入 30B 的例子N60n18w217MBGPU 部分18 × 0.217GB / 900 4.3msCPU 部分42 × 0.217GB / 45 202ms总耗时206ms约 4.8 tok/s。如果全放内存n060 × 0.217 / 45 289ms约 3.5 tok/s。也就是说只把 30% 的层挪到 GPU速度提升了 38%。如果想办法把 n 提到 3050%CPU 部分降到 30 层耗时变成 145ms 7ms 152ms约 6.6 tok/s比全内存快 89%。看出来了吧加 GPU 层的收益几乎完全来自“减少 CPU 那部分的时间”且斜率就是 CPU 带宽的倒数。只要 GPU 那部分耗时还远小于 CPU 部分加层就是近乎线性的收益。这也解释了为什么很多人反馈“混合推理比想象中快很多”——不是 GPU 在并行帮忙是它把 CPU 的负担切走了一块。这个模型还能指导你选位宽。假设显存固定能放 4GB 权重30B 模型用 3 位217MB/层能放 18 层用 5 位350MB/层只能放 11 层。分别算3 位18×0.217/900 42×0.217/45 4.3 202 206ms5 位11×0.350/900 49×0.350/45 4.3 381 385ms3 位快了将近一倍。所以在混合推理场景下降低位宽换更多 GPU 层数几乎总是划算的前提是精度还能接受。这跟纯 GPU 推理的结论相反——纯 GPU 推理你巴不得用高位宽因为反正都在显存里带宽一样。4.3 系统级调优线程数、NUMA 与页缓存软件层的配置调完还有几个系统层面的坑踩过的人都知道疼。**线程数怎么定。**默认配置经常是“CPU 物理核心数”这在混合推理里往往是错的。decode 阶段是纯带宽瓶颈线程太多反而会因为同步开销掉性能。我的经验值是物理核心数的一半到三分之二且不超过 16。比如 32 核的服务器设 12~16 线程通常比设 32 线程快 10% 以上。这个值必须实测不同架构的甜点不一样。**NUMA 亲和性。**双路服务器上如果进程在 NUMA 节点 0 上跑但内存分配跨到了节点 1跨节点访问的带宽只有本地的一半性能直接腰斩。解决办法是把进程和内存都绑定到同一个节点# 绑定到 0 号节点的 CPU 和内存 numa-tool --cpunodebind0 --membind0 \ ./runner --model ./models/base-7b-q3.myq --n-gpu-layers 18 --threads 12判断有没有踩这个坑很简单跑推理时用带宽监控工具看内存读数如果实测带宽只有标称值的一半八成是跨节点了。**页缓存与 mmap。**模型文件如果完全读进匿名内存会占用大量物理内存而且每次启动都要重新读盘。用 mmap 映射文件的话操作系统会把热数据缓存在页缓存里第二次启动几乎是瞬时的多个进程还能共享同一份物理页。代价是首次加载会慢而且如果内存压力大热页会被换出去导致推理过程中出现偶发的卡顿。缓解办法是加载完成后主动“预热”——跑一遍空推理把权重页都拉进缓存。# 预热跑一次短 prompt 的空推理把权重页拉入页缓存 ./runner --model ./models/base-7b-q3.myq --n-gpu-layers 18 --prompt 预热 --n-predict 1**GPU 侧的坑。**层数设得太大显存溢出后驱动会把一部分数据放到共享内存里此时推理速度会断崖式下跌而且是“看起来跑得动但慢十倍”。所以层数要留出安全余量不要贴着上限设。另外首次推理会有一次性的 kernel 编译和显存分配开销测速一定要跳过第一个 token。4.4 实测数据与瓶颈定位我在一台 8 核 CPU、32GB 内存、8GB 显存的机器上跑 3 位 30B 模型不同层数配置的实测数据如下。注意看 CPU 线程数的影响。GPU 层数线程数prefill512 tokendecodetok/s瓶颈判断0841s3.2CPU 算力 带宽11838s4.4CPU 带宽18836s4.7CPU 带宽181634s5.0CPU 带宽183233s4.6线程同步开销261630s6.5GPU 侧开始吃紧这张表有几个值得注意的现象。prefill 时间几乎不随层数变化因为瓶颈在 CPU 那 30 多层的算力上GPU 那部分本来就快减少它没意义。线程数从 8 加到 16 有小幅提升加到 32 反而下降印证了带宽瓶颈下线程过多的负面效应。到了 26 层GPU 显存开始紧张decode 提升幅度超过了线性预期从 5.0 到 6.5 是 30%而层数只增加了 44% 中的一部分说明此时 CPU 负担已经明显减轻GPU 的带宽优势开始充分体现。瓶颈定位的方法论很简单分别测“全 CPU”和“全 GPU”两个极端。如果全 GPU 的速度是全 CPU 的十几倍说明带宽差异主导混合推理的收益会很显著值得花时间调层数如果两者差距只有两三倍说明你的 CPU 带宽本来就不差或者 GPU 那侧有别的瓶颈调层数的收益有限。5. 常见问题与排查技巧实录5.1 输出崩坏类问题的定位路径**症状一输出重复。**模型反复输出同一句话或者卡在某个词上循环。这是 2 位、3 位量化最典型的症状根因是注意力层的 KV 投影精度不足导致注意力分数分布退化模型对“已经说过什么”失去了分辨能力。定位方法是单独把 attention 的 K、V 投影层提到 5 位或 6 位其他层保持低位重新量化。如果症状消失就能确认是这个原因。这个改动的代价很小因为 K/V 投影只占注意力层参数的三分之一左右。**症状二中途换语言。**中文回答到一半突然切成英文或者乱码符号。这通常是词表相关的层出了问题——要么 embedding 被量化了要么输出层的低位宽导致 logits 分布偏移。检查方法是把 embedding 和输出层恢复到 6 位以上再看是否复现。**症状三数字算错、日期编造。**这类问题在 3 位量化上很常见而且最难察觉因为句子本身通顺。根因是低比特量化对数值特征的表达能力不足模型能理解“这是一个数字”但精度不够。如果你的场景涉及数值计算、报表生成3 位以下基本不要考虑直接用 5 位。**症状四首 token 正常后面越来越乱。**这是 KV cache 的问题不是权重量化的问题。检查 KV cache 的量化配置如果是 4 位 KV 量化换成 8 位试试。5.2 速度不达预期的排查清单按这个顺序查基本能定位到问题。现象可能原因验证方法处理速度只有理论值 1/3反量化没向量化看 CPU 占用率是否只有 30%补 SIMD 实现加了 GPU 层反而变慢显存溢出到共享内存监控显存占用是否打满减少 2~3 层多线程加速比很低NUMA 跨节点对比单节点绑定前后的带宽绑定 CPU 和内存首次推理极慢之后正常页缓存冷启动第二次运行是否变快加预热步骤长上下文突然变慢KV cache 换页监控上下文增长时的内存降 KV 量化位宽同一配置两次测速差很多后台进程抢占带宽空闲时重测隔离测试环境第一条最常见。很多人写完标量版反量化就直接上生产测出来 3 tok/s以为是硬件不行实际上是 kernel 只发挥了三分之一的带宽。验证方法很直接跑推理的时候看 CPU 占用如果八个核里只有两三个在跑满其余在等那就是没有并行起来。补上 SIMD 之后通常能有 2~3 倍提升。5.3 几条踩坑总结第一条别在量化脚本里图省事用全局配置。我见过太多实现是“所有层统一 3 位、统一组大小 32”结果模型前半部分还行后半部分崩。真正该做的是分层配置前几层和最后几层用高位宽中间宽矩阵用低位宽注意力层的部分投影单独提位。第二条量化完必须做端到端生成测试不能只看 loss。数值指标和主观体验的偏离在低比特场景下非常严重。我的习惯是准备 20 条固定 prompt覆盖问答、代码、翻译、长文续写四类每次量化完挨个跑一遍人工扫一眼。这个流程花十分钟比事后被用户投诉强得多。第三条混合推理的层数配置要写进部署脚本自动算。手动调参在换模型、换显卡、改上下文长度的时候必然出错。把 4.1 节的公式实现成一个几十行的脚本输入模型大小、显存、上下文长度直接输出建议层数能省掉大量返工。第四条留好回滚路径。量化是一次有损压缩任何一次参数调整都可能让效果变差。我的做法是每次量化都保留配置文件和校准集的哈希出了问题能精确复现当时的条件而不是对着一堆同名文件猜哪个是好的。最后分享一个我觉得最实用的小技巧用 6 位模型当“探针”。当你怀疑某个效果问题是量化导致的还是模型本身能力不足跑一遍 6 位版本。6 位的表现基本等价于原始模型如果 6 位也答不对那就不是量化的锅别在量化参数上浪费时间了。这个判断能帮你省下大量无效调试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑