LoRA Fine-Tuning in Practice: Pipeline and Seven Pitfalls, Part 2
训练启动即 ModuleNotFoundError: No module named 'team_code'——包明明就在仓里,shell 里也 export PYTHONPATH=... 过了。
启动链路是 bash → conda run → python。export 只影响当前 shell 及其后续子进程的环境表;每多一层包装(conda run、nohup、ssh、cron、CI runner),“当前 shell 设过变量”这个前提就可能断掉。环境变量是最典型的隐式会话状态:设了的时候一切正常,换个终端、换个启动器就凭空消失,而报错只会说”模块找不到”,一个字不提 PYTHONPATH。
把变量写进命令本身,让环境跟着命令走:
conda run -n mysim-simlingo \
env PYTHONPATH=/home/xsl/MySim/simlingo_training \
WANDB_MODE=offline \
python train.py experiment=ue5_ft
env VAR=val cmd 的形式由 env 工具在 exec 目标进程前注入变量,不依赖任何上层 shell 状态。谁启动、从哪启动、隔多久启动,结果都一样。
环境类坑的特征是:一次修完,永久消失。它不涉及逻辑,修复是幂等的。真正要避免的是”在我机器上能跑”式的半修——改完 export 没写进启动脚本,三个月后换个会话再咬你一口。修复的完成态是”写进脚本/文档”,不是”这次跑起来了”。
训练启动后 DataLoader 抛 KeyError: 'augmentation'。自采数据的 measurements 里根本没有这个键。
SimLingo 官方数据的每帧 measurements JSON 带 augmentation 字段(标记该帧是否增广生成)。dataset 读 measurements 时无条件取这个键——即使配置里增广已关闭。自采数据没有该字段,dict['augmentation'] 直接 KeyError。
这类 bug 的本质是读侧契约:消费方对数据 schema 的要求是硬性的全集,和配置开关无关。你提供给它的每一帧必须满足读侧代码实际访问的所有键,而不是”你打算用的功能”涉及的键。
离线补写:遍历全部 measurements 文件,缺键的补 "augmentation": 0.0(0.0 = 非增广帧,语义上等于”原始数据”)。t44-patch-aug 日志记录 patched 549852——55 万帧级别的批量补丁,必须幂等(已补过的跳过),可重入。
if "augmentation" not in rec:
rec["augmentation"] = 0.0 # 0.0 = 原始帧,非增广
patched += 1
rec[...] 访问点收集成清单。配置里增广(augmentation)明确关闭,训练读到第 N 个样本时却抛 FileNotFoundError——找的是 rgb_augmented/ 目录下的文件,而自采数据只有 rgb/。
与坑三(KeyError)是同一族:读侧代码的文件路径构造不查增广开关,无条件按 augmented 路径拼文件名。配置项只控制”增广逻辑是否生效”,控制不了”读侧代码走哪条路径”。两条坑连起来是一条完整教训:
配置是意图,代码路径是现实。验证兼容性要按代码路径走,不能按配置想象。
从使用者视角,“关掉增广”是一个语义完整的操作;从实现视角,读图路径、字段读取、增广变换是三处独立代码,作者只在其中一处接了开关。配置项与代码路径之间的覆盖关系没有任何机制保证——没有报错能告诉你”这个开关没接管那条路径”,只有跑到那里才知道。
if use_aug: ...);控制不了(官方仓不宜大改)时,才用补数据/软链接把现实凑齐。train3 日志尾部只有一行:
/tmp/tmplq1gvn4z: line 16: 932757 Killed env WANDB_MODE=offline ... python train.py
没有 Python traceback,没有堆栈,没有任何告别。更诡异的是 train2 用同样配置跑通过,显存也没超。
Killed(退出码 137 = 128+9)意味着进程收到 SIGKILL——一个不能被捕获、不能被忽略、不能有析构机会的信号。Python 进程自己永远不会发出这个信号;发它的是 Linux 内核的 OOM killer(Out-of-Memory 杀手)。
机制:系统内存耗尽时,内核不能抛异常让谁”处理一下”,只能挑一个进程杀掉腾内存。挑选依据是每个进程的 oom_score(大致与常驻内存成正比,可被 /proc/<pid>/oom_score_adj 调节)。被杀者没有任何机会写日志——所以你看到的就是 shell 报的一行 Killed,别指望应用层留下任何东西。
# 内核日志里找死刑记录
dmesg -T | grep -i "killed process"
# 或 systemd 系统
journalctl -k | grep -i oom
记录里会有完整的内存快照:总内存、各进程 RSS、被选中者的 oom_score。凡是进程无疾而终,先查 dmesg,再怀疑应用。
显存(VRAM)没超——OOM killer 管的是系统内存(RAM),不管 GPU 显存,两者是独立的账本。本机 WSL 内存预算 32GB(.wslconfig 降档值),训练进程模型加载、DataLoader、字体缓存全在里面。train2 能过、train3 被杀,说明内存占用处于临界带,真正的答案在”谁和它一起抢了内存”——见坑五(下)的残骸进程叠加。
ps 一看:两个 train 进程并存。上一次崩溃的训练没死透,残骸还在;watcher 的判活逻辑漏判了它,以为”没在跑”,又起了新一轮。两个进程各载一份约十亿参数级的模型加各自的 DataLoader 缓存,WSL 的 32GB 内存预算直接爆顶,OOM killer 按 oom_score 挑中新进程(RSS 大者得分高)下手——这就是 train2 能过、train3 被杀的全部原因。
朴素判活长这样:
pgrep -f train.py # 危险:模式匹配太宽
三个经典失误:
pgrep -f train.py 会匹配到 conda run 包装进程、tee 管道、甚至发起查询的复合 shell 自身——判活误报”还在跑”,或反向漏掉真身。# 启动时:记录精确身份
python train.py ... &
echo $! > run/train.pid
# 判活:精确到命令行
ps -p "$(cat run/train.pid)" -o args= | grep -q "experiment=ue5_ft"
# 清理:按 PID 杀,不按名字
kill "$(cat run/train.pid)"
原则三条:判活匹配精确到命令行(进程名 + 关键参数);身份以 PID 文件为准(启动瞬间记录,不靠事后 grep);kill 按 PID,永不远程模式匹配。本项目 AGENTS.md 另有血泪补充:pgrep -f 的模式会匹配发起 kill 的 shell 自身,kill 相关命令禁止内联 pgrep 模式——本里程碑又踩了 3 次,累计 5+ 次才立成永久规矩。
“上一次的任务死干净了吗”是无人值守训练的前置条件。判活不是一句 ps 的事,它和”日志是否在长""产物是否在落”构成三层体系——进程在,只是第一层。
训练本身完全健康,跑到 epoch 末的验证可视化阶段突然崩掉:
OSError: cannot open resource
File ".../visualise_waypoints.py", ... ImageFont.truetype("arial.ttf", ...)
traceback 指向可视化回调 visualise_waypoints:它要给路点图写说明文字,调 PIL 的 ImageFont.truetype 加载 arial.ttf——仓库里没有这个文件。arial 是微软商业字体,Linux 发行版和 Python 环境默认都没有。
train5 陪葬最惨:999 步训练全部完成,在 epoch 末验证可视化时崩溃报废。损失不在崩溃本身,而在崩溃的时机——最贵的一段算力已经花完。
朴素但有效:字体文件放进仓根,真实躺在那里,脚本按 repo 相对路径就能命中。更通用的替代是 DejaVu Sans(matplotlib 自带、开源、字形覆盖足够),把字体名改成环境里有保证的那个。
try/except 降级为 warning、跳过该图继续训练,999 步就不会陪葬。锦上添花的功能没有资格杀死主干任务。在 simlingo_training/ 子目录里启动训练,立刻抛:
git.exc.InvalidGitRepositoryError
GitPython 探测当前仓库的方式是从 cwd 向上逐级找 .git/。在错误的目录启动,要么找不到 .git,要么找到外层另一个仓库——训练脚本里记录 git commit 的那一步直接炸。
这只是冰山一角。这个仓的启动序列里有三处隐式锚定 cwd 的约定:
experiment=ue5_ft 按相对路径找 config/experiment/ue5_ft.yaml;data_path 等相对路径以仓根为基准。三处都在启动序列的头几秒引爆,但报错各不相同(配置文件找不到 / InvalidGitRepositoryError / 数据目录不存在),单看任何一个都不会想到”目录站错了”。
一条铁律:一律 cd 仓根再启动。写进启动脚本第一行,不靠人记:
#!/usr/bin/env bash
set -euo pipefail
cd /home/xsl/MySim/simlingo_training # 隐式约定显式化
exec conda run -n mysim-simlingo env PYTHONPATH=$PWD python train.py experiment=ue5_ft
“必须从仓根启动”这类约定在任何 README 里都活不长——它不出错时毫无存在感。两个处理方向:能消除就消除(代码里用 __file__ 推导仓根,不依赖 cwd);不能消除就显式化(启动脚本开头 cd 钉死,并在配置加载后打印实际解析到的绝对路径)。本项目是后者:约定保留,但每次启动都不再依赖操作者的记忆力。
v4 扩量到 19 万样本后,训练在 step 1–2 静默挂起:进程活着,GPU 占用不低,但 loss 永不更新、日志永不追加。没有异常、没有报错,和 OOM killer 的”一行 Killed”相比连那一行都没有。
换数据集会说话:
| 数据集 | 结果 |
|---|---|
| 全量 19 万样本 | step 1–2 挂起 |
| 749 条路线集 | 同样卡死 |
| train8 的 250 条名单 | 正常跑通 |
训练代码、环境、配置三者不变,唯一变量是样本名单——嫌疑收敛到某个/某类坏样本:特定帧触发驱动级挂起(如畸形 JPEG 解码出非法尺寸张量,送进 CUDA 内核后计算不返回)。因为 PyTorch 的 CUDA 默认异步执行,挂起点和报错点(如果有)都不对应真实肇事代码,定位极难。
崩溃留 traceback,挂起什么都不留。可用的手术刀:
# 挂起中给活进程拍快照:各线程栈在哪
py-spy dump --pid <train_pid>
# 让 CUDA 同步执行,挂起点即肇事点(慢,仅诊断用)
CUDA_LAUNCH_BLOCKING=1 python train.py ...
py-spy dump 不需要重启进程,直接看 Python 层卡在哪个调用(读数据?前向?通信?);CUDA_LAUNCH_BLOCKING=1 强制每个 CUDA 调用同步返回,把”异步甩锅”变成”当场抓获”;此案未破——扩量前的二分定位没做完,被列入未穷尽清单存档;v4 改用 333 条复刻名单(train8 已验证可跑的子集扩展)完成重训,先保里程碑交付。这是工程上的正确取舍:挂起类问题排查成本无上限,而”绕开坏样本”的成本只是一份名单。但清单必须留档,否则”绕开”会悄悄变成”遗忘”。
无人值守训练的经典错觉:进程在 = 任务在推进。坑五(残骸叠加 OOM)证明”进程在”会骗你——残骸进程活着但不干活;加时赛的 CUDA 静默挂起证明”GPU 忙”也会骗你——占用拉满但 loss 永不更新。判活必须分层,每层盯一种死法:
| 层 | 问题 | 手段 | 抓什么 |
|---|---|---|---|
| 1. 进程在吗 | 身份 | 精确到命令行匹配 + PID 文件 | 崩溃、OOM 被杀、残骸叠加 |
| 2. 日志在长吗 | 活性 | grep 完成标记 / 写入超时检测 | 死锁、挂起、静默停滞 |
| 3. 产物在落吗 | 进度 | find -newer 上轮标记的 ckpt | 假活(在跑但不出货) |
第一层:身份精确。启动时 echo $! > run/train.pid 记下身份;判活用 ps -p <pid> -o args= 核对命令行关键参数;清理按 PID 杀,禁止按名字/模式远程 kill(坑五的教训:模式匹配会误伤同名进程和自己的 shell)。
第二层:活性超时。训练日志是心跳:正常时每几秒必有写入。盯日志文件的 mtime,超过阈值(如 25 分钟)无写入即停滞嫌疑;同时 grep 完成标记(如 Epoch 0 / Trainer.fit 结束行)区分”正常结束”与”卡住”。坑八那种 GPU 满载的静默挂起,只有这一层能抓到。
第三层:产物新鲜度。find outputs/ -name "*.ckpt" -newer run/last_marker——有新于上轮标记的 ckpt 才算真出货。进程活着、日志在长,也可能在空转(比如 dataloader 死循环重试);产物是最难伪造的证据。
三层判活对应三类不同的处置:第一层失守→清理残骸再起;第二层失守→抓快照(py-spy dump)再杀;第三层失守→检查配置与数据。判活分层的真正价值不在”发现死”,在于死的类型决定了下一步动作——混为一谈的 watcher 只能报警,分层的 watcher 能处置。
t45_final.sh 的无人值守接力:训练完成 → zero_to_fp32 导出 → 992 键同构校验 → 单条快检 → 全量 40 条评测。快检是链上唯一一道”质量门”:
# 只跑 route 10000 一条路线,约二十几分钟
run_eval_single.sh route_10000
if grep -q '"status": "Completed"' results/route_10000.json; then
run_eval_full.sh # 放行:全量 40 条
else
touch run/FAILED_marker # 打标记,停下留人工
exit 1
fi
成本账很直白:快检二十几分钟,全量评测几小时。微调权重最常见的失败模式是权重级崩溃(如 16/16 超时那种:满油门死舵,每条路线都跑满预算才判死)——这种崩溃一条路线就能确诊,用全量 40 条去确诊等于把确诊成本放大 40 倍。门禁用小成本把”全量评测”这个昂贵动作的触发条件收紧到”大概率值得跑”。
门禁失败不做任何自动重试,只做一件事:touch 一个失败标记文件然后退出。原因:
这条链的分工很克制:自动化负责体力活,判断权留给人。训练、导出、校验、评测这些确定性的体力活全自动接力;一旦进入”这个结果对不对”的判断领域,链主动断开,把现场和证据(ckpt、日志、标记)整理好等人回来。自动化系统的可靠性不取决于它多能扛,而取决于它知不知道什么时候该停。