Skip to content
传衡博客
返回

LoRA 与 QLoRA:从低秩适配到双重量化

参考资料
  1. LoRA: Low-Rank Adaptation of Large Language Models
  2. QLoRA: Efficient Finetuning of Quantized LLMs
  3. Unsloth Documentation - Quantization
  4. BitsAndBytes 4-bit Quantization

LoRA 把微调所需的训练参数从 175B 压缩到 0.1% 以下,QLoRA 则在此基础上叠加上 4-bit 量化和分页优化器,让 65B 参数的 Llama 能在单张 48GB 显卡上完成全参数微调。显存占用从 780GB 降到 48GB,压缩比超过 16 倍。

全量微调的瓶颈

LLaMA-65B 的全量微调需要约 780GB 显存。这个数字来自三个部分的叠加:

即使使用 ZeRO-3 这类分布式优化,也需要至少 10 张 A100。这使得中小团队几乎无法触碰大模型的微调。

LoRA:低秩适配

LoRA 的核心假设是:微调过程中权重的实际变化量 ΔW\Delta W 是低秩的[1]。与其更新全部参数,不如冻结原权重 W0W_0,只训练两个低秩矩阵的乘积:

h=W0x+ΔWx=W0x+BAxh = W_0 x + \Delta W x = W_0 x + BAx

其中 BRd×rB \in \mathbb{R}^{d \times r}ARr×kA \in \mathbb{R}^{r \times k},秩 rmin(d,k)r \ll \min(d, k)。通常 rr 取 8 或 16。

以 LLaMA-65B 的 q_proj 层为例,输入维度 8192,输出维度 8192。全量更新需要 8192² ≈ 6700 万参数,而 LoRA (r=16) 只需要 8192×16×2 = 26.2 万参数,压缩比 256:1。

训练时的内存结构

组件全量微调LoRA (r=16)
模型权重130 GB130 GB
可训练参数0 GB0.2 GB
梯度260 GB0.4 GB
优化器状态780 GB0.6 GB
合计1170 GB131 GB

LoRA 把训练内存从 1170GB 压到 131GB,单张 A100 就能跑。但模型权重本身的 130GB 仍然是个硬门槛。

量化基础:从 Int4 开始

在深入 QLoRA 之前,先理解什么是量化。量化的本质是用更少的 bit 来表示数值,从而节省存储空间。

为什么需要量化

LLaMA-65B 的每个参数默认用 16-bit 浮点数 (BF16) 存储,占用 2 字节。130GB 显存中,模型权重独占 130GB。如果能用 4-bit 表示每个参数,显存占用就能降到 32GB——单张 RTX 4090 都能装下。

线性 Int4 量化:最直观的方案

4-bit 能表示 24=162^4 = 16 个不同的值。最简单的思路是线性映射:把浮点数范围均匀切成 16 份。

假设我们有一组权重:

# 原始权重(BF16,每个 2 字节)
weights = [0.5, -0.3, 1.2, -0.8, 0.1, -0.4, 0.9, -0.6]

# 第 1 步:确定取值范围
w_min, w_max = min(weights), max(weights)  # -0.8, 1.2

# 第 2 步:归一化到 [0, 15] 的整数范围
# 公式:q = round((x - min) / (max - min) * 15)
quantized = []
for w in weights:
    normalized = (w - w_min) / (w_max - w_min)  # 映射到 [0, 1]
    q = int(round(normalized * 15))             # 映射到 [0, 15]
    quantized.append(q)

# quantized = [8, 5, 15, 0, 6, 4, 13, 2]
# 现在每个权重只需要 4 个 bit 存储

存储时,我们保存:

总计 12 字节,原始需要 16 字节,压缩比 4:3。

反量化:计算时还原精度

前向传播需要浮点数计算,所以要反量化:

def dequantize(q, w_min, w_max):
    """把 4-bit 整数还原为浮点数"""
    return w_min + (q / 15.0) * (w_max - w_min)

# 还原后的值
restored = [dequantize(q, w_min, w_max) for q in quantized]
# [0.52, -0.32, 1.2, -0.8, 0.08, -0.4, 0.92, -0.56]

注意还原值和原始值有细微差异(如 0.5 变成 0.52),这就是量化误差。

线性量化的局限

大模型权重服从零均值正态分布:大部分值集中在 0 附近,极端值很少。线性量化的 16 个值均匀分布,导致:

假设权重分布是 N(0,0.3)N(0, 0.3),-0.8 到 0.8 之间的值占 99%,但线性量化的 16 个值均匀分布在 [-0.8, 1.2] 整个范围。0 附近(-0.1 到 0.1)的数以亿计的权重,只能映射到 2-3 个量化值上。

这就是 QLoRA 要解决的问题:用非均匀分布的量化值,把更多的精度分配给概率密度更高的区域。

QLoRA:两阶段量化

QLoRA 在 LoRA 基础上做了三件事:4-bit Normal Float (NF4) 量化、双重量化、分页优化器[2]。其中前两者的组合让 65B 模型仅需 35-40GB 显存。

4-bit Normal Float:信息论最优量化

NF4 的核心改进是非均匀量化:量化值的分布匹配权重分布的概率密度。对于服从 N(0,1)N(0, 1) 的权重,NF4 的 16 个值是标准正态分布的 16 个分位数:

Normal Float (NF4) 采用信息论最优的分位数量化。假设权重服从 N(0,1)N(0, 1),NF4 的 16 个量化值是标准正态分布的 16 个分位数:

# NF4 的 16 个量化值(对称分布,只展示正半部分)
nf4_values = [
    -1.0,    # -∞ ~ -0.67σ 的分位数边界
    -0.6961928009986877,
    -0.5250730514526367,
    -0.39491748809814453,
    -0.28444138169288635,
    -0.18477343022823334,
    -0.09105003625154495,
     0.0,
     0.07958029955625534,
     0.16093020141124725,
     0.24611230194568634,
     0.33791524171829224,
     0.44070982933044434,
     0.5626170039176941,
     0.7229568362236023,
     1.0,
]

每个 4-bit 值索引到这 16 个预定义浮点数之一。存储时只存索引 (0-15),计算时再查表还原。

分块量化:用一个例子理解

QLoRA 不会一次性量化整个权重矩阵,而是分块处理(默认块大小 64)。这样做有两个原因:

  1. 局部统计特性:不同层的权重分布方差不同,分块量化能更好地适应
  2. 量化常数:每块需要一个缩放因子 absmax 来还原原始数值范围

假设我们有一个简化版的权重列表(只取 8 个值演示):

# 原始权重(BF16 存储,每个 2 字节)
weights = [0.5, -0.3, 1.2, -0.8, 0.1, -0.4, 0.9, -0.6]

# 第 1 步:找到绝对值最大值
absmax = max(abs(w) for w in weights)  # 1.2

# 第 2 步:归一化到 [-1, 1]
normalized = [w / absmax for w in weights]
# [0.4167, -0.25, 1.0, -0.6667, 0.0833, -0.3333, 0.75, -0.5]

# 第 3 步:量化到 4-bit(找最近的 NF4 值)
def quantize_to_nf4(x):
    """找到最近的 NF4 量化值并返回索引"""
    closest_idx = min(range(16), key=lambda i: abs(nf4_values[i] - x))
    return closest_idx

quantized_indices = [quantize_to_nf4(x) for x in normalized]
# [9, 6, 15, 3, 7, 5, 12, 4]  <- 每个只需 4 个 bit 存储

# 存储结构:
# - 量化常数 absmax: 32-bit float (4 字节)
# - 8 个 4-bit 索引: 32 bit = 4 字节
# 总计 8 字节,原始需要 16 字节,压缩比 2:1

双重量化:压缩量化常数

上面的例子中,每 64 个权重就需要一个 32-bit 的 absmax。对于 65B 模型,量化常数本身就要占用约 500MB。

QLoRA 的第二层量化把这些 absmax 也量化了:

# 第一层量化:每块一个 absmax
block_absmax_values = [1.2, 0.8, 1.5, 0.6, ...]  # 大量 32-bit 浮点数

# 第 2.1 步:把 absmax 也分块(每 256 个 absmax 一块)
# 找出这块 absmax 的最大值作为二级量化常数
second_block = block_absmax_values[:256]
second_absmax = max(second_block)  # 比如 1.8

# 第 2.2 步:把这 256 个 absmax 量化为 8-bit
# 8-bit 可以表示 0-255,映射到 [0, second_absmax]
quantized_absmax = [
    int((x / second_absmax) * 255) for x in second_block
]
# 每个 absmax 从 32-bit 降到 8-bit,额外节省 4x

双重量化后,量化常数的存储开销从约 500MB 降到约 125MB。

反量化计算

前向传播时需要还原 FP16/BF16 精度:

def dequantize(quantized_idx, absmax, second_absmax, quantized_absmax_idx):
    """从 4-bit 索引还原到浮点数"""
    # 第 1 步:还原一级 absmax(从 8-bit)
    block_absmax = (quantized_absmax_idx / 255.0) * second_absmax

    # 第 2 步:还原权重值
    nf4_value = nf4_values[quantized_idx]
    weight = nf4_value * block_absmax
    return weight

这个反量化在前向传播时动态进行,权重本身始终保持 4-bit 存储。

分页优化器:平滑显存峰值

Adam 优化器在更新步骤中需要同时保留:当前梯度、一阶动量、二阶动量。这三者在某些时刻会同时存在于显存中,形成峰值。

Paged Optimizers 利用 NVIDIA 的统一内存机制,把暂时不用的优化器状态分页换出到 CPU 内存,需要时再异步加载。这带来了约 1-2% 的训练速度损失,但消除了显存峰值,让训练能在更小的 GPU 上稳定运行。

完整显存对比

配置全量微调LoRAQLoRA
模型权重130 GB (BF16)130 GB32 GB (4-bit)
可训练参数-0.2 GB0.2 GB
梯度260 GB0.4 GB0.4 GB
优化器状态780 GB0.6 GB0.6 GB
激活值 (4K 长度)~50 GB~50 GB~50 GB
合计 (65B 模型)~1220 GB~181 GB~48 GB
所需 GPU16× A1003× A1001× A6000

QLoRA 让 65B 模型的微调门槛从 16 张 A100 降到单张消费级显卡。

训练代码

QLoRA 的典型配置(使用 transformers + peft):

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 4-bit 量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,      # 启用双重量化
    bnb_4bit_quant_type="nf4",           # Normal Float 4
)

# 加载量化模型
model = AutoModelForCausalModel.from_pretrained(
    "meta-llama/Llama-2-65b-hf",
    quantization_config=bnb_config,
    device_map="auto",
)

# 为量化模型准备训练
model = prepare_model_for_kbit_training(model)

# LoRA 配置
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)

# 启用分页优化器
training_args = TrainingArguments(
    per_device_train_batch_size=1,
    gradient_accumulation_steps=4,
    optim="paged_adamw_8bit",
    # ...
)qlora_train.py

关键配置:bnb_4bit_use_double_quant=True 启用双重量化,optim="paged_adamw_8bit" 启用分页优化器。

局限与权衡

QLoRA 的 4-bit 量化会引入约 0.1-0.5% 的精度损失(取决于任务)。在大多数下游微调任务中,这种损失可以忽略不计。但如果你在极度敏感的任务(如数学推理)上追求 SOTA,可能需要考虑:

QLoRA 的真正价值在于降低门槛。它让个人研究者和中小团队能够在消费级硬件上实验 65B+ 模型,而不必依赖昂贵的云计算集群。



Previous Post
MHA vs MQA vs GQA vs MLA:四种 Attention 机制显存与性能全对比
Next Post
FlashAttention2 原理与数值推导