EXPERIMENT REPORT

实验八:LoRA (Low-Rank Adaptation) 高效微调

2026-01-02 10 MIN READ

1. 实验八:基础实验

注:

  • 本实验环境假设为 2026 年主流配置,但向下兼容。
  • Happy Path:标准的低秩 ($r=8$) 微调,显存极低。
  • Sad Path:恶意膨胀 Rank ($r=2048$),模拟“伪全量微调”的显存爆炸。

1.1 第一阶段:实例租赁与环境准备

目标:准备支持 peftbitsandbytes 的计算环境。

  1. 租赁实例

    • 算力选型:推荐 RTX 4090 (24GB) 作为基准。若测试国产卡(如 MTT S4000),需确认 MUSA Toolkit 版本 ≥ 3.1。
    • 镜像选择:PyTorch 2.4+ -> Python 3.11 -> CUDA 12.x (或 MUSA 对应版本)。
  2. 安装依赖库: 2026 年的标准微调全家桶。

    hljs bash
    1
    pip install transformers accelerate bitsandbytes peft modelscope
    

1.2 第二阶段:模型下载

目标:获取 Qwen2.5-7B (BF16) 基座模型。

  1. 创建下载脚本

    hljs bash
    1
    touch download_qwen.py
    
  2. 代码内容

    hljs python
    1
    2
    3
    4
    from modelscope import snapshot_download
    # Qwen2.5 依然是 2026 年性价比极高的基座,适合做微调实验
    model_dir = snapshot_download('qwen/Qwen2.5-7B-Instruct', cache_dir='/root/autodl-tmp')
    print(f"Model downloaded to: {model_dir}")
    
  3. 执行

    hljs bash
    1
    python download_qwen.py
    

1.3 第三阶段:构建实验代码 (The Experiment)

目标:编写脚本 lora_vram_test.py,对比 Base Model、Standard LoRA 和 Bloated LoRA 的显存占用。

  1. 创建主实验脚本

    hljs bash
    1
    touch lora_vram_test.py
    
  2. 编写代码: 我们不真的训练,只初始化模型和优化器,因为这就足以分配显存了。

    hljs python
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer
    from peft import LoraConfig, get_peft_model
    import time
    
    model_id = "/root/autodl-tmp/qwen/Qwen2.5-7B-Instruct" # 请替换实际路径
    
    def print_gpu_memory(tag=""):
        torch.cuda.synchronize()
        allocated = torch.cuda.memory_allocated() / 1024**3
        reserved = torch.cuda.memory_reserved() / 1024**3
        print(f"[{tag}] Allocated: {allocated:.2f} GB | Reserved: {reserved:.2f} GB")
    
    print(">>> Phase 1: Loading Base Model (BF16)...")
    # 使用 BF16 加载,这是当前训练的标准精度
    model = AutoModelForCausalLM.from_pretrained(
        model_id, 
        torch_dtype=torch.bfloat16, 
        device_map="cuda"
    )
    print_gpu_memory("Base Model Loaded")
    
    print("\n>>> Phase 2: Happy Path (Standard LoRA r=8)")
    # 针对所有 Linear 层,这是目前提升效果的主流做法
    config_happy = LoraConfig(
        r=8, 
        lora_alpha=32, 
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
        lora_dropout=0.05, 
        bias="none", 
        task_type="CAUSAL_LM"
    )
    model_happy = get_peft_model(model, config_happy)
    model_happy.print_trainable_parameters()
    
    # 模拟创建优化器 (AdamW 需要维护状态,这是显存大头)
    optimizer = torch.optim.AdamW(model_happy.parameters(), lr=1e-4)
    print_gpu_memory("LoRA r=8 + Optimizer Init")
    
    # 清理显存,为 Sad Path 腾地儿
    del model_happy, optimizer
    torch.cuda.empty_cache()
    
    print("\n>>> Phase 3: Sad Path (Rank Inflation r=2048)")
    # 极高的 Rank 使得参数量激增,LoRA 失去了“低秩”的意义
    config_sad = LoraConfig(
        r=2048, 
        lora_alpha=4096, 
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
        task_type="CAUSAL_LM"
    )
    # 注意:这里需要重新 wrap base model,实际操作中通常重新加载,为了简化直接用原对象
    model_sad = get_peft_model(model, config_sad)
    model_sad.print_trainable_parameters()
    
    try:
        optimizer_sad = torch.optim.AdamW(model_sad.parameters(), lr=1e-4)
        print_gpu_memory("LoRA r=2048 + Optimizer Init")
    except RuntimeError as e:
        print(f"!!! OOM Triggered as expected: {e}")
    
    hljs output
    1
    (待运行结果:预期 Phase 2 显存几乎不涨,Phase 3 显存暴涨甚至 OOM)
    

1.4 第四阶段:执行与观测

  1. 开启监控

    hljs bash
    1
    watch -n 0.5 nvidia-smi
    
  2. 运行实验

    hljs bash
    1
    python lora_vram_test.py
    
  3. 观测重点

    • r=8 时,Trainable params 通常 < 1%,优化器显存占用极小(几十 MB)。
    • r=2048 时,LoRA 层的参数量可能达到数亿,优化器状态随之膨胀到 GB 级别,逼近全量微调的开销。

1.5 第五阶段:实验结果分析指南

为什么 Rank 膨胀会炸显存?

  1. 优化器状态是隐形杀手:AdamW 优化器要为每个可训练参数维护两个状态(Momentum 和 Variance)。
    • 全量微调:7B 参数 -> 需要 14B 个状态值(FP32存储) -> 约 56GB 显存(直接 OOM)。
    • LoRA r=8:0.1B 参数 -> 需要 0.2B 个状态值 -> 几百 MB 显存。
    • LoRA r=2048:参数量剧增,又回到了全量微调的噩梦。
  2. 架构师视角:LoRA 的本质是用计算换显存。我们冻结了主干,只更新旁路。但如果旁路太宽(Rank 太大),不仅显存没省下,计算量(GEMM)还增加了。

2. 实验八:进阶实验

工业界不关心你训练有多省显存,只关心推理时 Adapter 切换有多快,以及 Kernel Launch 的延迟。

2.1 进阶 1:Adapter 切换延迟与 Kernel 开销测试 (Latency & Throughput)

  • 缺失点:大多数教程只讲训练。但在生产环境(如云服务),一个基座模型需要挂载几十个不同的 LoRA 给不同用户用。**切换速度(Hot-swapping)**才是核心指标。
  • 操作:对比 Merged(权重合并)与 Unmerged(动态挂载)的推理延迟。
  • 观察
    • N 厂现象:Unmerged 略慢,但在 Tensor Core 强力调度下差异可控。
    • 国产挑战:如果在国产卡上,Unmerged 可能会因为过多的小算子(Small Kernels)导致严重的 Kernel Launch 瓶颈,延迟显著增加。

执行步骤

  1. 创建文件 lora_latency.py

    目标:测试 LoRA 在推理时的额外开销。

    hljs python
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer
    from peft import LoraConfig, get_peft_model
    import time
    import numpy as np
    
    model_id = "/root/autodl-tmp/qwen/Qwen2.5-7B-Instruct"
    
    # 1. 加载模型
    model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.float16, device_map="cuda")
    tokenizer = AutoTokenizer.from_pretrained(model_id)
    inputs = tokenizer("摩尔线程是一家", return_tensors="pt").to("cuda")
    
    # 预热
    _ = model.generate(**inputs, max_new_tokens=10)
    
    # 2. Baseline: 纯基座模型推理
    start = time.perf_counter()
    for _ in range(20):
        _ = model.generate(**inputs, max_new_tokens=20)
    torch.cuda.synchronize()
    avg_base = (time.perf_counter() - start) / 20
    print(f"Base Model Latency: {avg_base*1000:.2f} ms")
    
    # 3. LoRA Unmerged (动态计算)
    config = LoraConfig(r=64, lora_alpha=16, target_modules=["q_proj", "v_proj"])
    model = get_peft_model(model, config) # 此时 LoRA 是挂载的独立层
    
    start = time.perf_counter()
    for _ in range(20):
        _ = model.generate(**inputs, max_new_tokens=20)
    torch.cuda.synchronize()
    avg_lora = (time.perf_counter() - start) / 20
    print(f"LoRA (Unmerged) Latency: {avg_lora*1000:.2f} ms")
    print(f"Overhead: {((avg_lora - avg_base)/avg_base)*100:.2f}%")
    
    # 4. LoRA Merged (权重合并)
    model.merge_and_unload() # 将 LoRA 权重加回基座,变回标准线性层
    
    start = time.perf_counter()
    for _ in range(20):
        _ = model.generate(**inputs, max_new_tokens=20)
    torch.cuda.synchronize()
    avg_merged = (time.perf_counter() - start) / 20
    print(f"LoRA (Merged) Latency: {avg_merged*1000:.2f} ms")
    
    hljs output
    1
    (待运行:预期 Unmerged 会比 Base 慢 10-20%,而 Merged 应该与 Base 持平)
    
  2. 运行文件

    hljs bash
    1
    python lora_latency.py
    
  3. 分析: 如果 Unmerged 慢了很多,说明 GPU 在处理大量细碎的矩阵乘法(LoRA 的 $A \times B$)时,调度开销大于计算开销。这就是我们在驱动层做 Kernel Fusion(算子融合)要解决的问题。


3. 实验总结与核心知识点

3.1【核心结论】

LoRA 是“时间换空间”的极致体现。通过增加推理时的计算路径(旁路),换取了训练时优化器显存的指数级下降。但在生产端,必须使用 merge_and_unload() 消除推理延迟,否则你的 API 响应速度会被友商吊打。

3.2【技术解剖:计算与带宽】

  1. NVIDIA 方案:CUDA 编译器对 GEMM 极度优化,即使不合并权重,Tensor Core 也能快速吞吐 LoRA 的小矩阵计算。
  2. 国产化方案:我们 MUSA 架构目前正在全力优化 FlashAttention 与 LoRA 的兼容性。在国产硬件上,PCIe 带宽往往是瓶颈,因此在多租户场景下,我们会建议将常用 Adapter 常驻显存,而不是频繁从 CPU 搬运。

3.3【关键概念 (Knowledge Points)】

  • Rank (r):信息的压缩比。r 不是越大越好,超过一定阈值(如 r=64 对于简单任务),边际效益递减,显存和延迟却线性增加。
  • Optimizer States:显存占用的真正大头。7B 模型全量微调需要 ~50GB 显存,其中 ~40GB 都是优化器状态,而不是模型权重。
  • Kernel Launch Overhead:每次调用 GPU 计算都有固定开销。LoRA 引入了额外的小矩阵乘法,如果不合并权重,这些微小的 Kernel 启动时间累积起来会显著拖慢推理。

老鸟锐评: “别看到显存降了就沾沾自喜。在实验室里,省显存是本事;在生产线上,因为不合并 LoRA 权重导致推理延迟增加 20ms,那就是事故。硬件决定下限(显存),工程决定上限(延迟)。”