外观
PEFT 框架 LoRA 源码剖析与实践
内容整理自学习笔记,仅供面试备考参考;不构成录用、培训或考试承诺。
1. 如何配置 LoraConfig?
python
from peft import LoraConfig
LORA_R = 8
LORA_ALPHA = 16
LORA_DROPOUT = 0.05
TARGET_MODULES = ["q_proj", "v_proj"]
config = LoraConfig(
r=LORA_R, # LoRA 的秩,A 和 B 矩阵连接的宽度
lora_alpha=LORA_ALPHA, # 缩放超参数,ΔWx 以 α/r 归一化
target_modules=TARGET_MODULES, # LoRA 的目标模块名
lora_dropout=LORA_DROPOUT, # LoRA 层的 Dropout 比率
bias="none", # bias 训练方式:none/all/lora_only
task_type="CAUSAL_LM", # 任务类型(PeftConfig 父类参数)
)注意:
target_modules中模块名在不同模型中不同。例如 ChatGLM 中是query_key_value,LLaMA 中是q_proj和v_proj。
LoraConfig 参数详解
| 参数 | 说明 |
|---|---|
r | LoRA 的秩,r << d |
lora_alpha | 归一化超参数,减少改变 r 时需重新训练的计算量 |
target_modules | LoRA 作用的目标模块列表 |
merge_weights | Eval 模式中是否将 LoRA 值合并到 W₀ |
lora_dropout | LoRA 层的 Dropout 比率 |
fan_in_fan_out | 仅当应用在 Conv1D 层时置 True |
bias | none(均不可训练)/ all(均可)/ lora_only(仅 LoRA 部分) |
task_type | 设定任务类型 |
modules_to_save | 除 LoRA 外,还需要训练并保存的层 |
2. 模型显存占用分析
2.1 显存占用的两部分
| 类型 | 说明 |
|---|---|
| 静态显存 | 由模型参数量级决定(加载模型的权重) |
| 动态显存 | 前向传播中每个样本的每个神经元计算并存储激活值,用于反向传播的梯度计算,与 Batch Size 和参数量级相关 |
2.2 显存优化策略
| 策略 | 优化目标 |
|---|---|
| 8 bit 量化 | 优化静态显存 |
| 梯度检查(Gradient Checkpointing) | 优化动态显存 |
3. 8 bit 量化(load_in_8bit)
3.1 原理
模型量化是对浮点参数进行压缩的同时降低压缩带来的误差。
| 精度 | 每参数占用 |
|---|---|
| FP32 | 4 bytes |
| FP16 | 2 bytes |
| INT8 | 1 byte(FP32 的 1/4) |
python
from transformers import AutoModel
model = AutoModel.from_pretrained(
"THUDM/chatglm3-6b",
load_in_8bit=True,
torch_dtype=torch.float16,
trust_remote_code=True,
device_map="auto"
)3.2 Absmax 量化方案
- 寻找 Tensor 矩阵的绝对值的最大值
- 计算最大值到 127 的缩放因子
- 使用缩放因子对整个 Tensor 进行缩放后 Round 到整数
3.3 LLM.int8() 对 Outlier 的优化
当 Tensor 中存在极端值(Outlier)时,精度损失会被放大。LLM.int8() 的做法是:
- 将 Outlier 和非 Outlier 矩阵分开计算
- 再把结果合并,降低 Outlier 对精度的影响
3.4 prepare_model_for_int8_training
python
from peft import prepare_model_for_int8_training
model = prepare_model_for_int8_training(model)主要做了两件事:
- Layer Norm 层保留 FP32 精度
- 输出层保留 FP32 精度,保证解码时随机 Sample 的差异性
4. 梯度检查(Gradient Checkpointing)
4.1 原理
在前向传播中使用 torch.no_grad() 不去存储中间激活值,只保存输入和激活函数。当反向传播时,重新获取输入和激活函数计算激活值用于梯度计算。
正常训练:前向 → 存中间激活 → 反向(计算量:1×前向 + 1×反向)
Grad CKPT:前向 → 丢中间激活 → 反向时重新计算前向 → 反向(计算量:2×前向 + 1×反向)时间换空间:前向传播计算两遍,需要更多训练时间,但大幅降低动态显存占用。
4.2 与 use_cache 冲突
use_cache 是对解码速度的优化,存储每一步输出的 Hidden-State 用于下一步输入。但开启 Gradient Checkpoint 后中间激活值不存储,因此必须设置 use_cache=False。
5. PEFT 库 LoRA 模块整体实现思路
python
class LoraModel(torch.nn.Module):
def __init__(self, config, model):
super().__init__()
self.peft_config = config
self.model = model
self._find_and_replace() # 第一步:找到并替换为 LoRA 层
mark_only_lora_as_trainable(self.model, self.peft_config.bias) # 第二步:标记可训练参数
self.forward = self.model.forward| 步骤 | 说明 |
|---|---|
_find_and_replace() | 找到所有需加入 LoRA 的层(如 q_proj),替换为 LoRA 模式 |
mark_only_lora_as_trainable() | 保留 LoRA 部分参数可训练,其余参数固定 |
6. _find_and_replace() 实现细节
- 找到目标层:用正则匹配
target_modules(如 "q_proj", "v_proj") - 创建新 LoRA 层:
new_module = Linear(target.in_features, target.out_features, bias=bias, **kwargs) - 替换原层:
setattr(parent_module, child_name, new_module),并将原weight和bias赋值给新模块
7. LoRA 层(Linear)的实现细节
7.1 基类 LoraLayer
python
class LoraLayer:
def __init__(self, r, lora_alpha, lora_dropout, merge_weights):
self.r = r
self.lora_alpha = lora_alpha
self.lora_dropout = nn.Dropout(p=lora_dropout) if lora_dropout > 0 else lambda x: x
self.merged = False
self.merge_weights = merge_weights
self.disable_adapters = False7.2 Linear 层(同时继承 nn.Linear 和 LoraLayer)
初始化参数:
lora_A:nn.Linear(in_features, r, bias=False)→ Kaiming 均匀初始化lora_B:nn.Linear(r, out_features, bias=False)→ 全 0 初始化scaling = lora_alpha / r
注意:论文中 A 采用 Gaussian 初始化,PEFT 库使用 Kaiming 初始化。Kaiming 初始化专为 ReLU 族非线性激活函数设计,与 Xavier(适用于 tanh/sigmoid 等对称激活函数)是两种不同的初始化策略
7.3 Forward 实现
python
def forward(self, x):
if self.r > 0 and not self.merged:
result = F.linear(x, self.weight, bias=self.bias)
result += self.lora_B(self.lora_A(self.lora_dropout(x))) * self.scaling
return result
else:
return F.linear(x, self.weight, bias=self.bias)8. Train/Eval 中的 Merge 逻辑
- Eval 模式 + merge_weights=True:将
lora_B @ lora_A * scaling加到self.weight上 - Train 模式:从
self.weight减去之前 Merge 的值,恢复独立 LoRA 计算
python
def train(self, mode=True):
if not mode and self.merge_weights and not self.merged:
# Eval 模式:合并 LoRA 权重到主权重
self.weight.data += transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
self.merged = True
elif self.merge_weights and self.merged:
# 回到 Train 模式:减去已合并的 LoRA 权重
self.weight.data -= transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
self.merged = False9. LoRA 模型的保存
PEFT 的 PeftModel 重写了 save_pretrained,只存储 LoRA 层权重。
对于 Trainer,需重写 save_model 函数避免保存完整参数:
python
class ModifiedTrainer(Trainer):
def save_model(self, output_dir=None, _internal_call=False):
os.makedirs(output_dir, exist_ok=True)
torch.save(self.args, os.path.join(output_dir, TRAINING_ARGS_NAME))
saved_params = {
k: v.to("cpu") for k, v in self.model.named_parameters() if v.requires_grad
}
torch.save(saved_params, os.path.join(output_dir, "adapter_model.bin"))10. LoRA 模型推理加载
方案一:直接加载 LoRA 层(推荐线下测评)
python
from peft import PeftModel
model = PeftModel.from_pretrained(model, "./lora_ckpt")
model.eval()缺点:增加推理延时(多了 LoRA 层计算)
方案二:先合并再推理(推荐生产部署)
python
lora_model = PeftModel.from_pretrained(model, "./lora_ckpt")
lora_model = lora_model.merge_and_unload() # 合并权重
lora_model.train(False)
# 移除 prefix 恢复原始 key
deloreanized_sd = {
k.replace("base_model.model.", ""): v
for k, v in lora_model.state_dict().items() if "lora" not in k
}
lora_model.save_pretrained(output_dir, state_dict=deloreanized_sd)优点:无推理延时,如同加载常规模型。
11. 加载多个 LoRA 并随时切换
需要
peft >= 0.3.0
python
# 1. 加载第一个适配器并命名
model = PeftModel.from_pretrained(model, "tloen/alpaca-lora-7b", adapter_name="eng_alpaca")
# 2. 加载另一个适配器
model.load_adapter("22h/cabrita-lora-v0-1", adapter_name="portuguese_alpaca")
# 3. 切换适配器
model.set_adapter("eng_alpaca")
# 4. 禁用适配器(使用原始模型推理)
with model.disable_adapter():
output = model.generate(...)
# 5. 合并并卸载当前活动适配器
model = model.merge_and_unload()