🧊 动态混合精度量化 · 流程 & 源码

quant.py + pipeline.py · 分组非对称 RTN · 激活感知敏感度 · 贪心比特分配
📐 总览流程图 TD 自上而下
%%{init: { 'theme': 'base', 'themeVariables': { 'background': '#ffffff', 'primaryColor': '#e6edf5', 'primaryBorderColor': '#b8c9dd', 'secondaryColor': '#f2f6fc', 'tertiaryColor': '#fafcff', 'lineColor': '#7f9bb3', 'textColor': '#1e293b', 'fontSize': '14px', 'fontFamily': 'system-ui, sans-serif' }}}%% flowchart TD A[加载 FP16 模型 + 校准数据] --> B[collect_target_linears
跳过 vision / lm_head] B --> C[compute_sensitivity
▸ 预量化各层 2/3/4/8 bit CPU 一次
▸ 前向 hook 反量化 + 累加重建误差
▸ 输出:每层 err2/err3/err4/err8 + nparams] C --> D[allocate_bits
▸ Pass 1:贪心降级 8→4→3→2 直到加权平均 ≤ avg_bits
▸ Pass 2:强制下限 floor 单调上调] D --> E{实际落地} E --> F[apply_quantization
▸ 4/8 bit 层 → QuantLinear
▸ 2/3 bit 暂不落地] E --> G[save_quantized
▸ model_quant.safetensors + quant_config.json] F --> H[load_quantized_into
▸ 从 safetensors 恢复量化层
▸ 装配回同结构模型] style A fill:#e9f0fa,stroke:#b4c9df style C fill:#e6f3ed,stroke:#aacfc0 style D fill:#f4f0e6,stroke:#d4c9ae style F fill:#e9eef6,stroke:#b7c6db style G fill:#eaeef6,stroke:#b7c6db
Step 1 收集可量化线性层 跳过 vision / lm_head
flowchart LR A[遍历 named_modules] --> B{isinstance Linear?} B -- 否 --> A B -- 是 --> C{名称含 vision 或 lm_head?} C -- 是 --> A C -- 否 --> D[加入 targets 列表] D --> A

def collect_target_linears(model):
    """
    收集可量化线性层,跳过 vision 塔与 lm_head(保留 FP16 稳定输出)。
    """
    targets = []                                # 存储 (层名, 模块) 的列表
    for name, module in model.named_modules():  # 遍历模型所有模块
        if not isinstance(module, nn.Linear):   # 只关注线性层
            continue
        nl = name.lower()
        if "vision" in nl or "lm_head" in nl:   # 跳过视觉塔和输出头
            continue
        targets.append((name, module))          # 加入目标列表
    return targets
            
Step 2 计算逐层重建误差敏感度 预量化 CPU + Hook 反量化 GPU
CPU 预计算 (一次)
flowchart TD A1[遍历目标层] --> A2[取出权重 W] A2 --> A3[quantize_weight W 转为 2/3/4/8 bit] A3 --> A4[存入 pre 字典]
→
GPU 前向 Hook (每个 batch)
flowchart TD B1[hook 获得 Wf 和输入 A] --> B2[Y = Wf @ A^T] B2 --> B3[累加分母 normY2] B3 --> B4[对 2/3/4/8 bit] B4 --> B5[从 pre 取量化参数] B5 --> B6[dequant_weight 得到 Wq] B6 --> B7[Yq = Wq @ A^T] B7 --> B8[累加平方误差 errSum] B8 --> B9[计算相对误差]

@torch.no_grad()
def compute_sensitivity(model, calib_batches, group_size=128, device="cuda"):
    targets = collect_target_linears(model)      # 获取所有目标层
    err_keys = [2, 3, 4, 8]                      # 需要评估的比特数

    # ---------- CPU 预计算:各层 4 种比特的量化结果 ----------
    pre = {}
    for name, module in targets:
        W = module.weight.detach().float().cpu()    # 权重移到 CPU
        pre[id(module)] = {
            "nparams": W.numel(),                    # 该层参数量
            "packed": {b: quantize_weight(W, b, group_size) for b in err_keys},
        }

    # 统计量:累计误差和分母 ||Y||²
    stats = {id(m): {f"err{b}": 0.0 for b in err_keys} | {"y2": 0.0}
             for _, m in targets}
    name_of = {id(m): n for n, m in targets}

    def make_hook(mid):
        layer = pre[mid]   # 该层的预量化结果
        def hook(module, inp, out):
            Wf = module.weight.detach().float().to(device)  # 原始权重到 GPU
            A = inp[0].detach().reshape(-1, Wf.shape[1]).to(device).float()
            ns = stats[mid]
            Y = (Wf @ A.T).T                    # 原始输出
            ns["y2"] += float((Y * Y).sum())   # 累积分母
            for b in err_keys:                  # 对每个比特位宽
                qw, sc, zp = layer["packed"][b]
                Wq = dequant_weight(qw.to(device), sc.to(device), zp.to(device),
                                    b, group_size, Wf.shape[1])
                Yq = (Wq @ A.T).T               # 量化后的输出
                ns[f"err{b}"] += float(((Y - Yq) ** 2).sum())  # 累加平方误差
                del qw, sc, zp, Wq
            del Wf, A, Y, Yq
        return hook

    # 注册前向 hook
    handles = [m.register_forward_hook(make_hook(id(m))) for _, m in targets]
    model.eval()
    for batch in calib_batches:                # 遍历校准数据
        batch = {k: v.to(device) for k, v in batch.items()}
        model(**batch)
    for h in handles:                         # 移除 hook
        h.remove()

    # 汇总相对误差
    sens = {}
    for _, m in targets:
        mid = id(m)
        s = stats[mid]
        y2 = s["y2"] + 1e-12
        sens[name_of[mid]] = {f"err{b}": s[f"err{b}"] / y2 for b in err_keys} | {
            "nparams": pre[mid]["nparams"]}
    return sens
            
Step 3 贪心比特分配(含 floor 后置) Pass 1 达标 → Pass 2 强制下限
flowchart TD A[初始全 8bit] --> B{加权平均 ≤ 目标?} B -- 否 --> C[遍历所有可降级的层] C --> D[计算每层降档代价 cost] D --> E[选择 cost 最小的层] E --> F[将该层降一档 8→4→3→2] F --> B B -- 是 --> G[Pass 1 结束] G --> H{存在层 < floor?} H -- 是 --> I[将该层向上调一档] I --> H H -- 否 --> J[最终分配表]

def allocate_bits(sens, avg_bits=4.0, floor=2):
    nxt_lower = {8: 4, 4: 3, 3: 2}         # 降级路径
    nparams = {n: sens[n]["nparams"] for n in sens}
    total = sum(nparams.values()) + 1e-12
    prec = {n: 8 for n in sens}            # 初始全 8bit

    def weighted(p):
        return sum(p[n] * nparams[n] for n in p) / total

    # ---------- Pass 1: 贪心降级直到达标 ----------
    guard = 0
    while weighted(prec) > avg_bits + 1e-9 and guard < 10 ** 6:
        guard += 1
        best = None
        for n in sens:
            c = prec[n]
            if c == 2 or c not in nxt_lower:
                continue
            nx = nxt_lower[c]
            dec = sens[n][f"err{c}"] - sens[n][f"err{nx}"]   # 降档带来的误差增量
            bits_saved = c - nx
            cost = (dec * nparams[n]) / bits_saved          # 每释放 1 bit 的代价
            if best is None or cost < best[0]:
                best = (cost, n, nx)
        if best is None:
            break
        _, n, nx = best
        prec[n] = nx

    # ---------- Pass 2: 强制下限(单调上调,必然终止) ----------
    if floor and floor > 2:
        bump = {2: 3, 3: 4}
        changed = True
        while changed:
            changed = False
            for n in sens:
                if prec[n] < floor and prec[n] in bump:
                    prec[n] = bump[prec[n]]
                    changed = True
    return prec
            
Step 4 应用量化(替换为 QuantLinear) 仅 4/8 bit 落地
flowchart LR A[遍历 named_modules] --> B{条件: 是 Linear 且名在 prec 且 prec 为 4 或 8?} B -- 否 --> A B -- 是 --> C[QuantLinear.from_linear] C --> D[setattr 替换原模块] D --> A

def apply_quantization(model, prec, group_size=128):
    replaced = {}
    for name, module in model.named_modules():
        # 只替换 4/8 bit 的线性层
        if isinstance(module, nn.Linear) and name in prec and prec[name] in (4, 8):
            ql = QuantLinear.from_linear(module, prec[name], group_size)
            # 处理嵌套路径,找到父模块并替换属性
            if "." in name:
                parent_name, attr = name.rsplit(".", 1)
                parent = model.get_submodule(parent_name)
            else:
                parent, attr = model, name
            setattr(parent, attr, ql)          # 原地替换为量化层
            replaced[name] = prec[name]        # 记录已替换的层
    return replaced
            
Step 5 保存 / 加载量化产物 safetensors + config
flowchart LR S1[遍历 QuantLinear] --> S2[收集 qweight/scale/zp] S2 --> S3[写入 safetensors 和 config] L1[读取 config] --> L2[读取 safetensors] L2 --> L3[重建 QuantLinear 并 setattr]

def save_quantized(model, out_dir, prec, group_size, meta=None):
    from safetensors.torch import save_file
    os.makedirs(out_dir, exist_ok=True)
    tensors, keys = {}, {}
    for name, module in model.named_modules():
        if isinstance(module, QuantLinear):
            key = name.replace(".", "__")    # 替换 '.' 为 '__' 作为 safetensors key
            tensors[f"{key}.qweight"] = module.qweight
            tensors[f"{key}.scale"] = module.scale
            tensors[f"{key}.zp"] = module.zp
            keys[name] = {"bits": module.bits, "group_size": module.group_size,
                          "qweight": f"{key}.qweight", "scale": f"{key}.scale", "zp": f"{key}.zp"}
    save_file(tensors, os.path.join(out_dir, "model_quant.safetensors"))
    out = {"group_size": group_size, "precision_map": prec, "quant_keys": keys, "meta": meta or {}}
    with open(os.path.join(out_dir, "quant_config.json"), "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2)
    return out_dir

def load_quantized_into(model, out_dir):
    from safetensors.torch import load_file
    cfg = json.load(open(os.path.join(out_dir, "quant_config.json"), encoding="utf-8"))
    sd = load_file(os.path.join(out_dir, "model_quant.safetensors"))
    gs = cfg["group_size"]
    replaced = 0
    for name, info in cfg["quant_keys"].items():
        if not isinstance(model.get_submodule(name), nn.Linear):
            continue
        bits = info["bits"]
        qw, sc, zp = sd[info["qweight"]], sd[info["scale"]], sd[info["zp"]]
        if "." in name:
            parent_name, attr = name.rsplit(".", 1)
            parent = model.get_submodule(parent_name)
        else:
            parent, attr = model, name
        bias = getattr(parent, attr).bias   # 保留原偏置
        ql = QuantLinear(bits, gs, qw, sc, zp, bias)
        setattr(parent, attr, ql)           # 替换为量化层
        replaced += 1
    return replaced
            
📊 与竞品对比 & 优化方向

✅ 当前亮点

  • 高效 敏感度预量化一次,hook 只反量化,显存约 14 GB
  • 灵活 支持 2/3/4/8 bit 位打包(LSB-first)
  • 可控 贪心+floor 保证任意平均比特可达
  • 工程化 safetensors + config 离线复用

🚧 与 Unsloth 等差距

  • 敏感度:仅重建误差,缺梯度/海森信息
  • 分配:贪心局部最优,非全局搜索
  • 校准数据:5 万 token vs 30~150 万
  • 评估:仅 PPL,无 MMLU/GSM8K
  • 产物:safetensors,需外部转 GGUF
🔴
P0 · 评估闭环扩展
增加 MMLU (5-shot)、GSM8K、HumanEval 等基准,避免单指标失真。
🔴
P0 · 原生 GGUF 导出
仿照 llama.cpp 格式直接生成合规 GGUF,无需二次转换。
🟡
P1 · 敏感度升级
引入 gradient×weight 或 Hessian 迹,提高敏感度与真实 loss 的关联性。
🟡
P1 · 校准数据扩充
将 token 量从 5 万提升至 20~50 万,覆盖更多长尾分布。
🟢
P2 · 分配算法升级 — 从贪心到贝叶斯优化 / 动态规划
当前贪心算法存在局部最优、路径固定、优化目标偏差等问题。下面详细展开两种升级方案。

⚠️ 贪心算法的局限

  • 局部最优:每一步选择“当前代价最小”的层降级,但可能把某层降到 4 后再降到 3 时代价剧增,而另一层从 8 直接跳到 2 可能全局更优。
  • 路径固定:硬编码 8→4→3→2,忽略了不同层的“最优比特”可能不是相邻档位。
  • 优化目标偏差:代价基于重建误差 err,与实际下游任务(PPL, MMLU)的退化不一定强相关。
🧬 方案一:贝叶斯优化 黑盒优化 · 采样高效
flowchart LR A[随机初始化 20 组分配] --> B[评估每组 → 得到 PPL] B --> C[高斯过程拟合] C --> D[采集函数建议下一组] D --> B D --> E[迭代 50 轮后输出最优]
伪代码 贝叶斯优化实现

from bayes_opt import BayesianOptimization

def objective(prec_map):
    """输入分配表,返回 PPL 退化(负值,贝叶斯优化默认最大化)"""
    apply_quantization(prec_map)
    ppl = compute_ppl()
    restore()
    return -ppl

# 搜索空间:每层比特数 {2,3,4,8},映射为连续变量 [2,8]
pbounds = {f"layer_{i}_bits": (2, 8) for i in range(num_layers)}
optimizer = BayesianOptimization(f=objective, pbounds=pbounds)
optimizer.maximize(init_points=20, n_iter=50)
best = optimizer.max["params"]
# 将连续值离散化为最近的 {2,3,4,8}
                

✅ 优势

  • 直接优化真实 PPL / MMLU,而非重建误差。
  • 采样效率高,50~100 次评估即可找到较优解。
  • 易于扩展:可加权组合多个指标(PPL + MMLU)。

⚠️ 注意

  • 每次评估需跑一次完整的 PPL 计算(耗时约 2~3 分钟),总时间可控制在 2 小时内。
  • 需将离散比特映射到连续空间,或使用整数约束。
📊 方案二:动态规划 全局最优 · 可解释
flowchart TD A[层 0] --> B[层 1] --> C[...] --> D[层 N-1] style A fill:#e6f3ed style D fill:#e6f3ed
dp[i][b] = min_{k ∈ {2,3,4,8}} dp[i-1][b - k·nparams_i] + err_i[k]
伪代码 0-1 背包式动态规划

def dp_allocate(sens, avg_bits):
    n_layers = len(sens)
    total_bits_budget = int(avg_bits * sum(nparams) + 0.5)
    # dp[i][b] = 前 i 层使用 b 比特预算时的最小误差
    dp = [[inf] * (total_bits_budget + 1) for _ in range(n_layers + 1)]
    dp[0][0] = 0
    for i, name in enumerate(sens):
        for b in range(total_bits_budget + 1):
            if dp[i][b] == inf: continue
            for bits in [2, 3, 4, 8]:
                cost = bits * sens[name]["nparams"]
                if b + cost <= total_bits_budget:
                    new_err = dp[i][b] + sens[name][f"err{bits}"]
                    if new_err < dp[i+1][b + cost]:
                        dp[i+1][b + cost] = new_err
    # 回溯得到最优分配
    return best_prec
                

✅ 优势

  • 保证在给定的重建误差度量下获得 全局最优 分配。
  • 完全可解释,每个决策都有迹可循。
  • 适合写入论文或技术报告。

⚠️ 挑战

  • 状态空间:n_layers × total_bits_budget。对于 346 层 × 50 万 ≈ 1.7 亿,需内存优化(如只保留上一行)或分块处理。
  • 仍然依赖重建误差 err_i[k],未直接优化真实 PPL。
🧩 混合方案(推荐) 粗筛 + 精调 + 验证
flowchart LR A[重建误差粗筛] --> B[选出 top-20% 敏感层] B --> C[贝叶斯精调这些层] C --> D[固定其余层为低比特] D --> E[最终 PPL/MMLU 验证]
  • 步骤:先用现有 compute_sensitivity 得到各层重建误差,将前 20% 敏感层作为候选。
  • 精调:对候选层使用贝叶斯优化(搜索空间小),其余层直接压到 2 bit(或 3 bit)。
  • 验证:最终分配跑一次真实 PPL 和 MMLU,确认质量。
  • 优势:兼顾效率(避免全量搜索)与质量(聚焦关键层),同时保持工程简洁。
🔧 整合到现有 pipeline
扩展 allocate_bits 新接口

def allocate_bits_hybrid(sens, avg_bits, method='bayesian'):
    """
    支持三种分配模式:
    - 'greedy' : 原始贪心
    - 'bayesian' : 贝叶斯优化(全部层)
    - 'hybrid' : 粗筛 + 贝叶斯精调
    """
    if method == 'greedy':
        return allocate_bits(sens, avg_bits)
    elif method == 'bayesian':
        return bayesian_allocate(sens, avg_bits)
    elif method == 'hybrid':
        # 1. 按 err8 排序,选出 top 20% 敏感层
        sorted_layers = sorted(sens, key=lambda x: sens[x]['err8'], reverse=True)
        sensitive = sorted_layers[:int(0.2 * len(sorted_layers))]
        # 2. 其余层全部给 2 bit(或根据 avg_bits 调整)
        prec = {n: 2 for n in sens}
        # 3. 对敏感层做贝叶斯搜索
        return bayesian_allocate_with_fixed(sens, prec, sensitive, avg_bits)
                
📄 基于 quant.py + pipeline.py · 2026-08 ⚡ 浅色主题 · 语法高亮 · 流程辅助图 · 源码默认展开
华杉的技术博客 · 苏ICP备2026063680号-1
首页