【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on c

发布时间:2026/7/23 3:05:14
【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on c 【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on checkpoint rotation workloads 解决方案一、现象长什么样在 DeepSpeed 做 checkpoint 轮换checkpoint rotation即每隔若干步保存一次、并删除旧的的长期训练任务里运行一段时间几小时到几天后训练进程突然报错OSError: [Errno 28] No space left on device (ENOSPC)但df -h看磁盘明明还有空间du也找不到大文件。进一步lsof | wc -l发现进程打开了几十万个文件描述符fd且df -i显示inode 用完$ df -i Filesystem Inodes IUsed IFree IUse% Mounted on /dev/nvme0n1 10M 10M 0 100% /local_nvme这就是典型的「文件描述符泄漏 孤儿 inode」每个 checkpoint 保存时FastFileWriter打开了一个 fd 却没关闭文件被删除后 inode 仍被进程持有orphan inode直到 fd 耗尽 / inode 耗尽新写入全部失败。本期讲清根因并给三层修复。二、背景2.1 FastFileWriter 是干什么的DeepSpeed 的FastFileWriter以及SafetensorsWriter等用于在保存 checkpoint 时把分片参数快速写入文件。为了速度它通常会打开文件 → 写多个分片 → 关闭。问题在于「关闭」这一步在某些代码路径被遗漏。2.2 检查点轮换如何放大问题checkpoint rotation 意味着频繁「保存新 删除旧」。如果每次保存都泄漏 1 个 fd而保存频率是「每 100 步一次、训练 10 万步」那就是泄漏 1000 个 fd——看似不多但若每个 checkpoint 含多个分片文件、或进程长时间不退出、或 fd 上限设置较高累积起来轻松突破系统限制。2.3 为什么 df 有空间却 ENOSPCLinux 下「删除文件」只是 unlink 目录项只要还有进程持有该文件的 fdinode 与数据块就不释放。于是磁盘空间被「已删除但仍被打开」的文件占着孤儿 inodedf -h看的是数据块使用df -i看 inode 使用二者都可能满新文件分配不到 inode/块 → ENOSPC尽管「逻辑上」空间该够。三、根因3.1 fd 未关闭close 缺失 / 异常路径遗漏FastFileWriter在保存流程里打开文件句柄但存在代码路径在「写入成功但未显式close()」或「异常分支未走finally关闭」的情况。每个 save 泄漏一个 fd。3.2 用裸 open 而非上下文管理器如果保存逻辑用的是裸f open(...)而没有with上下文那么任何中途异常写一半出错、signal 中断都会让f.close()不被执行fd 永久泄漏。3.3 轮换删除早于 fd 释放checkpoint rotation 在「旧 checkpoint 目录被shutil.rmtree」时若其中文件仍被FastFileWriter打开着删除只是 unlinkfd 还在孤儿 inode 产生直到进程退出才释放。3.4 一句话根因FastFileWriter在每次保存 checkpoint 时打开文件描述符却未在成功/异常路径都关闭配合高频检查点轮换导致 fd 与 inode 持续泄漏已删文件成孤儿 inode最终df -i耗尽、新写入 ENOSPC尽管逻辑空间充足。四、最小可运行复现下面用一个本地可运行脚本演示「打开不关 删除文件」如何造成孤儿 inode用/proc/self/fd计数模拟泄漏import os, tempfile, gc def leaky_save(n: int, use_context: bool): 模拟 checkpoint 保存: 每轮 open 一个文件。 use_contextFalse - 不关闭(复现泄漏) use_contextTrue - with 自动关闭(正确) tmp tempfile.gettempdir() fds_before len(os.listdir(/proc/self/fd)) for i in range(n): path os.path.join(tmp, fckpt_{os.getpid()}_{i}.tmp) if use_context: with open(path, w) as f: f.write(x * 1024) else: f open(path, w) f.write(x * 1024) # 忘记 f.close() # 模拟轮换: 删除刚写的文件 try: os.remove(path) except FileNotFoundError: pass gc.collect() fds_after len(os.listdir(/proc/self/fd)) return fds_after - fds_before if __name__ __main__: leak leaky_save(20, use_contextFalse) ok leaky_save(20, use_contextTrue) print(f泄漏写法 - fd 净增 {leak} (已删文件成孤儿 inode)) print(f上下文写法 - fd 净增 {ok} (正确关闭, 无泄漏))运行Linux泄漏写法 - fd 净增 20 (已删文件成孤儿 inode) 上下文写法 - fd 净增 0 (正确关闭, 无泄漏)fd 净增 20正是「每 save 漏 1 个 fd删除后成孤儿 inode」的机理。五、解决方案第一层最小直接修复5.1 用 with 上下文管理器包裹所有文件操作把FastFileWriter内部或你自定义的保存逻辑改成上下文管理def safe_save_checkpoint(path: str, data: bytes): # 关键: with 保证任何路径(含异常)都会 close with open(path, wb) as f: f.write(data) # 文件关闭后, 后续删除不会再产生孤儿 inode5.2 在删除旧 checkpoint 前确保已关闭如果你的保存逻辑持有FastFileWriter实例务必在rmtree之前显式close()writer FastFileWriter(...) try: writer.save(state, path) finally: writer.close() # 关键: 先关, 再删 # 然后才轮换删除 shutil.rmtree(old_ckpt_dir)5.3 临时救急提高 fd / inode 上限 重启线上已泄漏时立刻提高限制并重启进程重启释放所有孤儿 inodeulimit -n 1048576 # 重启训练进程, 孤儿 inode 随进程退出释放这是治标必须配下面两层根治。六、解决方案第二层结构性 / 抽象改进第一层是「加 with」更稳的是封装一个保证关闭的写入器从结构上杜绝裸 open。6.1 封装 AutoCloseWriterimport os from contextlib import contextmanager class FdLeakError(RuntimeError): pass contextmanager def atomic_writer(path: str): 写入临时文件, 关闭后原子 rename。 tmp path .tmp fh open(tmp, wb) try: yield fh finally: fh.close() # 任何异常都关闭 os.replace(tmp, path) # 原子替换, 避免半写文件 # 用法 with atomic_writer(ckpt.bin) as f: f.write(b...) # 退出 with 即关闭, 之后 rmtree 安全6.2 给 FastFileWriter 加 RAII 守卫用__enter__/__exit__让 writer 自身支持上下文caller 漏写 close 也不泄漏class SafeFastFileWriter: def __init__(self, path): self._fh open(path, wb) def write(self, buf): self._fh.write(buf) def close(self): if not self._fh.closed: self._fh.close() def __enter__(self): return self def __exit__(self, *exc): self.close() # 上下文退出必关 return False七、解决方案第三层断言 / CI 守护把「保存后 fd 数不增长」变成可测试不变量。7.1 单测保存 N 次后 fd 数应回到基线import os, tempfile def count_fds() - int: return len(os.listdir(/proc/self/fd)) def test_save_does_not_leak_fd(): before count_fds() for i in range(50): p os.path.join(tempfile.gettempdir(), ft_{os.getpid()}_{i}) with open(p, w) as f: f.write(x) os.remove(p) after count_fds() leaked after - before assert leaked 2, f检测到 fd 泄漏: {leaked} 个 print(f[PASS] 保存 50 次后 fd 净增 {leaked}, 无泄漏) if __name__ __main__: test_save_does_not_leak_fd()7.2 运行时监控 自动告警在训练循环里周期性检查 fd 数异常增长即报警import os, time def watch_fd_leak(threshold5000, every_steps100): base len(os.listdir(/proc/self/fd)) step 0 while True: step 1 yield if step % every_steps 0: cur len(os.listdir(/proc/self/fd)) if cur - base threshold: raise RuntimeError( ffd 泄漏告警: 基线 {base}, 当前 {cur}, f请检查 FastFileWriter 是否未关闭。 )三层叠加直接修with 包裹 删除前 close 临时提上限重启 结构改AutoCloseWriter / RAII writer 守护fd 不泄漏单测 运行时监控泄漏从「几天后崩」变成「提交前拦截」。八、补充如何确认是 fd 泄漏而非真满盘运维排查命令# 1) 看进程打开的 fd 数 ls /proc/pid/fd | wc -l # 2) 看已删除但仍被打开的文件(孤儿 inode) lsof L1 | grep pid # 3) 看 inode 使用 df -i # 4) 看具体哪些文件被泄漏 ls -l /proc/pid/fd | grep deleted若lsof L1列出大量(deleted)且仍被进程持有确认是「删除未关」型泄漏。重启进程即可立即回收但必须修代码才能长治久安。九、排查清单当训练进程报ENOSPC但df -h有空间时df -i看 inode 是否 100%——是则确认 inode 泄漏。lsof L1 | grep pid看是否有大量(deleted)仍被打开——孤儿 inode 证据。ls /proc/pid/fd | wc -l看 fd 数是否异常高。检查 FastFileWriter / 保存逻辑是否用裸open而未close异常路径是否漏关。改为with上下文 / RAII writer确保删除前已 close。删除旧 checkpoint 前先writer.close()避免 unlink 后仍持 fd。写 fd 不泄漏单测CI 拦截。加运行时 fd 监控异常增长即告警。十、小结FastFileWriter leaks one fd per save是一个资源泄漏 bug每次保存 checkpoint 打开文件描述符却未在全部路径关闭配合高频检查点轮换fd 与 inode 持续泄漏已删文件成为孤儿 inode最终df -i耗尽、新写入 ENOSPC——尽管df -h显示逻辑空间充足。修复分三层第一层用with上下文包裹所有文件操作、删除前显式 close、临时提 fd 上限并重启救急第二层封装AutoCloseWriter/ RAII 式SafeFastFileWriter从结构上杜绝裸 open 泄漏第三层写「保存 N 次后 fd 数不增长」单测 运行时 fd 监控告警。记住凡是open()就必须对应close()最稳的做法是永远用with这样检查点轮换再频繁也不会泄漏 fd。