第一次把大模型训练环境搭起来的时候,几乎所有人心态都一样:只要python train.py能打印出第一个 loss,就觉得这件事已经完成了。过两周换一台机器、或者让同事接手、或者自己想复现三个月前的实验结果,才发现当初那套环境已经彻底找不回来了——驱动版本记不清、CUDA 是 11.8 还是 12.1 记混了、某个包是pip install临时装的、多卡能跑是因为当时手工export过一个环境变量。这就是大模型训练环境最典型的困境:跑通一次很容易,可复用很难。
这篇东西想聊的不是"怎么装 PyTorch"这种一句话答案,而是我自己在反复重建训练环境的折腾里,慢慢收敛出来的一套做法:一套从驱动、容器、Python 依赖一直管到目录规范和自检脚本的、能被复制到新机器上、能被同事接手后五分钟内跑起来、能支撑后续几十次微调和预训练实验的 LLM 基础训练环境。它面向的是那些已经能跑单卡 demo、但每次开新实验都要重新折腾一遍环境的同学,也适合团队里负责维护训练基础设施的人。核心目标只有一个:把"我这儿能跑"变成"谁那儿都能跑,而且跑出来是同一个结果"。
1. 可复用到底难在哪:环境漂移的四个源头
在动手之前,得先把问题看清楚。绝大多数"环境坏了"其实不是坏了,而是它本来就没被固定住,只是在某个瞬间恰好凑齐了能跑的条件。我把这些不确定因素归成四类,后面所有的设计都是围绕这四类去堵漏。
1.1 宿主机层面:驱动与内核的隐式依赖
深度学习框架对宿主机的依赖比想象中深。显卡驱动版本决定了你能用的 CUDA Runtime 上限,内核版本会影响 NCCL 的通信行为,甚至/dev/shm的默认大小都能让你在多卡训练时莫名其妙卡死。这些东西在宿主机上是共享的、全局的,你装框架的时候感觉不到它们的存在,但它们一变,整套环境就跟着变。
最要命的是驱动升级。生产环境里经常有人为了跑新卡去apt upgrade一把,结果老卡上的训练全崩了。这种情况在单机上还能救,在多机集群上就是灾难——你根本不知道哪台机器的驱动被谁动过。
1.2 Python 层面:依赖版本的地狱三角
大模型训练的 Python 侧依赖有一个很明显的三角关系:torch版本、transformers生态版本、以及各种带 CUDA 扩展的编译型包(比如 flash-attention 这类注意力加速库)。这三个东西的版本是互相咬合的,随便动一个都可能触发连锁反应。
更隐蔽的是隐式依赖。pip install的时候如果不写约束,pip 会去解析一个它自认为最优的版本组合,这个组合可能今天和明天都不一样。你昨天装出来的环境和今天装出来的环境,表面上pip list输出一样,实际底层依赖的传递版本可能已经不同了。所以复现性问题的根源往往不在你显式写的那几行依赖上,而在你没写的那些。
1.3 数据与配置层面:路径写死了就搬不动
我见过太多训练脚本里写死了data_path = '/home/xxx/data/train.jsonl'。这种环境在原作者机器上完美运行,换个人就报错。配置文件、缓存目录、checkpoint 保存路径、日志目录,这些如果没有统一的约定,所谓"可复用"就只是句口号。
这一层最容易被忽视,因为它不报错就不会有人管。但只要你想把实验从一台机器迁到另一台,问题立刻暴露。
1.4 人的层面:口口相传的"祖传操作"
最后一类最难治:环境搭建过程中那些没被记录下来的手工操作。比如"要先export NCCL_P2P_DISABLE=1不然会卡"、"装 flash-attn 之前得先装某个版本的 ninja"、这些知识通常存在于某个人的记忆或者聊天记录里,没进任何文档。人一走,环境就死了。
判断一套环境是否真的可复用,有一个很粗暴的测试:找一台全新的机器,只给它一份文档和一个仓库地址,看对方能不能在半天内跑起同样的结果。如果做不到,那它就不是可复用环境,只是恰好能跑的环境。
2. 底座先钉死:驱动、系统与资源的统一口径
可复用的第一步是停止让宿主机"自由生长"。我的做法是把宿主机上一切可变的东西收敛到最小,剩下的全部下沉到容器或其他隔离层里。
2.1 驱动版本:只升不降,且必须统一
驱动是整个技术栈里唯一一个我建议手工管理、禁止自动更新的东西。具体做法是:
- 关掉系统的自动更新(
unattended-upgrades这类服务直接禁用),驱动只允许人工按批次升级; - 在一个集群里,所有机器的驱动大版本必须一致,比如统一固定在某个支持 CUDA 12.x 的版本上;
- 升级前先在测试机上完整跑一遍自检脚本(后面第 6 节会给),确认多卡通信和混合精度都正常,再灰度到整个集群。
驱动和 CUDA 的关系经常被搞混,这里澄清一下:宿主机上的驱动只需要满足"支持到某个 CUDA 大版本"就够了,真正的 CUDA Runtime 是跟着框架走的,PyTorch 的 wheel 里就自带一份 Runtime。所以你在nvidia-smi里看到的CUDA Version: 12.x是驱动的能力上限,不是当前实际使用的版本。
2.2 系统与内核:选 LTS,别追新
操作系统我一般选长期支持版本,内核也尽量用发行版自带的稳定内核。原因很实际:NCCL、RDMA 这些通信相关的东西对内核版本有要求,新内核有时候反而会让多机通信出问题。追新在这里没有任何收益。
系统层面还有几个必须调的资源项,这些在默认配置下几乎一定会出问题:
| 资源项 | 默认值的问题 | 建议做法 |
|---|---|---|
/dev/shm | 默认通常偏小,多卡 DataLoader 用共享内存时容易卡死或报 bus error | 容器启动时挂载足够大的共享内存,或训练时把共享内存策略调小 |
| 文件描述符上限 | 数据加载并发一高就报 too many open files | 提升nofile软硬限制 |
| 内存锁定上限 | 影响部分通信库的行为 | 按需提升memlock |
| 交换分区 | 训练时被换出会拖慢甚至假死 | 训练节点建议关闭 swap 或大幅降低 swap 权重 |
这几项看着琐碎,但每一顶都对应过一次真实的翻车。
2.3 硬件差异如何抽象掉
不同型号的卡显存、算力、互联能力差别很大。我的处理方式是按卡型定义环境档位,而不是让训练代码去感知硬件。常见的一种分档:
- 小显存卡(24GB 级别):以 LoRA 类微调、短序列为主,开启梯度检查点和梯度累积;
- 中大显存卡(40GB~80GB 级别):全参数微调、预训练小模型、多卡并行;
- 集群卡:多机多卡、更大并行策略。
分档的意义在于:环境配置里哪些显存优化开关默认打开,是跟着档位走的。你换了卡,只改档位,不改代码。
3. 隔离层选型:容器打底、conda 做实验层
隔离方案是分水岭。用 pyenv + venv、用 conda、还是用容器,决定了后面一年你是轻松还是痛苦。我把三者的实际体验列一下。
| 方案 | 隔离彻底度 | 复现难度 | 上手成本 | 适合场景 |
|---|---|---|---|---|
| venv | 只隔离 Python 包 | 中 | 低 | 单人单机、纯 Python 依赖 |
| conda | 隔离 Python 和部分系统库 | 中 | 中 | 需要切换不同 CUDA Runtime 的场景 |
| 容器 | 隔离整个用户空间 | 低(镜像即环境) | 较高 | 团队协作、多机部署、长期维护 |
3.1 我的组合:分层镜像 + 底层不装实验包
单用容器的问题是每次想试个新库就得重建镜像,太笨重;单用 conda 的问题是它管不住系统库,换机器还是会漂。所以我用的是两层结构:
第一层是基础镜像,只装稳定不变的东西:CUDA Runtime、系统编译工具、常用命令行工具、Python 解释器本体、以及 pip/conda 本身。这一层可能几个月才动一次。
第二层是实验环境,跑起来之后在容器里用 conda 或 venv 建一个环境,装 torch、transformers 这些会频繁变动的包。这一层每个项目一份,坏了直接删掉重建,不影响别人。
这么做的好处是:基础镜像保证了下限(所有人的底座一致),实验环境保证了灵活性(你想试什么版本都行)。重建实验环境的成本被压到了分钟级。
3.2 基础镜像的关键细节
写基础镜像有几个地方必须注意,否则做出来的镜像要么大得离谱,要么跑不了多卡。
第一,把和宿主机驱动强相关的运行时装成"前向兼容"模式,让容器里的运行时版本不完全绑死宿主驱动版本。这样镜像能跨几代机器用。
第二,不要装图形化依赖和无关的编译产物,镜像体积会直接影响多机分发速度。
第三,把编译型依赖需要的工具链一次性装好,尤其是做扩展编译时会用到的构建工具和并行编译工具。很多人 flash-attn 装不上,根因就是镜像里缺编译环境。
第四,容器运行时要把显卡设备暴露进去,并且一定要挂足够的共享内存,这是多卡 DataLoader 稳定的前提。
3.3 为什么不在系统 Python 里直接装
一句话:系统 Python 是给操作系统用的,不是给你用的。在里面装 torch,一是版本冲突没法解,二是升级系统的时候容易把你环境搞坏,三是没法给不同项目准备不同版本。这个坑我早年踩过不止一次,后来强制自己任何机器上都不往系统 Python 里装东西。
4. 依赖锁定:把"随手装"变成"可回滚的版本矩阵"
镜像解决了底座,剩下的最大变量就是 Python 依赖。这一节是我认为整个环境里最值得花时间的地方。
4.1 三方版本的咬合关系
大模型训练环境里,版本关系大概是这样一条链:框架版本(torch)决定了它能兼容的 CUDA Runtime 和 Python 版本范围;模型库版本(transformers 那一类)决定了它支持的框架版本下限;编译型加速库的版本又强绑定框架和 CUDA 的版本。任何一个环节你选错,最后表现出来的都是"能 import 但一跑就崩",非常难查。
我一般这样定版本顺序:先定卡型档位 → 定 CUDA Runtime → 定框架版本 → 定模型库版本 → 最后定加速库版本。这个顺序不能反,因为它是按约束强度排的。
4.2 三层锁定文件
依赖管理我只用三样东西,效果比任何花哨工具都稳:
requirements.txt:只写直接依赖,不带版本号或者只写主版本,表达"我要什么"。constraints.txt:把每个包精确固定到小版本号,表达"锁死到这个组合"。这是复现的关键。env.lock:由环境的导出命令生成,记录完整依赖树的哈希或版本快照,用于审计。
安装命令统一成一行,谁执行都一样:
pip install -r requirements.txt -c constraints.txt --require-hashes-c保证约束生效,哈希校验保证包内容没被替换。团队协作时这一行的价值极高——它把"装对版本"从个人经验变成了可执行规范。
4.3 编译型依赖的缓存
各类带 CUDA 扩展的加速库有个共性:编译一次要十几分钟甚至更久。如果每次重建环境都重编译,没人受得了。我的做法是把编译好的产物按"卡型 + CUDA 版本 + 框架版本"打成内部包缓存起来,新机器直接装缓存包。
判断是否命中缓存的键就是那三个版本号。这三者一致,编译产物就能复用,这也是为什么前面强调版本要先定死——版本不定死,缓存就用不起来。
每次升级框架版本时,不要顺手升别的包。一次只动一个变量,跑完自检再做下一个。并行升级依赖,出了问题你根本不知道该往哪查。
5. 目录规范与配置管理:让下一次实验"零思考"
环境能重建只是第一步。真正的可复用是"下一个实验启动时,我不需要思考文件放哪、配置怎么写"。
5.1 一套目录约定
我的习惯是把训练相关的目录全部挂载到容器里的固定路径,代码内部只引用相对路径或环境变量,绝不出现绝对路径:
/workspace/ ├── code/ # 训练代码,git 管理 ├── configs/ # 配置文件 ├── data/ # 只读数据集 ├── ckpt/ # 模型检查点 ├── logs/ # 训练日志与 tensorboard ├── cache/ # 模型与数据集缓存 └── scripts/ # 启动与自检脚本这套结构的好处是,换机器时只需要把对应的宿主机目录挂载到这些路径上,代码一行不用改。数据集那个目录建议挂成只读,防止训练脚本误写。
5.2 配置分层:默认值、档位、实验
配置文件我分三层合并:
- 默认配置:所有参数的合理默认值,进版本控制。
- 档位配置:按卡型区分的覆盖项,比如 batch size、精度模式、并行策略开关。
- 实验配置:只写这次实验跟默认不同的部分。
启动时按顺序合并。这样一份实验配置可能只有十几行,却完整描述了一次训练。三个月后回头看,你能立刻知道当时改了什么。
5.3 环境变量统一收口
多卡训练需要的一堆环境变量(通信相关的各种超时、调试开关、显存分配策略)绝对不要靠手工 export,全部写进启动脚本或者配置里。我的标准是所有环境变量必须在一个地方定义,容器启动时统一注入。手敲的环境变量是环境漂移最主要的来源之一。
6. 自检脚本:把"能跑"变成"可验证"
这是我强烈建议每个团队都做的一件事:写一个自检脚本,环境搭好第一件事就是跑它。
6.1 自检应该覆盖什么
自检不是跑个import torch就完事。我的清单是这样几项,按依赖顺序排:
- 驱动与运行时可见性:能不能看到所有卡、卡的数量对不对;
- 单卡计算:在卡上做一次矩阵乘法并校验结果,确认计算路径通;
- 精度路径:混合精度前向反向跑一遍,确认没有不支持的操作;
- 多卡通信:所有卡做一次集合通信,校验结果一致,确认互联正常;
- 存储路径:往 checkpoint 目录写一个文件再读回来,确认挂载可写、权限正确;
- 数据加载:用真实的小样本数据跑一遍 DataLoader,确认共享内存和文件句柄够用。
6.2 一个可落地的自检思路
用 Python 写一个脚本,把上面几项串起来,每项输出通过或失败,最后给一个总结。关键点在于多卡那一步必须用真实的分布式启动方式跑,而不是单进程模拟,否则测不出通信问题。
# 多卡通信自检,按实际卡数调整 torchrun --nproc_per_node=$NUM_GPUS scripts/selfcheck.py脚本里打印每一步耗时的意义也很大:如果某一步比历史基线慢很多,说明底层有问题,虽然还能跑但不该继续用。
6.3 把自检变成流程的一部分
自检只有在被强制执行时才有价值。我把它挂在了两个位置:一是环境重建之后必须跑一遍才能标记为可用;二是每次正式训练启动前,主脚本会先调用一次轻量版自检,失败就直接退出。这样避免了"跑了三小时才发现多卡根本没生效"这种惨剧。
7. 我踩过的坑:从现象倒推根因的排查链路
环境搭建的知识里,最值钱的部分其实是踩坑记录。这一节我按"现象 → 排查 → 根因 → 处理"的方式写,方便你遇到类似情况时对照。
7.1 多卡训练启动后长时间无输出,然后超时
现象是分布式启动之后,日志卡在某一步不动,几分钟后抛通信超时。这种问题的排查顺序是:
第一步,确认卡可见性和数量对不对,机器上有没有别的进程占了卡。第二步,手工在两个进程间做一次集合通信看看通不通。第三步,检查机器之间的网络路径,包括网卡是否被正确识别、通信端口是否被防火墙挡住。第四步,看是不是互联拓扑被错误裁剪。
我遇到过的根因里,占比最高的是通信接口选错——机器有多张网卡,默认选到了一个不通的接口上。处理方法是在启动脚本里显式指定通信使用的网络接口,别依赖自动选择。
7.2 显存报 OOM,但监控显示还有很多空闲
这种情况通常不是真的显存不够,而是碎片化。训练过程中不断申请释放不同大小的显存块,时间长了就拼不出连续的大块。处理办法有两个:一是调整显存分配策略,让分配器更激进地复用;二是从根上减少动态形状,把序列长度分桶,减少变长输入。
顺带说一句,序列长度不统一带来的这种问题在大模型训练里非常常见,分桶(把长度接近的样本放到一个 batch)能同时改善显存和吞吐。
7.3 编译型加速库装不上
报错五花八门,但根因基本集中在三个地方:编译工具链缺失或版本不对、框架版本和加速库版本不匹配、编译时并行度太高把内存打爆。处理顺序是先确认框架版本,再补齐工具链,最后如果编译还是崩,就把并行编译的任务数调低。
这也是我把编译产物做缓存的原因——只要成功编译过一次,后面就不需要在每台机器上重复受这个罪。
7.4 加了卡但速度不涨反降
这是最反直觉的一类。加卡之后吞吐没提升甚至下降,通常意味着数据加载成了瓶颈,GPU 在等数据。排查方法是看 GPU 利用率的曲线,如果呈现规律性的掉到零,基本就是输入管道跟不上。
解决办法按性价比排序:提高数据加载的并行进程数、把数据预处理做成离线成品、使用更高效的样本读取格式、开启预取。
7.5 驱动被升级之后环境崩溃
这个没什么技术含量,纯粹是管理问题。处理方式是所有训练节点禁用自动更新,驱动升级走变更流程。同时保留一份"已知可用组合"的清单,出问题可以快速回退。
| 现象 | 最可能的根因 | 优先动作 |
|---|---|---|
| 多卡启动卡住后超时 | 通信接口选错 / 网络不通 | 显式指定网络接口 |
| OOM 但显存空闲 | 显存碎片 | 调整显存分配策略 + 长度分桶 |
| 加速库编译失败 | 工具链缺失 / 版本不匹配 | 对齐框架版本,降低编译并行度 |
| 加卡不提速 | 数据加载瓶颈 | 提升加载并行度,离线预处理 |
| 驱动升级后崩溃 | 宿主机变更 | 禁用自动更新,走变更流程 |
8. 从单机到集群:让同一套环境继续扩展
单机上搭好的环境,如果设计得当,扩展到多机几乎不需要改代码,只需要补三件事。
8.1 镜像分发
多机场景下,最省事的做法是把基础镜像推到内网镜像仓库,每台机器拉同一份。我一般会给镜像打两套标签:一个是内容哈希(保证精确对应),一个是语义化版本(方便人看)。部署时用哈希标签,绝对不要用latest。
8.2 网络与通信参数
多机环境下有几个参数必须显式配置,不能依赖默认值:通信使用的网络接口、通信后端的超时时间、以及用于通信的缓冲区大小。这些参数跟集群规模强相关,规模一变就要重新调。
我建议把这三个参数放进档位配置里,按集群规模分档,而不是让每个实验自己填。这样新人开实验的时候根本不需要知道这些。
8.3 启动脚本的模板化
多机启动命令比单机长得多,手工敲必错。我的做法是写一个启动模板,把机器列表、每台机器进程数、通信接口这些做成参数,实验配置只提供训练本身的参数。模板进版本控制,所有人共用一份,改一次全员受益。
# 多机启动模板示意,机器列表与进程数由外部传入 torchrun \ --nnodes=$NODES \ --node_rank=$RANK \ --nproc_per_node=$GPUS_PER_NODE \ --master_addr=$MASTER_ADDR \ --master_port=$MASTER_PORT \ train.py --config configs/exp.yaml模板化的真正价值在于:它把"环境知识"变成了"文件"。文件可以被审阅、被 diff、被新人读懂,而记忆不行。
最后再分享一个我自己一直在用的小习惯:每次环境变更(升框架、换驱动、改镜像)之后,我会把当年的"已知可用组合"记一行到一个清单里,格式就是卡型、驱动、框架、模型库、加速库这五个字段。这个清单在出问题的时候比任何文档都快——直接对照最近一次变更,基本就能定位。环境这件事没有一劳永逸,只有把变量控制得越来越少,把记录做得越来越细,你才能在两个月后打开这台机器时,依然知道它现在是什么状态。