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 双重量化的具体流程
我个人习惯把双重量化拆成两个阶段来理解:
- 第一重量化:把 FP16 权重分成若干组,每组计算一个 scale,然后用 NF4 量化权重。
- 第二重量化:把第一层得到的 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 双重量化的知识结构
为了让你更直观地理解,我画了一张流程图:
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