LoRA Fine-Tuning in Practice: Pipeline and Seven Pitfalls, Part 1
LoRA(Low-Rank Adaptation,低秩适配)冻结预训练权重 ,在旁边加一条低秩旁路:
其中 、,秩 ; 是缩放超参, 是实际生效的缩放系数。前向时两路输出相加,反向传播只更新 、, 全程不动。
本集配置 、,缩放系数 。原论文默认 (系数 1),把 设为 相当于给旁路输出整体放大一倍——和把学习率调大一倍效果接近,但只作用于 LoRA 支路。
域适配(domain adaptation)要学的不是新能力,而是把已有能力”掰”向新分布。这类改动在参数空间里往往是低秩的:InternVL2-1B 主干已经会开车,微调只需在少数几个主方向上做旋转和拉伸。秩 32 的 最多表达 32 个独立的修改方向——对域适配通常够用,对从头学新任务则远远不够。
一层 的全量权重有 个参数,LoRA 旁路只有 个。以 、 为例:
单层旁路参数只有原层的 1/16,且原层被冻结不产生梯度和优化器状态。这就是 SimLingo 全模型 6.5 亿参数、可训练参数却只有 1759 万(2.72%)的原因。
冻结主线还有一层工程价值:学坏了只坏小路。微调失败时权重出问题,嫌疑范围被天然限制在 、 两组矩阵上,主干 有官方 ckpt 背书。推理前还可以 merge_and_unload 把 合回 ,得到一个无旁路的普通模型。
PyTorch Lightning 训练启动时打印的参数统计:
trainable params: 17,596,416 || all params: 647,260,288 || trainable%: 2.7186
这三个数字是”LoRA 真的挂上了”的直接物证。 正好落在 LoRA 微调的典型区间(1–5%)。如果适配层没注入成功,这行会变成 trainable = all(全量微调),或者 trainable 大一个数量级。
LoRA 注入失败的最坏情况不是报错,而是静默退化成全量微调:训练照常跑、loss 照常降,但 6.5 亿参数在十万级样本上必然过拟合,而且优化器状态显存直接翻倍(Adam 需额外 2 份动量)。等评测发现行为崩溃时,已经无法回溯是哪一环错了。启动后第一件事核对这行数字,成本是一眼,省的是一整轮训练。
排查行为崩溃时,有人看到日志里另一个统计口径显示 327M 可训练参数,一度怀疑 LoRA 没挂上、在裸调全模型。实际是 Lightning 的统计口径把 PEFT 包装模块整组计入:被 LoRA 包了一层的线性层,整个原层参数都被算进”该模块的参数”。而优化器真正收到梯度的只有 、 两组低秩矩阵——17,596,416 才是优化器视角的真实数字。
教训:同一个”可训练参数”在不同框架口径下可以差 18 倍。拿数字下结论前,先确认它统计的是”优化器更新的张量”还是”含可训练子模块的模块”。
CARLA 的前视 RGB 图像底部约四分之一是引擎盖和挡风玻璃边框——不含驾驶信息。SimLingo 官方训练管线把图像底部 25% 裁掉再进模型,评测链的 agent 侧也固定裁底约 30%。cut_bottom_quarter: true 就是训练侧的对应开关。
卷积和 ViT 特征都是位置敏感的。如果训练不裁底、评测裁底,会发生两件事:
这正是 v1/v2 失败的现行假设之一:训练裁 0%、评测裁约 30%,中间差了整整一个条带的表征。v4 打开 cut_bottom_quarter 后才与评测链对齐。
裁剪、resize、通道顺序、归一化常数、坐标系——这些预处理步骤不出现在模型代码里,却是训练/评测之间的隐式契约。任何一侧单方面改动,契约即破裂,而且不会报任何错:loss 照常收敛,闭环评测才现形。排查这类问题时,正确的姿势是把两侧的数据预处理逐字段摆在一起对表,而不是盯着模型本身。
本项目数据侧其余开关同理:commentary、qa 关闭是因为自采数据没有语言标注流,开模型会去读不存在的字段;增广关闭是加速档的取舍,不是数据问题。
WANDB_MODE=offline \
TOKENIZERS_PARALLELISM=false \
MALLOC_ARENA_MAX=2 \
PYTHONPATH=/home/xsl/MySim/simlingo_training \
conda run -n mysim-simlingo python train.py experiment=ue5_ft
每个前缀变量都是一次踩坑的产物,逐一说。
Weights & Biases 默认把指标实时同步到云端。内网环境下每次网络超时都会拖慢训练循环,甚至阻塞进程。offline 模式把 run 数据写本地 wandb/ 目录,事后需要时再 wandb sync 上传。
Hugging Face 的分词器(tokenizer)底层是 Rust 实现,默认开多线程。而 PyTorch DataLoader 用 fork 起 worker 子进程——fork 一个已经有活跃线程的进程,子进程只继承调用线程,其余线程持有的锁会永远保持锁定状态。worker 再碰分词器内部的全局锁就死锁。这是”fork + 多线程”的经典事故形态,关掉分词器内部并行即绕开。
glibc 的 malloc 为减少多线程竞争,会给线程分独立的内存池(arena),默认上限是 CPU 核数 ×8 个 arena。每个 arena 独立向内核申请堆空间、独立的空闲链表,已释放的内存不跨 arena 归还——多线程训练进程(dataloader worker、CUDA 驱动线程、tokenizer 线程)下 RSS 会持续膨胀,像泄漏实为碎片。MALLOC_ARENA_MAX=2 把 arena 数压到 2,牺牲一点锁竞争换内存可控。在 32GB 内存预算的 WSL 上,这个变量是防 OOM 的工事之一。
PYTHONPATH 指向仓根让 team_code 等包可 import;把它写进命令行本身(而不是依赖 shell 的 export),保证无论谁、从哪个 shell 启动都一样——环境跟着命令走。
experiment=ue5_ft 是 Hydra 的配置组合语法:Hydra 按实验名从 config/experiment/ue5_ft.yaml 叠加出完整配置(模型、数据、训练器各层覆盖默认值)。注意 Hydra 的相对路径、data_path 都以**启动时的 cwd(仓根)**为锚,在子目录启动会在配置解析阶段就崩。
标准数据并行(DP)每个进程都持有一份完整的模型状态。ZeRO(Zero Redundancy Optimizer,零冗余优化器)把这些状态切片分布到各进程,用到时再通信聚合,按切的内容分三级:
| 级别 | 切分对象 | 单卡显存节省来源 |
|---|---|---|
| ZeRO-1 | 优化器状态(Adam 的 、 两份动量) | 训练侧大头 |
| ZeRO-2 | + 梯度 | 反向传播缓冲区 |
| ZeRO-3 | + 模型参数本身 | 前向时按需聚合权重 |
对 6.5 亿参数模型做 Adam 训练,优化器状态约占 GB(fp32 双动量),是 ZeRO-1 最直接的收益。
ZeRO 本是多卡技术,但日志里 GLOBAL_RANK=0、world size 为 1 时切片退化为”一份”——照样省显存,因为优化器状态可以按 ZeRO-1 的方式延迟物化、分块更新。单卡 32GB 的 5090 上微调 VLM,每一 GB 都要精打细算,DeepSpeed 顺手把 checkpoint 管理、bf16 混精也一并接管。
天下没有免费的午餐。ZeRO 的 checkpoint 按切片格式落盘:
epoch=000.ckpt/ # 名字像文件,实际是目录
└── checkpoint/
├── mp_rank_00_model_states.pt # 模型权重切片
└── optim_states/ # 优化器状态切片
这份产物不能直接喂给推理——推理代码期望一个扁平的 state_dict,而这里是按 rank 切片、带 DeepSpeed 包装前缀、可能还是 bf16 的中间形态。从训练产物到可评测权重之间,必须过一次导出(见 zero_to_fp32 的合并流程)。
另一个隐藏约定:评测 agent 加载 ckpt 时按约定上溯三级目录找 .hydra 配置快照来复原模型结构。拷贝产物时层级错一级,agent 在启动阶段直接起不来。
DeepSpeed 自带的 zero_to_fp32.py(就生成在 ckpt 目录里)把 ZeRO 切片 checkpoint 重建成普通单文件权重,三件事:
module.、model. 等前缀,要逐层剥回裸模型约定;pytorch_model.pt。python zero_to_fp32.py epoch=000.ckpt/checkpoint pytorch_model.pt
导出结果不能”跑起来就算对”。校验方法是和官方 ckpt 做逐键比对:
sd_new = torch.load("pytorch_model.pt", map_location="cpu")
sd_ref = torch.load("official_ckpt.pt", map_location="cpu")
assert set(sd_new) == set(sd_ref) # 零多零缺,共 992 个键
for k in sd_ref:
assert sd_new[k].shape == sd_ref[k].shape
键集合完全一致(本项目实测 992 个键,零多零缺)、形状逐一对上,才算结构同构。键级别同构之后再看数值:手动导出与官方脚本导出的抽样 200 个键相对差约 ,属 bf16→fp32 转换的精度噪声级,不算差异。
后来评测出现”16 条路线全部超时”的行为崩溃时,992 键同构校验成了排除导出错误嫌疑的铁证:权重导出环节被一举洗清,排查矛头才能转向数据分布(target_point 语义错配、裁剪对齐)。没有这份校验,排查链的第一环就会陷入”是不是导出弄坏了”的无限怀疑。
教训:每一个”格式转换”环节都要留下可复核的等价性证明。转换本身几行代码,证明它没弄坏东西才是工作量。
训练框架把每步指标写成 JSONL(JSON Lines):一行一个 JSON 对象,天然适合流式追加、逐行解析,崩了也只损失最后一行。画图就是一次过滤加投影:
import json, matplotlib.pyplot as plt
steps, losses = [], []
with open("train_metrics.jsonl") as f:
for line in f:
rec = json.loads(line)
if rec.get("split") != "train": # 只要训练行,滤掉 val
continue
steps.append(rec["step"])
losses.append(rec["loss"])
plt.plot(steps, losses)
plt.yscale("log") # 关键:对数轴
plt.xlabel("step"); plt.ylabel("loss")
plt.savefig("loss_curve.png", dpi=150)
这条曲线的 loss 从第一步的 15.48 降到末步的 ,横跨约五个数量级。线性纵轴下,坐标按绝对值均匀分布:15.48 占满全图,0.01 以下的所有细节被压进最底部 0.1% 的像素里——后半程 9000 多步在图上就是一条贴着零的直线,收敛形态完全不可见。
对数轴下纵坐标按 均匀分布,15、1.5、0.15、0.015…每个数量级占同样高度,跳水段、磨底段、尾部抖动三段形态才能同时看清。经验规则:凡是跨两个以上数量级的序列,默认对数轴,需要看绝对值时再切回来。
同一份 jsonl 里混着 train 和 val 两类记录,不过滤 split 直接画,曲线会混入低频的验证点,出现莫名其妙的锯齿。日志结构先 head -1 看一眼再写解析,比猜字段名可靠。
| 阶段 | 锚点 | 含义 |
|---|---|---|
| 跳水 | step 1:15.48;step 139:破 1;step 547:破 0.01 | 最陡下降在前百余步 |
| 磨底 | 量级缓降 | 微调的主要信息量在这里 |
| 抖动 | lr 衰减到 ,loss 在 量级抖动 | 末步 ,val |
LoRA 从已会开车的官方 ckpt 出发,初始 loss 15.48 主要反映旁路 (初始 , 的缩放和首步梯度)带来的扰动。一百多步破 1,说明梯度路径通畅、学习率 3e-5 没炸。如果这段降不动或发散,问题出在挂载和学习率,不用等后面。
量级的缓降是域适配的主体:模型在把驾驶先验掰向 UE5 数据分布。这段绝对值没有参考价值——不同任务、不同损失权重的绝对值不可比。有参考价值的是斜率:对数轴上保持稳定的负斜率说明还在学;提前拉平(斜率归零)说明数据信息量耗尽或学习率过低。
OneCycleLR 类调度收尾时 lr 衰减到 ,参数几乎不再移动,loss 围绕一个极小值随机抖动。末步 train 、val :val 低于 train 不是 bug——验证集只有 83 条且关闭了 dropout(训练时 0.1),训练 loss 是带正则噪声的在线平均,验证是确定性前向,val 略低很常见。反过来 val ≫ train 才是过拟合警报。
数值意义上,这是一次健康的收敛。但”收敛健康”只覆盖训练分布本身——闭环行为是否健康,这条曲线一个字都没说。
SimLingo 的总损失是三个分量的加权和:
实测曲线(对数轴)各自的轨迹:
| 分量 | 起点 | 终点 | 监督内容 |
|---|---|---|---|
| route_loss | 10.2 | 未来路点(waypoint)轨迹回归 | |
| speed_wps_loss | 5.3 | 速度相关路点预测 | |
| language_loss | 语言-动作对齐 |
route_loss 收获最大:从 10.2 降到 ,近六个数量级,是总 loss 跳水段的主要贡献者。路点是驾驶行为的核心输出,它的收敛质量直接决定轨迹形状。
speed_wps_loss 慢热:起点 5.3 比 route 低,但收敛到 比 route 的终值高近一个数量级。速度预测依赖时序上下文(单帧图像看不出加速度),天然比几何路点难拟合,慢热是预期形态。
language_loss 恒零不是 bug:这次训练用的是纯驾驶 bucket(数据批次类型),没有语言标注流,该分量无监督信号、恒等于零。看到恒零分量的正确反应是核对数据 bucket 类型,而不是去”修”它——给不存在的监督信号写补丁,只会引入真 bug。
总 loss 一条曲线只能告诉你”在收敛”,分量曲线告诉你”谁在收敛”。如果总 loss 健康而某个分量不动,说明该分量的监督信号没接上(字段缺失、bucket 错配)——这类问题在总量级上经常被掩盖,必须拆开看。
v4 训练的 loss 曲线教科书般健康:15.48 降到 ,val ,无任何过拟合迹象。但同一批权重进闭环评测:16 条路线全部超时,零条 Completed。逐帧看控制输出,是一个稳定的”行为崩溃指纹”:
“满油门 + 死舵 + 不随时间变化”三合一,是权重级行为崩溃的稳定签名,区别于”模型谨慎慢行”或”偶发卡死”。
训练 loss 度量的是开环拟合优度:给定训练分布里的帧,预测与真值的偏差。闭环驾驶是另一回事——模型的输出改变下一帧的输入,误差沿时间轴累积(covariate shift,协变量偏移)。一旦微调把模型掰向一个与评测错配的分布(本案例的现行假设:训练/评测的图像裁剪不一致、target_point 目标点语义不一致),开环 loss 越好,说明它把那个错误分布拟合得越彻底,闭环崩得越干净。
用一句话概括:loss 收敛证明模型拟合了训练分布,不证明这个分布在闭环里能开车。分布本身错了,拟合质量反而是反向指标。
这个反例倒逼出的正确顺序是”对照实验优先”:拿官方 zero-shot ckpt 走同一条评测链——它 Completed 了,评测链即无罪,嫌疑被一刀切到”微调权重与评测链之间”。随后逐项排除:导出键同构(992 键零多缺)、数据速度分布健康(中位 3.74 m/s)、LoRA 确认挂载(2.72%)……每一步都有实证,最终收敛到训练/评测分布错配。
模仿学习里这个问题有经典理论刻画:DAgger 一类方法专为对抗闭环分布偏移而生——核心洞察就是”训练分布 ≠ 模型自己诱导出来的测试分布”。