一句话:LoRA 用低秩矩阵大幅减少微调参数量;QLoRA 在此基础上把基础模型量化到 4bit,让消费级 GPU 能微调 70B 模型;Unsloth 是工程加速库,把整个流程再提速 2-5×。三者是层层叠加的关系。


一、为什么需要参数高效微调(PEFT)?

全量微调(Full Fine-tuning)的问题:

GPT-3(175B 参数)全量微调:
  - 参数量:175B × 4 bytes(FP32)= 700 GB 显存
  - 梯度:再 × 1 = 700 GB
  - 优化器状态(Adam):再 × 2 = 1400 GB
  - 合计:约 2800 GB 显存 → 需要 35 张 A100(80GB)

核心矛盾:预训练模型越来越大,但大多数下游任务只需要微调模型的"方向",不需要改变所有参数。

参数高效微调(PEFT) 的思路:冻结大部分参数,只训练少量新增参数,效果接近全量微调,但显存和计算量大幅减少。


二、LoRA:低秩适配(Low-Rank Adaptation)

核心思想

LoRA(2021,微软)的关键洞察:预训练模型的权重更新矩阵是低秩的

全量微调时,权重更新为:

$$W’ = W_0 + \Delta W$$

其中 $W_0 \in \mathbb{R}^{d \times k}$ 是预训练权重,$\Delta W$ 是更新量。

LoRA 假设 $\Delta W$ 是低秩的,用两个小矩阵的乘积来近似:

$$\Delta W = B A, \quad A \in \mathbb{R}^{r \times k},\ B \in \mathbb{R}^{d \times r},\ r \ll \min(d, k)$$

前向传播

$$h = W_0 x + \Delta W x = W_0 x + B A x$$

  • $W_0$:冻结,不参与梯度计算
  • $A$、$B$:可训练,参数量极少

参数量对比

以 GPT-3 的某一层为例,$d = k = 12288$:

全量微调:d × k = 12288 × 12288 = 150,994,944 个参数
LoRA(r=8):r×k + d×r = 8×12288 + 12288×8 = 196,608 个参数
压缩比:约 768:1

初始化方式

  • $A$:随机高斯初始化(保证初始时有信号)
  • $B$:全零初始化(保证训练开始时 $\Delta W = BA = 0$,不破坏预训练权重)

缩放因子 α

实际使用时加一个缩放因子:

$$h = W_0 x + \frac{\alpha}{r} B A x$$

  • $\alpha$:超参数,通常设为 $r$ 或 $2r$
  • $\frac{\alpha}{r}$:让不同 $r$ 值下的学习率等效,方便调参

应用到哪些层?

LoRA 通常应用于 Transformer 的注意力层权重:

Q 投影矩阵 W_Q  ← 加 LoRA
K 投影矩阵 W_K  ← 加 LoRA
V 投影矩阵 W_V  ← 加 LoRA
输出投影 W_O    ← 加 LoRA(可选)
FFN 层          ← 可选,通常不加

推理时的合并

训练完成后,可以把 LoRA 权重合并回原始权重,推理时零额外开销

$$W’ = W_0 + BA$$

合并后的模型和原始模型结构完全相同,不需要任何特殊推理代码。

完整代码实现

import math
import torch
import torch.nn as nn
import torch.nn.functional as F

class LoRALinear(nn.Module):    """
    带 LoRA 的线性层。
    原始权重 W_0 冻结,只训练低秩矩阵 A 和 B。
    """
    def __init__(
        self,
        in_features: int,
        out_features: int,
        rank: int = 8,
        alpha: float = 16.0,
        dropout_rate: float = 0.0,
    ):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.rank = rank
        self.scaling = alpha / rank  # α/r 缩放因子

        # 原始权重:冻结
        self.weight = nn.Parameter(
            torch.randn(out_features, in_features) * 0.02,
            requires_grad=False,  # 冻结!
        )

        # LoRA 矩阵:可训练
        # A 用 Kaiming-uniform 初始化(与 nn.Linear 默认初始化一致,PEFT 库标准做法)
        # 不用高斯 * 0.02,因为 Kaiming-uniform 能更好地保持各层激活值的方差
        self.lora_A = nn.Parameter(torch.empty(rank, in_features))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))  # B 初始化为 0,保证 ΔW=0

        # 可选的 dropout(防止 LoRA 过拟合)
        self.lora_dropout = nn.Dropout(dropout_rate) if dropout_rate > 0 else nn.Identity()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # 原始路径(冻结权重)
        base_output = F.linear(x, self.weight)

        # LoRA 路径:x → A → B,乘以缩放因子
        lora_output = F.linear(self.lora_dropout(x), self.lora_A)  # [*, rank]
        lora_output = F.linear(lora_output, self.lora_B)            # [*, out_features]

        return base_output + self.scaling * lora_output

    def merge_weights(self) -> nn.Linear:
        """
        将 LoRA 权重合并到原始权重,返回标准 Linear 层(推理用)。
        合并后推理无额外开销。
        """
        merged_weight = self.weight + self.scaling * (self.lora_B @ self.lora_A)
        merged_layer = nn.Linear(self.in_features, self.out_features, bias=False)
        merged_layer.weight = nn.Parameter(merged_weight)
        return merged_layer


def apply_lora_to_attention(model: nn.Module, rank: int = 8, alpha: float = 16.0) -> nn.Module:
    """
    将模型中所有名为 q_proj、k_proj、v_proj、o_proj 的线性层替换为 LoRA 版本。
    其余参数全部冻结。
    """
    # 先冻结所有参数
    for param in model.parameters():
        param.requires_grad = False

    # 替换目标层为 LoRA 版本
    target_layer_names = {"q_proj", "k_proj", "v_proj", "o_proj"}
    for module_name, module in model.named_modules():
        for layer_name in target_layer_names:
            if hasattr(module, layer_name):
                original_layer = getattr(module, layer_name)
                if isinstance(original_layer, nn.Linear):
                    lora_layer = LoRALinear(
                        in_features=original_layer.in_features,
                        out_features=original_layer.out_features,
                        rank=rank,
                        alpha=alpha,
                    )
                    # 复制原始权重(冻结)
                    lora_layer.weight.data = original_layer.weight.data.clone()
                    setattr(module, layer_name, lora_layer)

    return model


def count_trainable_params(model: nn.Module) -> tuple[int, int]:
    """返回 (可训练参数量, 总参数量)"""
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    return trainable, total


# ── 验证 ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    torch.manual_seed(42)

    # 验证 LoRALinear 的前向传播和权重合并
    lora_layer = LoRALinear(in_features=512, out_features=512, rank=8, alpha=16.0)
    x = torch.randn(2, 10, 512)

    output = lora_layer(x)
    print(f"LoRA 输出形状: {output.shape}")  # [2, 10, 512]

    # 验证合并前后输出一致
    merged_layer = lora_layer.merge_weights()
    output_merged = merged_layer(x)
    max_diff = (output - output_merged).abs().max().item()
    print(f"合并前后最大误差: {max_diff:.2e}")  # 应接近 0

    # 统计参数量
    trainable_params = sum(p.numel() for p in lora_layer.parameters() if p.requires_grad)
    total_params = sum(p.numel() for p in lora_layer.parameters())
    frozen_params = total_params - trainable_params
    print(f"可训练参数: {trainable_params:,}  ({trainable_params/total_params*100:.1f}%)")
    print(f"冻结参数:   {frozen_params:,}  ({frozen_params/total_params*100:.1f}%)")

三、QLoRA:量化 + LoRA

核心思想

QLoRA(2023,华盛顿大学)解决的问题:LoRA 虽然减少了可训练参数,但基础模型本身还是 FP16/BF16,70B 模型仍需 140GB 显存

QLoRA 的三个关键技术:

技术 1:NF4(4-bit NormalFloat)量化

把基础模型权重从 BF16(16bit)量化到 NF4(4bit),显存减少 4×。

NF4 不是普通的 INT4,而是专门为神经网络权重设计的:

神经网络权重通常服从正态分布 N(0, σ²)
NF4 的量化点是正态分布的等分位数点(quantile):
  → 在权重密集的区域(靠近 0)分配更多量化级别
  → 在权重稀疏的区域(远离 0)分配更少量化级别
  → 比均匀量化的 INT4 精度更高

NF4 的 16 个量化值(归一化到 [-1, 1],来自 QLoRA 论文 Appendix E 及 bitsandbytes 实现):

NF4_levels = [
    -1.0,
    -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,
]

这些值由 scipy.stats.norm.ppf(正态分布的逆累积分布函数)生成等分位数后归一化到 [-1, 1] 得到。注意分布是非对称的:有 9 个非负值(含 0)和 7 个负值,因为 QLoRA 使用非对称量化以确保 0.0 能被精确表示(零点 Z 映射到固定的非零量化值)。

技术 2:双重量化(Double Quantization)

量化本身需要存储"量化常数"(scale factor),QLoRA 对量化常数再做一次量化:

第一次量化:BF16 权重 → NF4(量化常数用 FP32 存储)
第二次量化:FP32 量化常数 → FP8(再节省约 0.37 bit/参数)

技术 3:分页优化器(Paged Optimizer)

使用 NVIDIA 的统一内存(Unified Memory),当 GPU 显存不足时,自动把优化器状态换页到 CPU 内存,避免 OOM。

QLoRA 的训练流程

基础模型(BF16)
    ↓ 量化(NF4 + 双重量化)
基础模型(NF4,冻结)          ← 显存:原来的 1/4
    ↓ 添加 LoRA 适配器(BF16,可训练)
前向传播:
  NF4 权重 → 反量化到 BF16 → 正常计算
  LoRA 路径:BF16 全精度计算
反向传播:
  只对 LoRA 参数计算梯度(BF16)
  基础模型梯度不保存

关键点:计算时临时反量化到 BF16,但存储时保持 NF4,所以显存占用是 NF4 级别的。

显存对比(以 LLaMA-65B 为例)

方法 显存需求 所需 GPU
全量微调(BF16) ~780 GB 10× A100 80G
LoRA(BF16 基础模型) ~130 GB 2× A100 80G
QLoRA(NF4 基础模型) ~48 GB 1× A100 80G
QLoRA + Unsloth ~38 GB 1× A100 80G

四、Unsloth:工程加速

Unsloth 是什么

Unsloth 是一个开源库(Daniel Han 等人,2023),不改变 LoRA/QLoRA 的算法,而是通过以下工程手段提速:

手段 1:手写 Triton kernel

用 OpenAI Triton(GPU 编程语言)重写了 LoRA 的前向/反向传播,比 PyTorch 默认实现快 2-3×:

PyTorch 默认:
  x → Linear(A) → 中间结果存到 HBM → Linear(B) → 输出
  (两次 HBM 读写)

Unsloth Triton kernel:
  x → Linear(A) → 直接在 SRAM 中 → Linear(B) → 输出
  (一次 HBM 读写,减少 IO 瓶颈)

手段 2:RoPE 和 RMSNorm 的融合 kernel

把 RoPE 位置编码和 RMSNorm 的计算融合进单个 kernel,减少 kernel 启动开销和中间结果的显存读写。

手段 3:梯度检查点优化

标准梯度检查点(Gradient Checkpointing)会重新计算前向传播以节省显存,但 Unsloth 只对部分层做检查点,并缓存计算代价高的中间结果,在显存和速度之间取得更好的平衡。

Unsloth 的使用方式

from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset
import torch

# 1. 加载模型(自动应用 QLoRA + Unsloth 优化)
# 模型名称末尾带 "unsloth-bnb-4bit" 的是 Unsloth 动态 4bit 量化版,精度优于标准 bnb-4bit
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/llama-3.1-8b-unsloth-bnb-4bit",
    max_seq_length=2048,
    dtype=None,          # None = 自动选择(新 GPU 用 BF16,旧 GPU 用 FP16)
    load_in_4bit=True,   # QLoRA:4bit 量化基础模型
)

# 2. 添加 LoRA 适配器
model = FastLanguageModel.get_peft_model(
    model,
    r=16,                # LoRA rank,从 16 开始试
    target_modules=[     # 应用 LoRA 的层(包含 FFN 效果更好)
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_alpha=16,       # α = r,scaling = 1.0,简化调参
    lora_dropout=0.0,    # Unsloth 的 Triton kernel 假设无 dropout,设为 0
    bias="none",         # 不训练 bias
    use_gradient_checkpointing="unsloth",  # Unsloth 优化版梯度检查点,比标准版省更多显存
    random_state=42,
)

# 3. 准备数据集
# Unsloth 推荐用 formatting_func 动态格式化,避免提前 map 整个数据集
alpaca_prompt = """Below is an instruction that describes a task.
Write a response that appropriately completes the request.

### Instruction:
{}

### Response:
{}"""

def formatting_func(examples):
    """将数据集的每条样本格式化为训练文本,末尾加 EOS token。"""
    texts = []
    for instruction, output in zip(examples["instruction"], examples["output"]):
        text = alpaca_prompt.format(instruction, output) + tokenizer.eos_token
        texts.append(text)
    return texts

dataset = load_dataset("yahma/alpaca-cleaned", split="train")

# 4. 训练
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    formatting_func=formatting_func,  # 动态格式化,比 dataset_text_field 更灵活
    max_seq_length=2048,
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,  # 等效 batch_size = 8
        warmup_steps=5,
        num_train_epochs=1,
        learning_rate=2e-4,
        fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=1,
        optim="adamw_8bit",             # 8bit Adam,进一步节省显存
        output_dir="outputs",
    ),
)

trainer.train()

# 5. 保存 LoRA adapter(只有几十 MB,不含基础模型)
model.save_pretrained("lora_adapter")
tokenizer.save_pretrained("lora_adapter")

# 6. 推理:加载 adapter 并切换到推理模式
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="lora_adapter",
    max_seq_length=2048,
    load_in_4bit=True,
)
FastLanguageModel.for_inference(model)  # 必须调用,启用 Unsloth 的推理加速

inputs = tokenizer(
    [alpaca_prompt.format("Explain LoRA in one sentence.", "")],
    return_tensors="pt",
).to("cuda")

outputs = model.generate(**inputs, max_new_tokens=128)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

五、LoRA 的超参数选择指南

rank r 的选择

任务类型 推荐 r 说明
简单指令跟随 8-16 任务简单,低秩足够
领域知识注入 16-64 需要更多容量
代码生成 32-64 代码任务复杂度高
多任务微调 64-128 需要覆盖多种能力

经验规则:r 越大,可训练参数越多,效果上限越高,但过拟合风险也越大。通常从 r=16 开始试。

alpha α 的选择

  • 通常设为 α = rα = 2r
  • Unsloth 推荐 α = r(即 scaling = 1.0),简化调参
  • 实际上 α 和学习率是耦合的,调一个等价于调另一个

应用到哪些层

只加 Q、V(原始 LoRA 论文):参数最少,效果一般
加 Q、K、V、O(常见做法):效果更好
加所有线性层(包括 FFN):效果最好,参数也最多

Unsloth 默认推荐加所有线性层(gate_projup_projdown_proj 也加)。


六、保存和加载 LoRA 权重的方式

方式 1:只保存 LoRA adapter(推荐)

# 保存:只有几十 MB
model.save_pretrained("lora_adapter")

# 加载:需要同时加载基础模型 + adapter
from transformers import AutoModelForCausalLM
from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3-8B")
model = PeftModel.from_pretrained(base_model, "lora_adapter")

方式 2:合并后保存完整模型

# 合并 LoRA 权重到基础模型(推理时无额外开销)
merged_model = model.merge_and_unload()
merged_model.save_pretrained("merged_model")  # 完整模型,几 GB

方式 3:保存为 GGUF 格式(本地推理用)

# Unsloth 支持直接导出 GGUF,供 llama.cpp / Ollama 使用
model.save_pretrained_gguf(
    "model_gguf",
    tokenizer,
    quantization_method="q4_k_m",  # 4bit 量化,平衡精度和速度
)

七、三者的完整对比

LoRA QLoRA QLoRA + Unsloth
基础模型精度 BF16/FP16 NF4(4bit) NF4(4bit)
LoRA 精度 BF16 BF16 BF16
显存(7B 模型) ~16 GB ~6 GB ~5 GB
训练速度 基准 略慢(反量化开销) 快 2-5×(Triton kernel)
效果损失 极小 小(NF4 精度损失) 同 QLoRA
适用 GPU A100/H100 消费级(RTX 3090/4090) 消费级
安装复杂度 简单(pip install peft 中等(需要 bitsandbytes) 简单(pip install unsloth

八、核心要点速查

问题 答案
LoRA 的核心公式? $\Delta W = BA$,$A \in \mathbb{R}^{r \times k}$,$B \in \mathbb{R}^{d \times r}$,$r \ll d,k$
为什么 B 初始化为 0? 保证训练开始时 $\Delta W = 0$,不破坏预训练权重
α/r 缩放因子的作用? 让不同 rank 下的学习率等效,方便调参
QLoRA 用什么量化? NF4(4-bit NormalFloat),基于正态分布分位数设计
双重量化是什么? 对量化常数再做一次量化,额外节省 0.37 bit/参数
Unsloth 怎么加速的? 手写 Triton kernel,减少 HBM 读写;融合 RoPE/RMSNorm kernel
推理时 LoRA 有额外开销吗? 合并后无开销;不合并则有一次额外矩阵乘法
消费级 GPU 能微调多大模型? RTX 4090(24GB)+ QLoRA + Unsloth 可微调 70B 模型