大数跨境

从LoRA到QLoRA:大模型高效微调的技术演进

从LoRA到QLoRA:大模型高效微调的技术演进 ai算法芯片与系统
2026-07-03
3
导读:LoRA用低秩分解大幅缩减微调参数量,QLoRA叠加4-bit量化,进一步压低显存,让单卡也能高效微调百亿大模型,且精度损失极小。

 

📖 本文目录

  1. 1. 前言
  2. 2. 第一部分:微调的本质与困境
  3. 3. 第二部分:LoRA的数学原理 🧩
  4. 4. 第三部分:LoRA的PyTorch实现 💻
  5. 5. 第四部分:QLoRA的原理 ⚙️
  6. 6. 第五部分:QLoRA的PyTorch实现 🛠️
  7. 7. 第六部分:两种技术的对比 ⚖️
  8. 8. 第七部分:实践建议 🎯
  9. 9. 结语 ✨

📝 前言

大语言模型的参数量从亿级增长到千亿级,全量微调变得愈发困难。以 LLaMA-65B 为例,仅以FP16精度加载模型就需要约 130GB 显存,若加上梯度、优化器状态,总需求轻松突破 400GB。这使得在消费级硬件上微调大模型几乎成了不可能完成的任务。😱

为了突破这一瓶颈,研究人员提出了参数高效微调(Parameter-Efficient Fine-Tuning)技术。其中,LoRA(Low-Rank Adaptation)以其简洁优雅的数学形式显著的显存节省效果,成为目前应用最广泛的方法之一。随后出现的 QLoRA,在LoRA的基础上引入量化技术,进一步降低了硬件门槛,让单卡玩转百亿大模型成为现实。🎉

本文将深入剖析这两种技术的数学原理、实现细节,并通过PyTorch代码展示其核心逻辑。💡


🔥 第一部分:微调的本质与困境

1.1 全量微调的数学形式 📐

给定一个预训练权重矩阵  ,全量微调的学习目标是:

其中   是在下游任务上通过反向传播学习到的权重更新量。模型的前向传播为:

这里   是输入向量,  是输出向量。

1.2 全量微调的资源消耗 💸

全量微调的显存占用主要由 四大部分 构成:

  • • 模型参数 🧠:  本身,占用 
  • • 梯度 📉:与   形状相同,占用相同的显存
  • • 优化器状态 📊:以Adam为例,需要存储动量方差,各占一份,共 
  • • 中间激活值 🔄:取决于批次大小序列长度

对于Adam优化器,总显存约为:

当   时,  万。以FP32精度训练,仅参数和优化器状态就需要约 268MB。而对于 LLaMA-65B,这个数字会膨胀到数百GB,直接劝退普通玩家。💔

1.3 打破困境的思路 💡

研究人员注意到一个关键现象:预训练模型已经具备了强大的通用表征能力,针对特定下游任务的微调,其权重更新量   可能存在很强的结构冗余

换言之,  的有效自由度远低于其参数总量。这便是 低秩假设 的出发点。🧐


🧩 第二部分:LoRA的数学原理

2.1 低秩假设 🤔

LoRA的核心假设是:微调过程中的权重更新量   是一个低秩矩阵

🤷 什么是低秩? 矩阵的秩衡量的是其行或列向量的线性独立程度。若  ,则   的信息可以被压缩到   个独立的维度上。

形式化地,存在   和  ,使得:

且秩  远小于  和  ,即:

2.2 权重更新公式 ✍️

LoRA将权重更新公式重新定义为:

其中:

  • •  :原始预训练权重,在微调过程中被 冻结(Frozen),不参与梯度更新 ❄️
  • •  :可训练矩阵,维度为   🔥
  • •  :可训练矩阵,维度为   🔥
  • •  :秩超参数,控制适配器的 容量 🎛️

2.3 前向传播 ➡️

对于输入  ,前向传播的计算为:

这里存在两条计算路径

  • • 主路径 🛤️: 冻结的原始权重处理输入
  • • 适配路径 🌱: 可训练的低秩适配器处理输入

两条路径的输出在元素级相加,得到最终结果。✨

⚠️ 注意:实际的矩阵乘法顺序是  ,而不是先计算  。这样可以避免显式构造完整的   矩阵,从而节省显存。

🎨 LoRA 架构示意图

下图直观展示了 LoRA 的前向数据流(输入   分别经过冻结主干和可训练适配器,最后相加):

2.4 参数量分析 📉

原始全量微调需要训练   个参数。LoRA只需要训练   和  ,参数量为:

由于  ,这个数量远小于

以   为例:

  • • 全量微调参数量:  (约 1677万
  • • LoRA参数量:  (约 6.5万

缩减比例约为 256倍!🎊

2.5 为什么能节省显存?🤯

这是最容易产生误解的地方。虽然   和   形状相同,但LoRA 从不计算和存储完整的  。具体而言:

  • • 不存储完整的   🚫:显存中只有  (冻结)、  和  (可训练)
  • • 不存储优化器状态 🚫:优化器只为   和   维护动量等状态,而不是为 
  • • 不存储梯度 🚫:反向传播时,梯度只传播到   和   无梯度

Adam优化器对可训练参数量   的显存占用约为:

LoRA使得   从   骤降至  ,从而大幅节省显存。💾

2.6 低秩的现实意义 🌍

为什么   可以被视为低秩?这背后是 “内在维度”(Intrinsic Dimension) 假设。

预训练模型在大量通用数据上训练后,其参数空间已经处在一个较好的局部最优附近。针对特定下游任务,模型不需要改变所有参数,只需要在少数关键方向上进行微调。🧭

可以这样理解:  负责将输入投影到一个   维的 “信息瓶颈” 中,  负责将这个压缩后的信息映射回原始空间。  的大小控制了允许的调整复杂度。

2.7 初始化策略 🎲

LoRA的初始化方式非常讲究

  • •  :使用高斯分布随机初始化,确保初始时   有非零值
  • •  :使用零矩阵初始化 ✅

这样做的目的是保证训练开始时:

即模型在初始时刻完全等同于原始预训练模型,不会因为引入LoRA而破坏预训练权重已有的能力。👌

2.8 与SVD分解的区别 🆚

需要特别澄清:LoRA不是对已有矩阵进行SVD分解,而是 “重参数化”(Reparameterization)

方法
时机 ⏰
操作 🛠️
目的 🎯
SVD
已有完整矩阵后
将矩阵分解为  ,截断小奇异值
压缩模型,加速推理
LoRA
训练开始前
用   和   替代  ,仅训练二者
减少训练参数量,节省显存

💬 如果将SVD类比为对已完成的画作进行压缩,那么LoRA就是用更小的画板重新作画。 🎨


💻 第三部分:LoRA的PyTorch实现

3.1 基础实现 🧱

以下是一个LoRA层的PyTorch实现:


   
   
   
    
   
   
   import torch
import
 torch.nn as nn
import
 math

class
 LoRALayer(nn.Module):
    def
 __init__(self, in_features, out_features, rank=8, alpha=16, dropout=0.1):
        super
().__init__()
        self
.rank = rank
        self
.alpha = alpha
        self
.scaling = alpha / rank  # 🔥 缩放因子,控制LoRA的影响强度
        
        # ❄️ 冻结的原始权重

        self
.weight = nn.Parameter(torch.empty(out_features, in_features))
        nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
        self
.weight.requires_grad = False  # 冻结!
        
        # 🔥 LoRA矩阵A:随机初始化

        self
.lora_A = nn.Parameter(torch.zeros(rank, in_features))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        
        # 🔥 LoRA矩阵B:零初始化(确保初始BA=0)

        self
.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        
        # 🎲 Dropout防止过拟合

        self
.dropout = nn.Dropout(dropout)
        
    def
 forward(self, x):
        # 🛤️ 主路径:Wx

        base_output = torch.nn.functional.linear(x, self.weight)
        
        # 🌱 LoRA路径:BAx

        # 注意计算顺序:先A后B,避免构造完整ΔW

        lora_output = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
        lora_output = lora_output * self.scaling  # 应用缩放
        
        return
 base_output + lora_output  # ✨ 元素级相加

📌 代码解读:

  • • __init__ 参数rank 控制低秩维度,alpha 是缩放系数,dropout 用于正则化。scaling = alpha/rank 可在改变 rank 时保持输出量级稳定。
  • • 冻结原始权重self.weight.requires_grad = False 确保预训练权重不参与梯度更新,这是节省显存的第一步。
  • • LoRA 矩阵初始化lora_A 采用 Kaiming 均匀初始化以维持激活方差;lora_B 用零初始化,保证初始时  ,模型行为与原始完全一致。
  • • 前向计算顺序:先计算 x @ lora_A.T 将输入压缩到   维,再与 lora_B.T 相乘恢复维度,这样避免显式构造   的大矩阵,节省显存。
  • • 缩放与相加lora_output * self.scaling 控制 LoRA 分支的影响强度,最后与主路径相加,实现并行适配。

3.2 集成到线性层 🔌

更实用的做法是将LoRA包装在标准线性层上:


   
   
   
    
   
   
   class LoRALinear(nn.Module):
    def
 __init__(self, linear_layer, rank=8, alpha=16, dropout=0.1):
        super
().__init__()
        self
.rank = rank
        self
.alpha = alpha
        self
.scaling = alpha / rank
        
        # ❄️ 引用原始线性层并冻结

        self
.linear = linear_layer
        for
 param in self.linear.parameters():
            param.requires_grad = False
            
        in_features = linear_layer.in_features
        out_features = linear_layer.out_features
        
        # 🔥 LoRA参数

        self
.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.01)
        self
.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        self
.dropout = nn.Dropout(dropout)
        
    def
 forward(self, x):
        base = self.linear(x)  # 原始路径
        lora = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
        lora = lora * self.scaling
        return
 base + lora

📌 代码解读:

  • • 包装设计:接收一个已有的 nn.Linear 实例,将其权重冻结,避免重新定义权重,方便与预训练模型无缝集成。
  • • 参数继承:从 linear_layer 中提取 in_features 和 out_features,确保 LoRA 矩阵维度匹配。
  • • 初始化差异:这里 lora_A 使用小随机数(*0.01)而非 Kaiming,因为权重已经经过预训练,LoRA 只需小幅度调整;lora_B 仍为零初始化。
  • • 前向透明性:调用 self.linear(x) 保持原始计算,LoRA 分支并行,整体行为与原始线性层一致,便于替换。

3.3 应用示例 🚀

将LoRA应用于 LLaMA 等Transformer模型的注意力层:


   
   
   
    
   
   
   def apply_lora_to_model(model, rank=8, target_modules=["q_proj", "v_proj"]):
    """
    遍历模型,将目标线性层替换为LoRALinear
    """

    for
 name, module in model.named_modules():
        if
 any(target in name for target in target_modules):
            if
 isinstance(module, nn.Linear):
                parent_name = ".".join(name.split(".")[:-1])
                child_name = name.split(".")[-1]
                parent = model.get_submodule(parent_name)
                
                # 替换为LoRA线性层

                lora_linear = LoRALinear(module, rank=rank)
                setattr
(parent, child_name, lora_linear)
                print
(f"✅ 已应用LoRA: {name}")  # 加点日志
    return
 model

📌 代码解读:

  • • 模块遍历model.named_modules() 遍历所有子模块,通过名称匹配 target_modules(如 "q_proj")。
  • • 类型检查:只替换 nn.Linear 类型,避免误替换其他模块。
  • • 父模块定位:通过拆分名称找到父模块和子模块名,使用 get_submodule 和 setattr 动态替换。
  • • 日志输出:打印替换信息,便于调试和确认。
  • • 灵活性:可指定任意 target_modules 列表,如 ["q_proj","v_proj","k_proj","o_proj"],控制微调范围。

⚙️ 第四部分:QLoRA的原理

4.1 QLoRA的核心思想 💡

QLoRA(Quantized LoRA)在LoRA的基础上引入了 模型量化 技术,进一步降低显存占用。📉

其核心思路是 “三管齐下”

  • • 将预训练权重   量化为低精度(如4-bit) 存储,大幅降低显存占用 💾
  • • LoRA参数   和   保持高精度(如BF16),确保训练质量 🎯
  • • 前向传播时临时将   反量化回高精度进行计算 ⚡

4.2 量化基础知识 📚

量化是将高精度数值映射到低精度表示的过程。对于4-bit量化,每个数值用4个比特表示,即有16个可能的取值。

QLoRA使用了 NF4(4-bit NormalFloat) 数据类型,这是专门为正态分布数据设计的量化格式。它基于分位数量化,使得量化后的数值更能代表原始分布的特征。📊

4.3 QLoRA的前向传播 🔄

QLoRA的前向传播可表示为:

其中:

  • •  :当前层的输出
  • •  :输入激活值,保持高精度(BF16)
  • •  :以NF4格式存储的 4-bit量化权重
  • •  :量化常数,包括每个量化块的绝对最大值零点偏移
  • • DoubleDequant双反量化操作

🎨 QLoRA 数据流示意图

下图展示了 QLoRA 的前向过程:4-bit 权重经双反量化后参与主路径计算,同时高精度 LoRA 分支并行,最后相加输出。

4.4 双反量化的细节 🔍

双反量化过程如下:

  1. 1. 第一次反量化:利用  (块级最大值),将4-bit整数反量化为16-bit浮点数
  2. 2. 第二次反量化:利用  (偏移量),调整数值的零点和尺度,得到完整的BF16精度权重

⚠️ 关键点:反量化后的BF16权重只在当前前向传播计算时临时存在,计算完成后立即释放,不长期占用显存。这就是它省显存的秘诀!🤫

4.5 为什么需要双反量化?🤔

直接对整个矩阵使用统一的量化系数会导致精度损失严重,因为不同位置的权重分布差异较大。

QLoRA采用分块量化策略,将权重矩阵划分为多个小块(如每64个元素一组),对每个块独立进行量化。这样每块都有自己的   和  ,能更好地保留局部精度信息。🧩

4.6 QLoRA的资源优势 🥇

QLoRA的显存占用分析:

  • • 原始权重  :以4-bit存储,仅占全精度(BF16,2字节)的 1/4 📉
  • • LoRA参数   和  :保持BF16精度,但参数量很小
  • • 优化器状态针对LoRA参数维护

这使得在单张24GB显存的消费级GPU(如RTX 3090/4090)上微调 33B 甚至 65B 的模型成为可能!🎉🎉🎉


🛠️ 第五部分:QLoRA的PyTorch实现

5.1 4-bit线性层封装 📦


   
   
   
    
   
   
   import torch
import
 torch.nn as nn
from
 bitsandbytes.nn import Linear4bit  # 🔥 核心4-bit线性层

class
 QLoRALinear(nn.Module):
    def
 __init__(self, in_features, out_features, rank=8, alpha=16, dropout=0.1):
        super
().__init__()
        self
.rank = rank
        self
.alpha = alpha
        self
.scaling = alpha / rank
        
        # ❄️ 4-bit量化的原始权重(占用极小显存)

        self
.quantized_weight = Linear4bit(in_features, out_features)
        for
 param in self.quantized_weight.parameters():
            param.requires_grad = False  # 冻结!
            
        # 🔥 高精度的LoRA参数(BF16)

        self
.lora_A = nn.Parameter(torch.randn(rank, in_features) * 0.01)
        self
.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        self
.dropout = nn.Dropout(dropout)
        
    def
 forward(self, x):
        # 🔄 4-bit权重会自动反量化用于计算(临时解压)

        base = self.quantized_weight(x)
        
        # 🌱 LoRA路径保持高精度

        lora = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
        lora = lora * self.scaling
        
        return
 base + lora

📌 代码解读:

  • • 依赖库:使用 bitsandbytes 提供的 Linear4bit,它封装了4-bit存储和双反量化逻辑,无需手动实现。
  • • 量化权重self.quantized_weight 内部以NF4格式存储权重,前向调用时自动反量化并计算,对用户透明。
  • • 冻结量化权重:将所有参数 requires_grad=False,因为量化权重不参与训练,只做前向。
  • • LoRA保持高精度lora_A 和 lora_B 仍为普通 nn.Parameter,默认 FP32 或 BF16,确保训练稳定性。
  • • 前向混合base 来自量化权重的计算(内部临时解压),lora 来自高精度分支,二者相加,兼顾显存和效果。

5.2 使用HuggingFace Transformers集成 🤗

实际项目中,强烈推荐使用 bitsandbytes 和 peft 库,它们已经封装好了所有细节:


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

# ⚙️ 配置4-bit量化

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                      # 启用4-bit加载
    bnb_4bit_use_double_quant=True,         # 启用双量化(进一步省显存)
    bnb_4bit_quant_type="nf4",              # 使用NF4数据类型
    bnb_4bit_compute_dtype=torch.bfloat16   # 计算时使用BF16
)

# 🚀 加载4-bit量化模型

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf"
,
    quantization_config=bnb_config,
    device_map="auto"                       # 自动分配到多GPU
)

# 🎛️ 配置LoRA

lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["q_proj", "v_proj"],    # 只微调Q和V
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM"
)

# 🔥 应用LoRA(与量化权重无缝结合)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()          # 打印可训练参数量

📌 代码解读:

  • • 量化配置BitsAndBytesConfig 统一管理量化参数,load_in_4bit=True 启用4-bit加载,bnb_4bit_use_double_quant 启用双量化进一步压缩,bnb_4bit_quant_type="nf4" 指定数据类型。
  • • 模型加载from_pretrained 时传入 quantization_config,自动将模型权重以4-bit加载,大幅减少初始显存。
  • • device_map="auto":自动将层分配到多GPU或CPU,适合大模型。
  • • LoRA配置LoraConfig 与纯LoRA相同,target_modules 指定应用层,r 和 alpha 控制秩和缩放。
  • • 结合应用get_peft_model 将LoRA适配器添加到量化模型上,二者协同工作,print_trainable_parameters 可查看可训练参数量(通常很少)。

5.3 训练循环示例 🏋️


   
   
   
    
   
   
   from transformers import Trainer, TrainingArguments

# 📋 训练参数配置

training_args = TrainingArguments(
    output_dir="./lora-checkpoints",
    num_train_epochs=3,
    per_device_train_batch_size=4,          # 批次大小
    gradient_accumulation_steps=4,          # 梯度累积,模拟更大批次
    learning_rate=2e-4,
    fp16=True,                              # 启用混合精度训练
    logging_steps=10,
    save_strategy="epoch",
    report_to="none"                        # 不汇报到外部平台
)

# 🚀 启动训练

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)

trainer.train()

📌 代码解读:

  • • 批次与累积per_device_train_batch_size=4 设置每卡批次,gradient_accumulation_steps=4 实现梯度累积,等效批次为16,适合显存受限场景。
  • • 学习率2e-4 是 LoRA 微调的常用初始学习率,通常比全量微调稍高。
  • • 混合精度fp16=True 启用 FP16 训练,减少显存并加速,但需注意数值稳定性。
  • • 保存策略save_strategy="epoch" 每轮保存检查点,便于恢复。
  • • Trainer 封装:HuggingFace Trainer 自动处理数据加载、梯度更新、日志记录,简化训练流程。

⚖️ 第六部分:两种技术的对比

6.1 核心差异 🆚

维度 📊
LoRA
QLoRA
原始权重精度
全精度(FP16/BF16) 🧊
4-bit量化(NF4) 🧩
LoRA参数精度
全精度 ✅
全精度 ✅
主要节省来源
减少可训练参数
减少可训练参数 + 压缩原始权重
显存占用
较低 📉
极低
 📉📉
适用场景
单卡24GB可跑7B-13B
单卡24GB可跑33B-65B 🏆

🎨 总览对比表

下表从权重精度显存占用训练吞吐推荐模型规模四个维度直观对比两种方法:

对比维度 📊
LoRA
QLoRA
主干权重精度
FP16 / BF16(2字节)
4-bit NF4(~0.5字节)
主干权重显存(以7B计)
~14 GB
~3.5 GB(节省4倍
LoRA适配器精度
FP16 / BF16
FP16 / BF16
可训练参数量
极少(通常 < 1%)
极少(通常 < 1%)
总显存占用(含适配器)
~16 – 20 GB
~4 – 6 GB
推荐消费级GPU
24GB 显存可跑 7B~13B 模型
24GB 显存可跑 33B~65B 模型 🏆
训练吞吐量
较快(无额外解压开销)
稍慢(含双反量化计算开销)

6.2 性能对比 📈

QLoRA在量化原始权重时不可避免地带来微小精度损失,但由于:

  • • 反量化计算仍然使用高精度
  • • LoRA参数保持高精度,不受量化影响

在大多数下游任务上,QLoRA的效果与LoRA相差无几,而显存占用大幅降低。这简直是白嫖的性能!🤩


🎯 第七部分:实践建议

7.1 秩   的选择 🎛️

秩   是LoRA最重要的超参数:

  • •   较小(4-8) 🌱:参数量少,显存占用低,适合简单任务资源受限场景
  • •   中等(16-32) 🌿:平衡性能与效率的常用区间
  • •   较大(64+) 🌳:拟合能力增强,但可能过拟合,显存占用增加

💡 一般建议从   开始实验,观察效果后调整。

7.2 目标模块的选择 🎯

在Transformer模型中,常见的应用目标:

  • • 仅注意力层( :参数量最少,效果足够 👍
  • • 所有注意力层( :更优效果,参数量适中 👌
  • • 全连接层也加入:参数量增加,效果提升有限 🤷

7.3 Dropout的设置 🎲

  • • 当数据集较小时,适当增大 lora_dropout(如0.1-0.2)可缓解过拟合
  • • 当数据集较大时,可减小或不用dropout

✨ 结语

LoRA通过低秩重参数化,将微调参数量从   压缩到  ,使得在有限资源下微调大模型成为可能。QLoRA在此基础上引入4-bit量化,进一步降低了硬件门槛,让单卡玩转百亿参数不再是梦。🌠

这两种技术并非相互替代,而是互补的。在实际应用中,可以根据可用显存和任务需求灵活选择。随着模型规模的持续增长,参数高效微调技术的重要性将不断提升。📈

希望这篇博客能帮助你彻底理解LoRA与QLoRA!如果你有任何问题或想法,欢迎在评论区交流讨论!💬👇


Happy Fine-Tuning! 🚀🔥

 


【声明】内容源于网络
0
0
ai算法芯片与系统
长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
内容 211
粉丝 0
ai算法芯片与系统 长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
总阅读3.0k
粉丝0
内容211