Skip to content

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_projv_proj

LoraConfig 参数详解

参数说明
rLoRA 的秩,r << d
lora_alpha归一化超参数,减少改变 r 时需重新训练的计算量
target_modulesLoRA 作用的目标模块列表
merge_weightsEval 模式中是否将 LoRA 值合并到 W₀
lora_dropoutLoRA 层的 Dropout 比率
fan_in_fan_out仅当应用在 Conv1D 层时置 True
biasnone(均不可训练)/ 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 原理

模型量化是对浮点参数进行压缩的同时降低压缩带来的误差。

精度每参数占用
FP324 bytes
FP162 bytes
INT81 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 量化方案

  1. 寻找 Tensor 矩阵的绝对值的最大值
  2. 计算最大值到 127 的缩放因子
  3. 使用缩放因子对整个 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)

主要做了两件事:

  1. Layer Norm 层保留 FP32 精度
  2. 输出层保留 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() 实现细节

  1. 找到目标层:用正则匹配 target_modules(如 "q_proj", "v_proj")
  2. 创建新 LoRA 层new_module = Linear(target.in_features, target.out_features, bias=bias, **kwargs)
  3. 替换原层setattr(parent_module, child_name, new_module),并将原 weightbias 赋值给新模块

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 = False

7.2 Linear 层(同时继承 nn.Linear 和 LoraLayer)

初始化参数

  • lora_Ann.Linear(in_features, r, bias=False)Kaiming 均匀初始化
  • lora_Bnn.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 = False

9. 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()