一句话: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_proj、up_proj、down_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 模型 |