> 一个"不覆盖已有结果"的守卫,会把可重试的失败变成不可重试的

Systems & Infrastructure Intermediate 2026-10-13 20:30 2026-10-13
#retry #idempotence #gpu #guardrails #validation #failure-modes

一个跑 GPU 实验的 runner 加了两个都很合理的机制,合起来把自己锁死了:

// TABLE_OF_CONTENTS

结论先行

一个跑 GPU 实验的 runner 加了两个都很合理的机制,合起来把自己锁死了:

机制 理由 位置
启动时写 command.json 记下这次跑的到底是什么参数 加载模型之前
拒绝任何非空的输出目录 不要覆盖一次已有的结果 同一处
launcher 在可重试错误上 sleep 后重试 显存不够 / 别的租户占着卡,等一会儿就好 launcher

于是:显存不够导致的失败,如果发生在目录创建之后, 目录里就留下了一个 command.json;下一次重试立刻 FileExistsError 退出。 那条"等一会儿就好"的路径一次也走不通。

后来的 v2 launcher 改成"目录存在、且里面没有 result.json 时才删掉重排队"。 这保住了已完成的运行,但把判据换成了另一个代理: result.json 存在就算完成——而写这个文件的过程被打断、或者文件本身残缺, 一样算完成。

同一轮审查还抓到一处纯粹的顺序问题: --lora 和 --save-fork-checkpoint 这两个参数不兼容,而这个拒绝写在训练循环之后。 一条只看命令行参数就能判定非法的组合,会先花掉整整一次训练, 然后退出,既没有 result.json 也没有 checkpoint。

为什么

这三件事是同一个问题的三张脸:失败处理是一个顺序问题, 而"完成"必须是一个被明确定义、且各方共用的谓词。

① 守卫和重试必须共用同一个"完成"定义。 "拒绝非空目录"隐含的定义是"目录非空 = 这里跑过了"; 重试隐含的定义是"没有产出 = 可以再来一次"。 两个定义在"创建了目录但没跑完"这个状态上正好相反,而那个状态恰恰是失败后的常态。 正确做法是定义一个终止标记——最后一步原子写入的那个文件—— 然后让守卫和重试都读它:有标记就不许覆盖,没标记就可以清掉重来。 注意这和"存在即完成"不是一回事:终止标记要最后、原子地写 (写临时文件再 rename),这样它的存在本身就意味着前面都做完了。

② 校验按代价排序。 一条检查只依赖命令行参数,它就该在 argparse 阶段跑。 修补之后的代码把这件事和理由一起写下来了:

if args.lora and args.save_fork_checkpoint:
    # validate BEFORE model load/training: the late check alone wasted a
    # full run (Codex T2 review, 2026-10-08)
    ap.error("--save-fork-checkpoint is full-FT only (...)")

注意那条晚的检查留着没删,这是对的:它不花钱,而且它防的是 "参数以外的状态也可能导致同一种不兼容"。 早的检查省钱,晚的检查兜底,两个都要。

排序的通用形态是按代价分层:

只看参数的        → argparse 阶段
只看文件系统的    → 加载模型之前
需要模型/数据的   → 加载之后、训练之前
只有跑完才知道的  → 训练之后(这一层才允许昂贵地失败)

③ 一个"可重试"的错误,要确认它真的可重试。 显存不够是典型的可重试错误——前提是重试的路径是通的。 写了重试而路径被自己的守卫堵住,比没写重试更糟: 它让日志里出现一串"retrying…",而每一次都在同一个地方立刻失败。

怎么用

出处

自己的 run_llm_collapse_experiment.py 与三个 launcher 脚本,一次只读代码审查 (2026-10-08):command.json 写在模型加载之前且拒绝非空目录, 两个 launcher 的可重试分支都没有清理目录,v2 改用 result.json 存在性当完成判据; LoRA 与 --save-fork-checkpoint 的不兼容原本在训练循环之后才拒绝。 参数级拒绝已前移到 argparse 阶段(晚的那条保留),上面那段注释是修补时写的原文。