‹ 返回文章列表

深入拆解 LoRA:微调神器的底层逻辑与工程解剖

本文将分阶段彻底拆解 LoRA:从核心公式,到手写 PyTorch 极简实现;从“为什么 A 随机 B 全 0”的反向传播死锁推演,到追溯至 ResNet 的学术源头;最后,我们将扒开 Hugging Face peft 的底层源码,看看工业界是如何实现“狸猫换太子”与“零延迟推理”的。

LoRA (Low-Rank Adaptation) 是目前大模型微调(PEFT)中最核心、最优雅的技术之一。很多人会使用 peft 库跑通训练,但如果继续追问:

  • 为什么 LoRA 有效?
  • 为什么更新矩阵可以低秩分解?
  • 为什么 A 随机初始化而 B 必须初始化为 0?
  • 为什么 LoRA 可以在推理时完全融合进原模型?
  • Hugging Face 到底是如何把普通 Linear 偷偷替换成 LoRA Layer 的?

很多人可能就一知半解了, 而这些问题,恰恰才是 LoRA 最有价值的部分。


LoRA 的核心数学逻辑

在传统的全参数微调中,假设模型原本的权重矩阵为 W_0W\_0(维度是 d×kd \times k),微调就是在寻找一个权重增量 ΔW\Delta W,使得更新后的权重变为 W=W_0+ΔWW = W\_0 + \Delta W

在拥有数百亿参数的大模型中,直接训练 ΔW\Delta W 需要消耗极其庞大的显存。LoRA 的核心思想是假设权重增量矩阵 ΔW\Delta W 具有“低秩”特性。因此,我们可以用两个小矩阵 AABB 的乘积来近似替代庞大的 ΔW\Delta W

ΔW=BA\Delta W = B A

  • W_0W\_0: 预训练权重,维度为 d×kd \times k在训练中被冻结(Freeze),不更新。
  • AA: 降维矩阵,维度为 r×kr \times k。(rr 就是秩 rank,通常设置得很小,比如 8, 16, 64,远小于 ddkk)
  • BB: 升维矩阵,维度为 d×rd \times r

前向传播的公式就变成了:

h=W_0x+ΔWx=W_0x+(α/r)BAxh = W\_0x + \Delta Wx = W\_0x + (\alpha / r) BAx

(注:α\alpha 是一个缩放因子,用来控制 LoRA 旁路对原模型的影响程度。)

极简代码实现 (PyTorch)

下面是一段可以独立运行的、最核心的 LoRA 线性层(Linear Layer)实现代码。真实场景下的 peft 库底层的逻辑与这段代码完全一致。

import torch
import torch.nn as nn
import math

class LoRALinear(nn.Module):
    def __init__(self, in_features, out_features, r=8, lora_alpha=16):
        super().__init__()
        
        # ==========================================
        # 1. 原始模型链路 (主干)
        # ==========================================
        self.linear = nn.Linear(in_features, out_features, bias=False)
        # 【关键】冻结主干权重
        self.linear.weight.requires_grad = False 
        
        # ==========================================
        # 2. LoRA 旁路链路配置
        # ==========================================
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = self.lora_alpha / self.r
        
        # ==========================================
        # 3. 定义 A 和 B 矩阵
        # ==========================================
        # 降维矩阵 A
        self.lora_A = nn.Parameter(torch.zeros((r, in_features)))
        # 升维矩阵 B
        self.lora_B = nn.Parameter(torch.zeros((out_features, r)))
        
        # 初始化旁路权重
        self.reset_parameters()

    def reset_parameters(self):
        # A 矩阵: 使用 Kaiming 均匀分布初始化 (类似正态分布)
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        # B 矩阵: 全部初始化为 0
        nn.init.zeros_(self.lora_B)

    def forward(self, x):
        # 1. 走原始大模型的主干 (不可导)
        orig_output = self.linear(x)
        
        # 2. 走 LoRA 小模型的旁路 (可导)
        # x 的 shape 是 (batch_size, in_features)
        # x @ lora_A.T 会将特征从 in_features 降维到 r
        # 然后 @ lora_B.T 会将特征从 r 恢复到 out_features
        lora_output = (x @ self.lora_A.T @ self.lora_B.T) * self.scaling
        
        # 3. 两路结果相加
        return orig_output + lora_output

逐行代码深度剖析

1. 冻结原权重,只炼旁路

self.linear.weight.requires_grad = False

这是 LoRA 节省显存的根本原因。如果不加这行代码,PyTorch 在反向传播时会计算原庞大模型权重的梯度,优化器也会为它保存动量状态(比如 Adam 会额外占用两倍体量的显存)。冻结后,主干网络只做前向传播计算,极大地释放了显存。

2. 定义低秩矩阵 A 和 B

self.lora_A = nn.Parameter(torch.zeros((r, in_features)))
self.lora_B = nn.Parameter(torch.zeros((out_features, r)))

计算参数量对比: 假设 in_features=40964096out_features=40964096,秩 rr=88

  • 全量微调更新参数量:4096×4096=16,777,2164096 \times 4096 = 16,777,216 (约 1600 万)
  • LoRA 更新参数量:(8×4096)+(4096×8)=65,536(8 \times 4096) + (4096 \times 8) = 65,536 (约 6.5 万)
  • 参数量直接缩小了约 250 倍!这就是为什么消费级显卡也能微调大模型。

Question: A不是降维矩阵么, 为啥shape为(r, in_features)不应该是(in_feature, r)么?

2.1 从前向传播的维度计算来看

回顾一下我们写的 forward 函数里的核心计算代码:

Python

lora_output = (x @ self.lora_A.T @ self.lora_B.T) * self.scaling

请注意,代码里参与矩阵乘法(@)的是 self.lora_A.T(A的转置),而不是 A 本身。

我们来看看每一步的 Tensor 形状(Shape)变化:

  • x 的形状是 (batch_size, in_features)
  • self.lora_A 的定义是 (r, in_features)
  • self.lora_A.T 的形状就变成了 (in_features, r)

当它们相乘时:

  • x @ self.lora_A.T
  • 对应形状:(batch_size, in_features) @ (in_features, r)
  • 计算结果:(batch_size, r)

你看,经过 self.lora_A.T 的相乘,数据的特征维度完美地从 in_features 降维到了 r。所以它的确起到了降维的作用。

2.2 为什么不直接定义成 (in_features, r) 呢?

既然参与计算的是转置后的矩阵,为什么我们在 __init__ 初始化时,不干脆直接定义成 (in_features, r),省去一步转置呢?

因为我们要严格对齐 PyTorch 官方的设计规范。

在 PyTorch 中,当你调用原生的 nn.Linear(in, out) 时,如果你去打印它底层权重的形状,你会发现:

Python

layer = nn.Linear(in_features=128, out_features=64)
print(layer.weight.shape) 
# 输出结果是: torch.Size([64, 128])  --> 也就是 (out, in)

PyTorch 的底层 C++ 和 CUDA 代码(基于 cuBLAS)为了内存读取的连续性和计算效率,约定俗成地把线性层的权重存储为 (out_features, in_features)

计算前向传播 F.linear(x, weight) 时,底层的数学逻辑就是 y=xWT+by = x W^T + b

在 LoRA 中:

  • 矩阵 A 本质上是一个 in_features 输入,r 输出的线性层。所以按照 PyTorch 的习惯,它的权重形状应该存为 (r, in_features)
  • 矩阵 B 本质上是一个 r 输入,out_features 输出的线性层。所以它的权重形状应该存为 (out_features, r)

真正执行降维动作的矩阵,形状必然是 (in_features, r). 为了迎合PyTorch底层 nn.Linear 将权重存储为 (out, in) 的性能优化规范,我们在定义 Parameter 时写成了 (r, in_features),并在前向传播时补上了一个 .T 转置操作。

3. 极其精妙的初始化策略

nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
nn.init.zeros_(self.lora_B)

为什么 A 是随机的,而 B 必须是全 0?

  • 因为在模型刚开始微调时(第 0 步),我们希望 LoRA 层的加入完全不改变原模型的输出
  • 当矩阵 BB 全为 0 时,B×A=0B \times A = 0。此时旁路的输出是 0,即 orig_output + 0 = orig_output
  • 如果 A 和 B 都是随机初始化,初始状态下加入 LoRA 就会给模型引入大量噪声,导致模型瞬间“变傻”,甚至崩溃。
  • 如果 A 也是 0 呢?不行,如果全是 0,会导致梯度消失(Symmetry Breaking 问题),网络将无法学习。因此,A 负责打破对称性提取特征,B 负责控制初始稳定

3.1 为什么 LoRA 中 A 和 B 不能全为 0?

在 LoRA 中,旁路的输出是两层矩阵相乘:y=BAxy = B A x

在反向传播时,我们要根据最终的 Loss(损失函数 LL)来更新 AABB。根据高数里的链式求导法则,计算梯度公式如下:

  1. BB 求导:

    L/B=(L/y)(Ax)T\partial L / \partial B = (\partial L / \partial y) \cdot (Ax)^T

    你看,更新 BB 的梯度,取决于 A×xA \times x(也就是 AA 的输出)。

  2. AA 求导:

    L/A=BT(L/y)xT\partial L / \partial A = B^T \cdot (\partial L / \partial y) \cdot x^T

    你看,更新 AA 的梯度,取决于矩阵 BB

致命的“死锁”来了:

如果你把 AABB 一开始全设为 0(即 A=0,B=0A=0, B=0):

  • 去算 BB 的梯度时,因为 A=0A=0,所以 (Ax)T=0(Ax)^T = 0,导致 BB 的梯度是 0
  • 去算 AA 的梯度时,因为 B=0B=0,所以 BT=0B^T = 0,导致 AA 的梯度也是 0

这就变成了一个“先有鸡还是先有蛋”的死锁。两个权重矩阵都在等对方给自己提供非零的梯度信号,结果谁也无法更新,永远停留在 0。这就是所谓的梯度消失和网络无法学习。

打破死锁的办法(Symmetry Breaking):

我们必须把 AA 随机初始化(赋予非零的值)。这样:

  • 第一步反向传播时:因为 A0A \neq 0,所以 (Ax)0(Ax) \neq 0BB 瞬间获得了非零的梯度,被更新了!
  • 第二步反向传播时:因为 BB 已经被更新不再是 0 了,所以 AA 也开始获得非零的梯度,可以正常学习了。
  • 这就是为什么 AA 负责“打破死锁(对称性)”,而 BB 负责“初始状态为 0(保证模型初始不崩)”。

3.2 那是否可以B随机初始, A为0

理论上完全可以!如果你把 BB 随机初始化,AA 全零初始化,同样能够打破死锁,网络完全可以正常学习。

我们来顺着这个思路,把这条路推演一遍,看看它为什么行得通,以及为什么原作者最终还是选择了 A随机, B全0 的方案。

为什么 B随机, A全0 在数学上是可行的?

假设我们现在反其道而行之:A=0A = 0B0B \neq 0(随机初始化)。

1. 初始状态依然安全(满足 Merge 的初衷)

前向传播的输出是 y=BAxy = B A x

因为 A=0A = 0,所以 Ax=0A x = 0,那么 B(0)=0B(0) = 0

第一步的输出依然是 0,加上主干网络的输出后,完全没有破坏原模型的初始状态,这很好。

2. 梯度死锁被打破了

我们再来看第一步反向传播的梯度(Loss 为 LL):

  • 计算 A 的梯度: L/A=BT(L/y)xT\partial L / \partial A = B^T \cdot (\partial L / \partial y) \cdot x^T
    • 因为 BB 是随机初始化的(B0B \neq 0),且输入 xx 正常情况下也不为 0,所以 L/A\partial L / \partial A 算出来不是 0! AA 在第一步就获得了梯度,被更新了。
  • 计算 B 的梯度: L/B=(L/y)(Ax)T\partial L / \partial B = (\partial L / \partial y) \cdot (Ax)^T
    • 因为 A=0A = 0,所以 (Ax)T=0(Ax)^T = 0,导致第一步时 L/B=0\partial L / \partial B = 0BB 在第一步原地踏步。

3. 第二步开始起飞

到了第二步,因为 AA 刚刚被更新了,已经不再是 0 了。

此时再算 BB 的梯度,(Ax)T(Ax)^T 就不为 0 了,BB 也顺利获得了梯度。

死锁被成功打破,模型可以顺畅地一路训练下去。

既然都行,为什么官方默认用 A随机, B全0

既然两者在数学上都能跑通,为什么 LoRA 的作者以及整个开源社区都约定俗成地使用 A随机, B全0 呢?这就涉及到神经网络优化的直觉和历史经验了。主要有两个核心原因:

1. 信息的“留存”与优化的难度

我们可以把 AA 看作是特征提取器(把 dd 维输入压缩提取成 rr 维),把 BB 看作是特征映射器(把 rr 维特征还原成 dd 维)。

  • 官方方案(A随机,B全0):

    输入 xx 经过随机的 AA,会生成一组中间特征 AxAx。虽然 AA 是随机的,但这个中间特征保留了输入 xx 的丰富信息

    此时,BB(虽然是0)站在那里,它的任务是:看着这组已经富含输入信息的特征 AxAx,慢慢学习“我该如何把这些特征组合起来,以降低 Loss”。这相当于在一个现成的、有意义的特征池里做选择,优化起来相对容易。

  • 你的方案(A全0,B随机):

    输入 xx 经过全 0 的 AA,出来的中间特征 AxAx 是一滩死水(全是 0)。输入 xx 的信息在这里瞬间全部断网、丢失了

    此时,要更新 AA 时,梯度的反传完全依赖于 BB 里面那些随机的、毫无意义的权重方向。这就好像要求 AA(一个瞎子)去从头摸索如何提取特征,而指导它的 BB 是一个满嘴胡言乱语的人。虽然慢慢也能学对,但在最初的几步,网络优化的方向会非常盲目,甚至可能引起 Loss 的震荡。

2. ResNet (残差网络) 的祖传手艺

LoRA 的结构 W_0x+BAxW\_0 x + B A x 本质上就是一个“残差连接”(Residual Connection)。

在深度学习领域(尤其是 ResNet 提出之后),对于残差块 x+F(x)x + F(x) 的初始化,业界有一个公认的最佳实践:永远把残差块里的“最后一层”初始化为 0。

  • 在 ResNet 里,通常是把最后一个 Batch Normalization 的 γ\gamma 参数设为 0。
  • 在 LoRA 里,F(x)=BAxF(x) = B A xBB 就是这最后一道关卡。

把最后一层设为 0,意味着在整个块的内部(比如 AA 这层),神经元已经处于活跃状态、准备好随时输出特征了,只是被最后一道门(BB)暂时按住了而已。这种“外表平静,内部活跃”的状态,被证明是训练深层网络最稳定、最容易收敛的姿势。

总结:

你的推导在数学逻辑上绝对满分!B随机, A全0 完全可以 work。但 A随机, B全0 在信息保留和优化平滑度上具有工程上的优势,这也是为什么大模型微调的底层代码(如 HuggingFace 的 peft)全都采用了后者的原因。

3.3 为什么 ControlNet 的 Zero Conv 行得通?

ControlNet 的情况完全不同,它的 Zero Conv 是一层单层的卷积网络,没有像 B×AB \times A 这种两层互相依赖的乘法。

假设 Zero Conv 的权重是 WW,输入是 cc,输出是 y=Wcy = Wc

计算梯度的公式是:

L/W=(L/y)c\partial L / \partial W = (\partial L / \partial y) \cdot c

注意看!更新 WW 的梯度,只取决于输入 cc,根本不依赖于其他的权重矩阵!

只要你的输入 cc(比如图像特征、边缘检测图)不是全 0 的,并且网络有回传的误差 L/y\partial L / \partial y,那么 就算 WW 当前是 0,它也能立刻算出非零的梯度并开始更新。

总结一下核心差异:

  • LoRA (B×AB \times A):两个权重互相乘,求导时互相依赖。两个都是 0,梯度就是 0。必须一个随机,一个为 0。
  • ControlNet (Zero Conv):权重乘以输入。求导时只依赖输入。只要输入不是 0,权重是 0 照样能算出梯度。

PEFT源码分析

上一趴我们看了“LoRA 层本身长什么样”。顺着这个逻辑,必然会有两个极其核心的工程问题:

  1. 狸猫换太子:大语言模型(如 Llama 3)里原本全是普通的 nn.Linearpeft 是怎么在不改动原始模型代码的情况下,把这些层偷偷替换成我们上一趴写的 LoRALinear 的?
  2. 九九归一:我们反复提到的“合并权重(Merge)以实现零延迟推理”,在代码里到底是怎么做矩阵加法的?

我们直接来看这两个过程的真实源码(为了突出核心逻辑,去掉了库中繁杂的错误拦截和特殊模型兼容判断)。

核心机制:狸猫换太子 (模型注入机制)

在微调大模型时,我们通常会写这样一行代码:model = get_peft_model(model, peft_config)。这行代码的底层会疯狂遍历模型的所有网络层,找到目标层(比如注意力机制里的 q_projv_proj),然后执行替换动作

这个替换动作的源码在 peft/tuners/lora/model.py 中的 _create_and_replace 方法里:

# 摘自 peft/tuners/lora/model.py
import torch.nn as nn

def _create_and_replace(self, lora_config, adapter_name, target, target_name, parent, **optional_kwargs):
    """
    参数解释:
    target: 原始模型里的那一层 (比如一个普通的 nn.Linear)
    target_name: 这层的名字 (比如 'q_proj')
    parent: 这层的父模块 (相当于这层的“归属地”)
    """
    
    # 1. 提取原始层的属性
    if isinstance(target, nn.Linear):
        in_features = target.in_features
        out_features = target.out_features
        has_bias = target.bias is not None
        
        # 2. 实例化我们上一趴拆解过的 LoRA 包装层
        new_module = Linear(
            adapter_name, 
            in_features, 
            out_features, 
            bias=has_bias,
            r=lora_config.r,
            lora_alpha=lora_config.lora_alpha,
            lora_dropout=lora_config.lora_dropout
        )
        
        # ==========================================
        # 3. 移花接木:把旧层的权重转移到新层的主干上
        # ==========================================
        # 因为我们的 LoRA 层内部包含了一个 self.base_layer (相当于上一趴的 self.linear)
        new_module.weight = target.weight
        if has_bias:
            new_module.bias = target.bias
            
        # 将新层移动到和旧层一样的设备 (CPU/GPU) 和数据类型 (fp16/bf16) 上
        new_module.to(target.weight.device, dtype=target.weight.dtype)
        
        # ==========================================
        # 4. 彻底替换:用 setattr 修改大模型的计算图
        # ==========================================
        # 这行代码一跑,大模型里原来的 target_name 层就变成了 new_module
        setattr(parent, target_name, new_module)
        
        # 释放旧层,清理显存
        del target

💡 源码级思考:

这里最精妙的是使用了 Python 自带的反射机制 setattr。在 PyTorch 中,修改网络结构不需要你去改 Llama 的原始类文件。只要拿到类的实例(parent),直接强行覆盖它的属性(target_name),计算图就被彻底重写了。这就是所谓“非侵入式修改”的最高境界。

在 PyTorch和大模型框架(如 Hugging Face 的 peft 库)中,“狸猫换太子”的技术本质上是运行时动态修改对象属性(Runtime Object Mutation)与抽象语法树/模块树的递归遍历

你觉得模糊的地方,通常在于:peft 面对成百上千层的深层大模型,是如何精准定位到某一个具体的 nn.Linear,又是如何顺藤摸瓜找到它的“父亲(parent)”,从而完成强行覆盖的?

为了彻底拆解这个工程细节,我们先看懂它的核心查找与替换算法,然后再通过下方的一个互动演示来直观感受这个过程。

核心工程细节:如何找到“父亲”和“孩子”?

假设你的大模型(比如一个简化版的 Transformer)结构非常深,其中一个线性层的完整名字(target_name)叫作: "transformer.layers.0.attention.q_proj"

这个字符串代表了它在模型中的层级路径。peft 需要做两件事:

  1. 根据这个字符串,一路向下探寻,拿到这个具体的 q_proj 实例(即 target),读取它的输入输出维度和权重。
  2. 拿到 q_proj 所在的上一层组件 attention 实例(即 parent),这样才能用 setattr(parent, "q_proj", new_module) 把旧层替换掉。

1. 拆解字符串路径

在 Python 中,不能直接用 model.transformer.layers.0.attention.q_proj 这种写死的方式去处理动态字符串。peft 内部会使用类似下面的经典递归/循环逻辑来拆解路径:

def find_parent_and_child(model, target_path):
    # 将 "transformer.layers.0.attention.q_proj" 拆分为组件列表
    # -> ['transformer', 'layers', '0', 'attention', 'q_proj']
    parts = target_path.split(".")
    
    # 最后一项是孩子的名字 -> "q_proj"
    child_name = parts[-1]
    
    # 前面的部分用来找父亲 -> ['transformer', 'layers', '0', 'attention']
    parent_path = parts[:-1]
    
    # 从根模型开始,顺着路径一层一层往下找
    parent = model
    for part in parent_path:
        # 如果是数字,说明遇到了 nn.ModuleList(比如第 0 层)
        if part.isdigit():
            parent = parent[int(part)]
        else:
            # 否则通过 getattr 动态获取子模块
            parent = getattr(parent, part)
            
    # 此时的 parent 就是拥有 q_proj 的那个 attention 模块实例
    # 我们可以直接拿到旧的孩子实例
    child = getattr(parent, child_name)
    
    return parent, child_name, child

2. 执行替换(核心三步)

一旦通过上面的函数拿到了 parentchild_name(即 "q_proj")和旧的 child,替换的细节就完全闭环了:

# 第一步:获取旧层的核心参数
in_features = child.in_features
out_features = child.out_features

# 第二步:创建全新的 LoRALinear,并把旧层的权重指针直接偷过来
new_module = LoRALinear(in_features, out_features, r=8)
new_module.weight = child.weight  # 浅拷贝,指针相同,不占用新显存

# 第三步:狸猫换太子
# 相当于执行了 parent.q_proj = new_module
setattr(parent, child_name, new_module)

通过这种方式,原本的模型实例在完全不知道自己被修改的情况下,内部的计算图节点已经被神不知鬼不觉地替换成了带有 LoRA 旁路的全新节点。

动态模型树替换直观演示

为了让你更直观地看到这个“层级遍历、寻找父亲、动态替换”的过程,我为你准备了一个大模型层级替换模拟器

在下方工具中,你可以看到一个模拟的 Transformer 模型树结构。你可以选择想要微调的 Target Modules(例如只微调 q_projv_proj,或者微调全部线性层),然后点击“开始替换”。模拟器会演示 peft 的查找指针是如何在树状结构中移动、锁定目标、并在保持权重不变的情况下,将普通的 nn.Linear 动态重写为 LoRALinear 的。

[[LORA_SIMULATOR]]

核心机制:九九归一 (Merge 权重合并)

当我们训练完 LoRA 准备部署时,会调用 model.merge_and_unload()。这个方法会穿透到每一个刚才替换好的 LoRA 层中,调用 merge() 方法。

数学上的合并公式非常简单:W(new)=W(base)+B×A×scalingW(new) = W(base) + B \times A \times scaling。在 peft/tuners/lora/layer.py 中,它是这样写成工程代码的:

# 摘自 peft/tuners/lora/layer.py 的 Linear 类内部

def merge(self, safe_merge: bool = False, adapter_names: list[str] = None):
    """
    将 LoRA 旁路的权重永久加到主干模型上
    """
    # 遍历当前需要合并的 LoRA 适配器
    for active_adapter in adapter_names:
        if active_adapter in self.lora_A.keys():
            # 拿到原始大模型层
            base_layer = self.get_base_layer()
            
            # ==========================================
            # 1. 计算增量 Delta W (也就是 B * A)
            # ==========================================
            delta_weight = self.get_delta_weight(active_adapter)
            
            # ==========================================
            # 2. 物理合并:直接修改底层 data 指针
            # ==========================================
            # 这里必须使用 .data +=,这样才不会破坏 PyTorch 的计算图,且能原地修改显存
            base_layer.weight.data += delta_weight
            
            # 3. 将该 adapter 标记为已合并,防止二次重复相加
            self.merged_adapters.append(active_adapter)


def get_delta_weight(self, adapter) -> torch.Tensor:
    """
    专门用来计算 B * A 的辅助函数
    """
    # 拿到 A 和 B 两个 nn.Linear 层的参数矩阵
    weight_A = self.lora_A[adapter].weight
    weight_B = self.lora_B[adapter].weight
    
    # 获取缩放因子 (alpha / r)
    scaling = self.scaling[adapter]
    
    # 执行矩阵乘法 @,并乘以缩放因子
    # 注意:为了适配不同设备的张量维度定义,通常会封装一个 transpose 操作
    # 但核心数学逻辑就是: (B @ A) * scaling
    output_tensor = (weight_B @ weight_A) * scaling
    
    return output_tensor

💡 源码级思考:

  1. 原地修改 (.data +=):为什么要写成 .weight.data += 而不是直接 .weight = ?因为 .weight 是一个受到 PyTorch 严格保护的 nn.Parameter(特别是如果模型被量化过)。直接用 .data += 可以绕过梯度图引擎(Autograd),在显存地址上进行最底层的原地相加,这样既快又不会引发显存飙升。
  2. merge_and_unload() 里的 unload 是什么?:在执行完上面这段加法代码后,peft 会在外层再执行一次“反向的狸猫换太子”——把当前的 LoRALinear 层删掉,重新实例化一个纯净的 nn.Linear 塞回大模型里。至此,模型身上所有的 LoRA 痕迹被彻底抹除,恢复成了最纯粹的形态,这也是它在部署时(比如用 vLLM 或 TensorRT-LLM 跑的时候)完全没有额外开销的根本原因。
#LoRA#基础

如果这篇文章对你有帮助,欢迎去 GitHub 留言,我会认真看。

If this article helped, feel free to leave a message on GitHub.

去留言