4. 双重量化:QLoRA 中的双重量化机制,如何进一步压缩显存占用

好,咱们接着聊量化。上一节我们讲了 NF4 量化,能把模型权重压到每个参数 4-bit。但你可能已经发现了——量化参数本身也要占空间啊!

你想想看,NF4 量化需要存两类东西:量化常数(scale)和量化后的权重。如果每个 4-bit 权重都配一个 32-bit 的 scale,那这 overhead 可就大了去了。我刚开始做 QLoRA 实验时就踩过这个坑——算完显存一看,好家伙,scale 占的空间快赶上权重本身了。

那怎么办?QLoRA 的解决方案是:双重量化(Double Quantization)。说白了,就是对量化参数再做一次量化。

4.1 为什么需要双重量化?

先算一笔账。假设我们有一个 4096×4096 的权重矩阵,用 NF4 量化:

  • 权重本身:4096×4096 × 0.5 字节 = 8 MB
  • 如果每 64 个权重共享一个 scale(32-bit float):需要 4096×4096/64 × 4 字节 = 1 MB

嗯,1 MB 的 overhead 看起来还能接受?但别忘了,大模型有几十上百层。我算过一个 7B 模型,光 scale 就能吃掉 200+ MB 显存。这还没算上其他量化参数。

所以 QLoRA 的作者想了个办法:把 scale 从 32-bit 量化到 8-bit。这样一来,scale 的存储开销直接降到原来的 1/4。

核心思路:双重量化 = 对权重做 NF4 量化 + 对量化常数做 FP8 量化。

4.2 双重量化的具体流程

我个人习惯把双重量化拆成两个阶段来理解:

  1. 第一重量化:把 FP16 权重分成若干组,每组计算一个 scale,然后用 NF4 量化权重。
  2. 第二重量化:把第一层得到的 scale 集合起来,再分成更大的组,对 scale 做 FP8 量化。

这里有个关键点:两次分组的粒度不同。第一层分组通常比较细(比如 64 个权重一组),第二层分组比较粗(比如 256 个 scale 一组)。

为什么会这样设计?因为 scale 本身的变化比权重平缓得多。我在项目中测试过,scale 的分布通常很集中,用 8-bit 量化几乎不损失精度。

4.3 代码实现:双重量化

来看一个简化的实现。注意,这里我省略了 NF4 的具体编码逻辑,重点展示双重量化的结构:

import torch
import torch.nn as nn

def double_quantize(weight_fp16, group_size=64, second_group_size=256):
    """
    双重量化:先量化权重,再量化 scale
    """
    # 第一重量化:计算 scale 并量化权重
    orig_shape = weight_fp16.shape
    weight_flat = weight_fp16.flatten()
    
    # 按 group_size 分组
    n_groups = weight_flat.numel() // group_size
    weight_groups = weight_flat[:n_groups * group_size].view(n_groups, group_size)
    
    # 计算每个组的 scale(取绝对值最大值)
    scales_first = weight_groups.abs().max(dim=1).values  # FP32
    
    # 用 scale 归一化并量化到 NF4(这里用伪代码表示)
    # weight_nf4 = nf4_quantize(weight_groups / scales_first.unsqueeze(1))
    
    # 第二重量化:对 scale 做 FP8 量化
    n_second_groups = scales_first.numel() // second_group_size
    scales_second = scales_first[:n_second_groups * second_group_size].view(n_second_groups, second_group_size)
    
    # 计算第二层 scale
    scales_second_scale = scales_second.abs().max(dim=1).values  # FP32
    
    # 量化第一层 scale 到 FP8
    scales_first_fp8 = (scales_first / scales_second_scale.repeat_interleave(second_group_size))
    # 实际会转成 FP8 格式存储
    
    return {
        'weight_nf4': weight_nf4,          # NF4 量化后的权重
        'scales_first_fp8': scales_first_fp8,  # FP8 量化后的第一层 scale
        'scales_second': scales_second_scale,  # 第二层 scale(保持 FP32)
        'group_size': group_size,
        'second_group_size': second_group_size
    }

我的经验:实际实现时,第二层 scale 的数量很少(比如 7B 模型可能只有几千个),所以保留 FP32 完全没问题。真正省显存的是把第一层 scale 从 FP32 降到 FP8。

4.4 显存节省到底有多少?

咱们用数据说话。还是以 4096×4096 的矩阵为例:

方案 权重存储 Scale 存储 总计 节省比例
FP16 原始 32 MB 0 32 MB
单重量化 (NF4) 8 MB 1 MB (FP32) 9 MB 71.9%
双重量化 (NF4+FP8) 8 MB 0.25 MB (FP8) + 少量 FP32 ~8.3 MB 74.1%

你看,双重量化比单重量化又多省了约 2.2% 的显存。单个矩阵看起来不多,但放大到整个模型,效果就很可观了。

我曾经踩过的坑:双重量化在推理时确实省显存,但训练时要注意——反向传播需要计算梯度,而 FP8 的 scale 在反传时精度有限。我建议训练时保持第二层 scale 为 FP32,只在推理时用全 FP8 方案。

4.5 双重量化的知识结构

为了让你更直观地理解,我画了一张流程图:

双重量化流程图 FP16 权重矩阵 第一重量化 按 group_size=64 分组 → NF4 量化权重 NF4 权重 + FP32 Scale 集合 (Scale 数量 = 权重数 / 64) 第二重量化 按 second_group_size=256 分组 → FP8 量化 Scale 最终:NF4 权重 + FP8 Scale + 少量 FP32 第二层 Scale

4.6 实际应用中的注意事项

双重量化看起来简单,但落地时有几个细节要注意:

  • 分组大小要选好:group_size 太小(比如 32)会导致 scale 太多,第二重量化收益不大;太大(比如 256)又会影响量化精度。我一般用 64 或 128。
  • 第二层分组可以更粗:因为 scale 变化平缓,second_group_size 设到 256 甚至 512 都没问题。
  • 反量化时要小心:先反量化第二层 scale,再反量化第一层 scale,最后反量化权重。顺序不能乱。

我的建议:如果你用的是 Hugging Face 的 transformers 库,QLoRA 的双重量化已经内置了。直接设置 bnb_4bit_use_double_quant=True 就行。但如果你想自己实现,记得测试反量化后的精度损失——我遇到过因为 scale 量化太粗导致 loss 震荡的情况。

好了,双重量化就讲到这里。说白了就是一层套一层的量化,把能省的显存都省下来。下一节我们聊聊 QLoRA 的完整训练流程,到时候你会看到这些量化技巧是怎么协同工作的。


公众号:蓝海资料掘金营,微信deep3321