跳转至

量化与低精度⚓︎

Abstract

本文整理大模型中的量化与低精度表示,重点说明数值如何映射到低位宽表示、误差从何而来、为何需要校准与分组,以及 PTQ、QAT、SmoothQuant、AWQ、GPTQ、FP16、BF16 与量化部署格式分别解决什么问题。这里关注的不是某个框架的接口,而是精度、存储、带宽、kernel 与运行时布局之间的系统级折中。

量化的目标⚓︎

量化的直接目标,不只是缩小模型文件体积,而是同时降低参数存储成本、显存带宽压力和部分算子的运行代价。在大模型场景下,量化是否真正有效,往往不只取决于位宽本身,还取决于量化误差是否可控、数据布局是否适合底层 kernel,以及运行时能否高效完成反量化与矩阵乘法。

量化最常见的收益包括:

  • 存储压缩,使模型权重更容易驻留在有限显存或内存中
  • 带宽降低,使推理时每步需要搬运的数据量下降
  • 推理吞吐改善,使部分硬件后端能够利用更高密度的低精度算子
  • 能耗下降,使边缘设备和大规模部署都更容易控制成本

与此同时,低位宽表示也会带来精度损失、元数据开销以及 kernel 约束。位宽越低,这些代价越容易从局部误差演变成端到端精度下降或运行时效率损失。

数值表示与基本公式⚓︎

量化的本质,是把连续浮点值映射到一个更稀疏的低位宽可表示集合中,再在需要时近似恢复。对线性量化而言,最基本的参数是 scale 和 zero-point。

对非对称量化,常见映射关系是:

S = \frac{X_{max} - X_{min}}{Q_{max} - Q_{min}}
Z = \operatorname{round}\left(Q_{min} - \frac{X_{min}}{S}\right)
X_{int} = \operatorname{clip}\left(\operatorname{round}\left(\frac{X_{float}}{S}\right) + Z, Q_{min}, Q_{max}\right)
\hat{X}_{float} = S \cdot (X_{int} - Z)

其中,X_{float} 表示原始浮点值,X_{int} 表示量化后的整数值,\hat{X}_{float} 表示反量化后的近似重建值。量化并不会保留原始连续值,而是把它们吸附到离散整数网格上,再用 scale 和 zero-point 解释这些网格点在原空间中的位置。

从表示类型看,FP32、INT8 和 INT4 解决的问题并不相同。FP32 依靠较大的指数和尾数空间保留更高的数值表达能力;INT8 和 INT4 则通过把值约束到更小的离散集合中,换取更低的存储和访存成本。二者的差别不只是位宽不同,更在于一个强调高保真数值表示,另一个强调部署效率。

因此,量化讨论的重点并不是“是否还能表示原值”,而是“在给定误差预算下,低位宽表示是否足以支撑目标任务和目标系统”。

线性量化的最小示例
linear_quantization.py
import torch


def linear_quantize(x, scale, zero_point, qmin, qmax):
    x_int = torch.round(x / scale) + zero_point
    x_int = torch.clamp(x_int, qmin, qmax)
    return x_int.to(torch.int32)


def linear_dequantize(x_int, scale, zero_point):
    return scale * (x_int.float() - zero_point)

误差来源⚓︎

量化误差并不只有一种来源。低位宽表示带来的误差,通常至少包括舍入误差、截断误差,以及由异常值主导动态范围所带来的分辨率浪费。

舍入误差来自离散网格本身。一个浮点值被映射到最近整数点后,原值与重建值之间会留下不可避免的偏差。位宽越低,可用离散点越少,单位区间内的表示密度越低,舍入误差通常越明显。

截断误差来自 clipping。若系统为了避免异常值拉大动态范围而主动收缩量化区间,那么超过阈值的值就会被截断到区间边界。这会减少大部分正常值的量化步长,但代价是极端值的误差被显著放大。

异常值敏感性则连接了前两类误差。若少量 outlier 主导整个张量的动态范围,那么 scale 会被迫变大,结果是大多数常见值共享过于粗糙的量化网格,分辨率浪费严重。因此,量化真正困难的地方往往不是平均值,而是分布尾部。

激活值通常比权重更难量化,原因在于它们的分布会随输入样本变化,并且更容易出现偏斜和动态范围抖动。权重是静态参数,可以在部署前充分分析;激活则是运行时信号,量化方案必须面对输入相关的分布不稳定性。

对称与非对称量化⚓︎

对称量化⚓︎

对称量化通常令 zero-point 固定为 0,即:

Z = 0.

它隐含的前提,是数值分布相对适合围绕 0 对称展开。在这种情况下,量化和反量化路径更简单,底层整数 GEMM 也更容易高效实现。

若把两个对称量化张量写成整数形式,矩阵乘法通常可以近似写成:

Y \approx S_A S_B \sum (A_{int} B_{int}).

这里没有额外的 zero-point 交叉项,因此寄存器压力和预处理复杂度通常更低。这也是为什么权重量化常常偏向对称形式。

非对称量化⚓︎

非对称量化允许 zero-point 非 0,因此更适合分布明显偏移、并不围绕 0 对称的数值,尤其是某些激活分布。

它的优势,是能更充分利用整数区间;但代价是算术路径更复杂。若两个张量都有非零 zero-point,则整数乘法展开后会出现额外交叉项:

\sum (A_{int} - Z_A)(B_{int} - Z_B).

这些额外项意味着更多预处理、更复杂的 kernel 逻辑,以及更高的实现成本。因此,非对称量化通常是在表示能力和运行复杂度之间做折中,而不是无代价地优于对称量化。

粒度与分组⚓︎

量化不只是在“用几 bit”上做选择,还要决定一组 scale 和 zero-point 覆盖多大范围。这就是量化粒度问题。

Per-Tensor⚓︎

Per-tensor quantization 为整个张量共享一组量化参数。它的优点是元数据最少、布局最简单、实现最容易与底层 kernel 对齐。

它的缺点同样明显:只要张量内部不同通道或不同局部块的分布差异较大,共享同一组 scale 就会带来明显精度损失。因此,它更适合分布相对均匀的场景。

Per-Channel⚓︎

Per-channel quantization 为每个输出通道或某个固定维度单独分配 scale。这样可以更细粒度地适应不同通道的分布差异,通常对卷积或线性层权重更友好。

它带来的代价,是元数据量上升,并且 kernel 需要在更细粒度上读取对应 scale。若硬件和后端支持较好,这类代价通常是可接受的。

Group-Wise / Block-Wise⚓︎

Group-wise 或 block-wise quantization 介于前两者之间。系统会把张量切成固定大小的组或块,每组共享一套量化参数。

若每组大小为 g,则一行长度为 d 的张量大致会分成 d / g 个量化组。组越小,分布适应性通常越好,但元数据开销和访问复杂度也会增加;组越大,布局更规整,但更容易被局部 outlier 拉坏。

这类方案在现代大模型量化中很常见,因为它在精度、元数据和 kernel 友好性之间给出了较好的工程折中。

PTQ、QAT、动态量化与静态量化⚓︎

PTQ⚓︎

PTQ(Post-Training Quantization)是在训练完成后直接量化模型。它的优点是成本低、改动小、部署路径短,因此在工业部署中非常常见。

它的局限在于,模型并没有在训练过程中适应量化噪声。对 INT8 一类较高位宽,PTQ 往往已经足够实用;但在 4-bit 或更低位宽时,若分布复杂或异常值明显,精度损失通常更难控制。

QAT⚓︎

QAT(Quantization-Aware Training)会在训练过程中显式引入量化噪声,常见方式是插入 fake-quant 节点,并用 STE 近似不可导的 round 操作。

它的核心优势,是让模型参数在训练时就对低位宽约束进行适配,因此在极低位宽和高精度敏感场景下,通常能获得更高的精度上限。代价则是训练成本更高、流程更复杂。

动态量化与静态量化⚓︎

动态量化会在推理时根据当前输入重新估计激活值范围,因此更能适应运行时数据分布变化。它的优势是接入简单、对输入变化更鲁棒;它的代价是推理过程中需要额外统计或计算量化参数。

静态量化会在部署前通过校准数据集固定量化参数。它的优势是推理路径更短、更容易实现高吞吐和稳定延迟;它的代价是对输入分布变化更敏感。如果线上输入与校准数据差距很大,误差可能明显上升。

混合精度量化⚓︎

并不是所有层都必须共享同一种位宽。混合精度量化的核心,是让敏感层保留更高精度,让冗余更大的层压到更低位宽。

它本质上是在模型压缩率和精度之间做层级分配,而不是强迫整个网络用同一条规则。许多现代量化系统之所以能在较低平均位宽下保持可接受精度,依赖的正是这种非均匀分配思路。

校准与裁剪⚓︎

校准和裁剪的共同目标,是在截断误差和量化分辨率之间找到可接受的动态范围。范围过大,异常值虽然被保留,但大多数正常值会共享过粗的量化步长;范围过小,常见值精度变高,但尾部值会被严重截断。

Min-Max⚓︎

Min-Max 直接使用观测到的最小值和最大值作为量化区间。它实现简单,也最容易解释。

它的问题是对异常值极其敏感。只要样本中有少量极端值,scale 就会被明显拉大,导致绝大多数值的分辨率被浪费。

Percentile⚓︎

Percentile 使用某个分位点作为动态范围阈值,而不是强行保留最极端的尾部值。它本质上是一种主动 clipping:放弃少量异常值精度,换取主体分布更细的量化分辨率。

这种方法的优点是工程简单、效果稳定;缺点是阈值需要手工选择,不同模型和不同层的最佳分位点未必一致。

KL Divergence⚓︎

KL Divergence 方法不直接手工指定裁剪比例,而是比较原始分布与量化后重建分布之间的差异。常见目标是寻找使两者分布差异尽可能小的阈值。

其核心公式可以写为:

D_{KL}(P \Vert Q) = \sum_i P(i) \log \frac{P(i)}{Q(i)}.

它的优势,是把“哪一个阈值更合适”转化为一个更明确的分布保真问题;它的代价,是实现和计算都比简单的 Min-Max 或 Percentile 更复杂。

分位点阈值搜索示意
percentile_threshold.py
import numpy as np


def percentile_threshold(x, p=0.999):
    bound = np.quantile(np.abs(x), p)
    return -bound, bound

方法族⚓︎

SmoothQuant⚓︎

SmoothQuant 主要针对激活中的异常值问题。它的核心思想,是利用等价缩放把一部分激活侧的量化压力转移到权重侧,使激活分布变得更平滑、更容易量化。

它并不是凭空减少总误差,而是在更难量化的一侧和更容易离线处理的一侧之间重新分配误差来源。其系统意义在于,若激活值原本是主要瓶颈,那么这种“迁移压力”的做法可以显著改善部署可行性。

代价是权重需要吸收相应缩放,离线变换和部署布局需要与运行时 kernel 假设一致。

AWQ⚓︎

AWQ(Activation-aware Weight Quantization)关注的是,在真实激活驱动下,哪些权重对输出更重要。它的目标不是均匀压缩所有权重,而是尽量保护那些对激活响应更敏感的部分。

它主要作用在权重侧,但判断依据来自激活分布,因此兼具算法和部署意义。其优势通常在于部署友好,权重布局往往仍能保持较规整形式,较容易匹配底层 kernel。

它的代价是需要额外的离线分析过程,而且“保护哪些权重”本身依赖代表性样本质量。

GPTQ⚓︎

GPTQ 更偏逐层、逐块地最小化量化后的重建误差,常利用二阶信息近似来决定量化顺序和误差补偿方式。

它更像是在给定低位宽约束下,尽量把局部重建误差压低。其优点通常是精度表现较强,尤其适合低位宽权重量化场景。

它的代价则是离线校准成本更高,并且某些量化布局会带来更复杂的打包、索引或运行时访问模式。因此,它在系统上未必总是最容易落地的方案。

低精度浮点⚓︎

FP16⚓︎

FP16 仍然是浮点表示,而不是整数量化。它通过减少尾数和指数总位宽,换取更低存储和更高吞吐。

它的特点是尾数相对更细,但指数范围较窄,因此在训练中更容易出现溢出或下溢问题。对很多训练系统而言,FP16 的价值在于较低内存和较成熟的硬件支持,但往往需要更谨慎的数值稳定性处理。

BF16⚓︎

BF16 同样不是整数量化,但它属于低精度系统设计中非常重要的一环。它保留了与 FP32 相同宽度的指数位,只缩短尾数位,因此动态范围接近 FP32。

这意味着 BF16 的相对精度不如 FP16 细,但数值稳定性通常更好,也更不容易依赖复杂的 loss scaling。对训练系统而言,BF16 的意义往往更多体现在稳定训练和简化数值处理,而不只是单纯压缩内存。

因此,低精度浮点和整数量化虽然不属于同一数值族,但二者都在解决“以更低表示成本完成可接受计算”的系统问题,所以应放在同一篇学习笔记中统一理解。

系统与部署⚓︎

Packing 与 Kernel⚓︎

量化后的权重并不能直接等价于“运行时已经更快”。真正决定部署效率的,往往是低位宽数据如何打包、scale 与 zero-point 如何组织,以及底层 kernel 是否为该布局做了优化。

低 bit 权重通常需要 packed layout。例如,4-bit 权重常常要以多个值打包进同一字节或更大的块结构中,同时附带 group-wise scale、zero-point 或其他元数据。这样才能在访存和向量化执行时获得收益。

元数据本身也有成本。组越小,scale 适配能力越强,但元数据比例更高;组越大,布局更规整,但局部异常值更容易损害精度。因此,量化设计不只是选位宽,还包括选 block shape、metadata 形式和 kernel 假设。

不同推理引擎通常还会偏好不同的打包方式,因此模型在加载时往往需要 repacking,把外部存储布局转换成更适合目标硬件和目标 kernel 的内部布局。文件能加载,并不意味着运行时一定高效。

GGUF 与 llama.cpp⚓︎

GGUF 更像是一种模型存储与分发格式;真正定义具体量化族、块布局和运行时执行假设的,通常还是具体推理框架,例如 llama.cpp

这意味着 GGUF 解决的是“模型如何被组织和携带”,而不是单独决定“运行时如何最高效执行”。一个格式可以保存量化张量、scale 和元数据,但具体推理引擎仍然需要与这些布局匹配的 kernel 和内存访问路径。

llama.cpp 中常见的分块量化做法,本质上是在更小局部范围内共享量化参数,以限制异常值影响并改善局部分辨率。例如某些量化族会使用 block、super-block 或更复杂的层级打包结构,以在元数据摊销和精度之间做折中。

因此,同样是 4-bit 量化,最终运行效果仍然会受到块结构、元数据布局、是否需要额外索引以及后端 kernel 支持程度的显著影响。文件格式可移植,不代表不同引擎之间的实际吞吐和延迟表现等价。

常见问题⚓︎

为什么量化不只是把 FP32 改成 INT8?⚓︎

因为位宽变化只描述了表示范围的缩小,并没有回答如何选择 scale、是否需要 zero-point、动态范围如何裁剪、不同层是否应共享同一位宽,以及运行时是否有匹配的 kernel。

真正的量化系统同时涉及数值映射、误差控制、元数据组织和部署执行路径,因此远不只是一次简单类型替换。

为什么激活值通常比权重更难量化?⚓︎

权重是静态参数,可以离线统计和反复分析;激活值则会随输入变化,分布更容易偏斜,也更容易出现动态范围抖动。

因此,激活量化通常更依赖校准、动态估计或专门方法去处理异常值问题,而权重量化更容易在离线阶段完成充分优化。

PTQ、QAT 和低比特权重量化分别适合什么场景?⚓︎

PTQ 适合快速部署、改动成本低、位宽仍相对保守的场景;QAT 适合低位宽、精度敏感、愿意付出训练代价换更高精度上限的场景;低比特权重量化则更适合推理部署受显存和带宽强约束的系统。

三者并不是简单替代关系,而是分别对应不同的成本预算和系统目标。

为什么同样是 4-bit,不同框架运行速度可能差很多?⚓︎

因为位宽相同并不代表数据布局相同。group size、scale 组织方式、是否有额外索引、是否需要 repacking、kernel 是否针对该格式做专门优化,都会显著影响实际吞吐和延迟。

因此,部署性能往往由“量化格式 + 打包布局 + kernel 实现 + 硬件后端”共同决定,而不是只由 bit 数决定。

参考资料⚓︎

评论