模型量化与推理引擎:GPTQ 权重量化的精度恢复与加速实践

cover

一、量化推理的精度悬崖:从 FP16 到 INT4 的质量断崖

模型量化是大模型推理加速的重要技术,但量化过程并非平滑过渡。从 FP16 量化到 INT8 时,大多数模型的精度损失在 0.5% 以内,几乎可以忽略;但从 INT8 进一步量化到 INT4 时,精度可能出现骤降——Perplexity 飙升 20%-50%,生成内容出现明显的语法错误和事实偏差。

这种"精度悬崖"的根源在于权重的分布特性。大模型中,约 1%-5% 的权重是"异常值"(Outlier),它们的绝对值远大于其余权重。在 INT4 量化中,4 bit 只能表示 16 个离散值,量化粒度极粗。如果使用统一的缩放因子,异常值会"吃掉"大部分量化范围,导致普通权重的精度严重损失。GPTQ(基于梯度的训练后量化)通过逐层优化解决这一问题——它在量化每个权重时,考虑该权重对模型输出的影响,并调整未量化权重来补偿量化误差。

二、GPTQ 的核心算法:逐层误差补偿与 Hessian 近似

flowchart TB
    subgraph 量化流程
        W[权重矩阵 W] --> ROW[逐行量化]
        ROW --> Q[量化当前权重 w_q]
        Q --> ERR[计算量化误差 δ = w - w_q]
        ERR --> COMP[补偿误差到后续权重]
        COMP --> HESS[Hessian 逆矩阵 H⁻¹]
        HESS --> ADJ[调整: w_adj = w - δ × H⁻¹]
        ADJ --> NEXT[继续下一行]
    end

    subgraph Hessian 计算
        CALIB[校准数据集] --> FWD[前向传播]
        FWD --> LOSS[计算 Hessian: H = 2XᵀX]
        LOSS --> INV[近似求逆: H⁻¹]
    end

    subgraph 分组量化
        GROUP[将权重按列分组] --> SCALE[每组独立缩放因子]
        SCALE --> ZERO[每组独立零点]
        ZERO --> QGROUP[组内量化为 INT4]
    end

    style ERR fill:#ffebee
    style COMP fill:#e8f5e9
    style HESS fill:#e3f2fd

GPTQ 的核心思想是"量化一个权重,补偿剩余权重"。具体流程为:

  1. 对权重矩阵按行逐个量化,量化当前权重 $w_q$ 后计算误差 $\delta = w - w_q$。
  2. 将误差 $\delta$ 按照该权重对输出的影响(Hessian 矩阵的逆)分配到后续未量化的权重上。
  3. Hessian 矩阵 $H = 2X^TX$ 通过校准数据集的前向传播计算,其逆矩阵 $H^{-1}$ 使用 Cholesky 分解高效求解。

分组量化(Group-wise Quantization)是另一个关键优化。将权重按列分成若干组(通常 128 列一组),每组使用独立的缩放因子和零点,避免异常值影响整个通道的量化精度。这种分组策略将 INT4 量化的精度损失从 20%+ 降低到 2%-5%。

三、GPTQ 量化的工程实现

# gptq_quantizer.py — GPTQ 权重量化核心实现
import numpy as np
from dataclasses import dataclass
from typing import Optional


@dataclass
class QuantConfig:
    """量化配置"""
    bits: int = 4                     # 量化位数
    group_size: int = 128             # 分组大小
    damp_percent: float = 0.01        # Hessian 对角阻尼系数
    desc_act: bool = True             # 是否按 Hessian 对角线降序排列


@dataclass
class QuantizedWeight:
    """量化后的权重"""
    q_weight: np.ndarray              # 量化权重(INT4/INT8)
    scales: np.ndarray                # 缩放因子
    zeros: np.ndarray                 # 零点
    group_size: int
    bits: int

    def dequantize(self) -> np.ndarray:
        """反量化:恢复为 FP16 权重"""
        # 将量化值乘以缩放因子并加上零点
        # q_weight shape: [out_features, in_features]
        # scales shape: [out_features, num_groups]
        # zeros shape: [out_features, num_groups]

        out_features, in_features = self.q_weight.shape
        num_groups = in_features // self.group_size

        # 重塑为分组形式
        q_grouped = self.q_weight.reshape(
            out_features, num_groups, self.group_size
        )

        # 反量化公式: w = (q - zero) * scale
        fp16_weight = (
            q_grouped.astype(np.float32) -
            self.zeros[:, :, np.newaxis]
        ) * self.scales[:, :, np.newaxis]

        return fp16_weight.reshape(out_features, in_features).astype(np.float16)


class GPTQQuantizer:
    """GPTQ 量化器"""

    def __init__(self, config: QuantConfig):
        self.config = config

    def quantize_layer(
        self,
        weight: np.ndarray,
        hessian: np.ndarray,
    ) -> QuantizedWeight:
        """对单层权重执行 GPTQ 量化

        Args:
            weight: FP16 权重矩阵 [out_features, in_features]
            hessian: Hessian 矩阵 [in_features, in_features]
        """
        out_features, in_features = weight.shape
        group_size = self.config.group_size
        num_groups = in_features // group_size

        # Step 1: Hessian 对角阻尼,防止数值不稳定
        diag = np.diag(hessian)
        damp = self.config.damp_percent * np.mean(diag)
        hessian += damp * np.eye(in_features)

        # Step 2: Cholesky 分解求 Hessian 逆
        # H⁻¹ ≈ (L Lᵀ)⁻¹ = L⁻ᵀ L⁻¹
        try:
            L = np.linalg.cholesky(hessian)
            hessian_inv = np.linalg.solve(
                L @ L.T, np.eye(in_features)
            )
        except np.linalg.LinAlgError:
            # Cholesky 失败时使用伪逆
            hessian_inv = np.linalg.pinv(hessian)

        # Step 3: 按 Hessian 对角线降序排列(desc_act)
        if self.config.desc_act:
            hess_diag = np.diag(hessian)
            perm = np.argsort(hess_diag)[::-1]
            weight = weight[:, perm]
            hessian_inv = hessian_inv[perm][:, perm]
        else:
            perm = np.arange(in_features)

        # Step 4: 逐行量化与误差补偿
        q_weight = np.zeros_like(weight, dtype=np.int32)
        scales = np.zeros((out_features, num_groups), dtype=np.float32)
        zeros = np.zeros((out_features, num_groups), dtype=np.float32)

        errors = np.zeros_like(weight, dtype=np.float32)

        for col_idx in range(in_features):
            # 当前列所属的分组
            group_idx = col_idx // group_size

            # 计算当前分组的缩放因子和零点
            if col_idx % group_size == 0:
                group_start = group_idx * group_size
                group_end = min(group_start + group_size, in_features)
                group_weights = weight[:, group_start:group_end]

                # 使用最小-最大量化计算缩放因子和零点
                w_max = np.max(group_weights, axis=1, keepdims=True)
                w_min = np.min(group_weights, axis=1, keepdims=True)

                max_q = (1 << self.config.bits) - 1

                scales[:, group_idx] = (
                    (w_max - w_min).squeeze() / max_q
                )
                zeros[:, group_idx] = (
                    w_min.squeeze() / scales[:, group_idx]
                )

            # 量化当前列
            scale = scales[:, group_idx]
            zero = zeros[:, group_idx]

            q_val = np.clip(
                np.round(
                    weight[:, col_idx] / scale - zero
                ),
                0, (1 << self.config.bits) - 1
            ).astype(np.int32)

            q_weight[:, col_idx] = q_val

            # 计算量化误差
            deq_val = (q_val.astype(np.float32) + zero) * scale
            errors[:, col_idx] = weight[:, col_idx] - deq_val

            # 误差补偿:将误差分配到后续列
            if col_idx < in_features - 1:
                # 使用 Hessian 逆矩阵计算补偿量
                err_col = errors[:, col_idx]
                h_inv_col = hessian_inv[col_idx, col_idx + 1:]

                # 补偿公式: w_adj[:, j] -= err * h_inv[j] / h_inv[col_idx]
                compensation = (
                    err_col[:, np.newaxis] *
                    h_inv_col[np.newaxis, :] /
                    (hessian_inv[col_idx, col_idx] + 1e-10)
                )
                weight[:, col_idx + 1:] -= compensation

        # Step 5: 恢复原始列顺序
        if self.config.desc_act:
            inv_perm = np.argsort(perm)
            q_weight = q_weight[:, inv_perm]
            # 注意:scales 和 zeros 不需要重排,因为分组是按原始顺序的

        return QuantizedWeight(
            q_weight=q_weight,
            scales=scales,
            zeros=zeros,
            group_size=group_size,
            bits=self.config.bits,
        )

    def compute_hessian(
        self,
        weight: np.ndarray,
        calibration_data: np.ndarray,
    ) -> np.ndarray:
        """通过校准数据计算 Hessian 矩阵

        Args:
            weight: 权重矩阵 [out_features, in_features]
            calibration_data: 校准数据 [num_samples, in_features]
        """
        # H = 2 * X^T * X / num_samples
        num_samples = calibration_data.shape[0]
        hessian = (
            2.0 * calibration_data.T @ calibration_data / num_samples
        )
        return hessian.astype(np.float32)


def benchmark_quantization(
    original_weight: np.ndarray,
    quantized: QuantizedWeight,
) -> dict:
    """评估量化质量"""
    # 反量化恢复权重
    deq_weight = quantized.dequantize()

    # 计算权重误差
    weight_diff = original_weight.astype(np.float32) - deq_weight.astype(np.float32)
    mse = np.mean(weight_diff ** 2)
    max_error = np.max(np.abs(weight_diff))

    # 计算信噪比
    signal_power = np.mean(original_weight.astype(np.float32) ** 2)
    snr_db = 10 * np.log10(signal_power / (mse + 1e-10))

    # 计算显存节省
    original_bytes = original_weight.nbytes
    quantized_bytes = (
        quantized.q_weight.nbytes * quantized.bits / 32 +
        quantized.scales.nbytes +
        quantized.zeros.nbytes
    )
    compression_ratio = original_bytes / quantized_bytes

    return {
        "mse": float(mse),
        "max_error": float(max_error),
        "snr_db": float(snr_db),
        "compression_ratio": float(compression_ratio),
        "original_bytes": int(original_bytes),
        "quantized_bytes": int(quantized_bytes),
    }

四、GPTQ 量化的精度瓶颈与部署权衡

GPTQ 量化在实际部署中面临几个关键权衡。

校准数据集的敏感性:GPTQ 的 Hessian 矩阵依赖校准数据集,数据集的分布与实际推理数据的分布越接近,量化精度越高。通常使用 128-512 条样本作为校准数据,数据量过少会导致 Hessian 估计不准确,过多则增加量化时间但不显著提升精度。选择校准数据时,应确保覆盖模型的主要使用场景。

分组大小的权衡:分组越小(如 32),每组独立的缩放因子越多,量化精度越高,但额外的元数据(scales 和 zeros)也越多。Group Size 128 是最常用的折中——它在 INT4 量化下提供约 3.5x 的压缩比(而非理论上的 4x),因为元数据占用了约 12% 的额外空间。Group Size 32 的压缩比约 3x,但精度更好。

推理加速的实际表现:INT4 量化在 GPU 上的推理加速主要来自显存带宽的节省——模型权重从显存加载到计算单元的时间减半。但 INT4 的计算本身需要先反量化为 FP16,再执行矩阵乘法,因此计算密集型场景(长序列、大批量)的加速比不如显存密集型场景(短序列、小批量)。实测中,INT4 量化在单请求场景下加速约 1.5-2x,在大批量场景下加速约 2-3x。

适用边界:GPTQ 适用于对延迟敏感、显存受限的推理场景。对于需要最高精度的场景(如医疗诊断、法律分析),INT8 量化比 INT4 更安全。对于模型服务化部署,建议同时提供 INT8 和 INT4 两个版本,让用户根据场景选择。

五、总结

GPTQ 通过逐层误差补偿和 Hessian 近似,将 INT4 量化的精度损失控制在可接受范围内。分组量化(Group Size 128)是精度与压缩比的最佳平衡点。在实际部署中,校准数据集的选择和分组大小的配置是影响量化质量的关键因素。INT4 量化的加速主要来自显存带宽节省,在显存密集型场景下效果最佳。建议从 INT8 量化起步验证精度,确认可接受后再尝试 INT4 + GPTQ。

更多推荐