1. 从一台闲置GPU服务器说起:为什么我们要自建算力池
实验室角落里那台装了四张显卡的服务器,大半时间都在吃灰。偶尔有人跑个训练任务,也是SSH上去手动nvidia-smi看一眼哪张卡空着,然后CUDA_VISIBLE_DEVICES=2 python train.py一把梭。这种"人肉调度"的模式在只有三五个人的时候还能凑合,一旦团队扩到十几个人,问题就全冒出来了:有人占着卡跑小demo,有人排了一下午队结果发现卡被占了,还有人训练到一半OOM把别人的进程一起带崩。
这个项目的出发点很朴素——把散落在各处的GPU算力聚成一个共享资源池,让做科研协作的团队成员能像用内部WiFi一样,按需申请、按量使用、用完释放。核心不是造一个多先进的调度系统,而是用尽量低的运维成本,把TensorFlow训练任务的算力分配这件事从"靠喊"变成"靠系统"。
适合谁来参考这篇内容?如果你所在的课题组、实验室或小团队有几张到几十张GPU,成员以跑TensorFlow实验为主,没有专职运维,又不想一上来就上Kubernetes那套重家伙,那这套思路基本可以直接抄。我会把选型逻辑、部署细节、踩过的坑和后期维护经验都摊开讲,重点放在"为什么这么选"而不是"照着敲命令"。
需要先说明一点:下面涉及的所有主机名、IP、路径、团队规模都是虚构代称,实际部署时按你自己的环境替换即可。整套方案的核心组件都是开源且社区活跃的,不存在什么独门秘方,关键在于组合方式和参数取舍。
2. 调度层选型:为什么最终没上K8s而选了Slurm
2.1 三种主流路线的真实对比
自建算力池,调度层是第一个要拍板的事。市面上常见的有三条路:Kubernetes + GPU Operator、Slurm、以及各种轻量级自研脚本。我前后试过前两种,最后落在Slurm上,原因不是它"最先进",而是它"最匹配"。
| 维度 | Kubernetes方案 | Slurm方案 | 自研脚本方案 |
|---|---|---|---|
| 上手成本 | 高,需要懂容器编排 | 中,配置文件为主 | 低,但后期维护爆炸 |
| GPU隔离 | 依赖device plugin | 原生支持GRES | 靠环境变量,易冲突 |
| 多用户配额 | 需额外组件 | 内置QOS/账户体系 | 基本没有 |
| 故障恢复 | 强,但排查链路长 | 够用,日志直观 | 全靠人 |
| 适合规模 | 50卡以上且有专人 | 5到100卡 | 5卡以下 |
K8s的问题不在于不好,而在于"杀鸡用牛刀"。我们团队没有容器化传统,成员提交的是python train.py这种裸脚本,硬塞进Pod里反而增加了调试成本——出问题时你得先分清是代码问题、镜像问题还是调度问题。Slurm则天然贴合HPC场景,sbatch提交、squeue查看、scancel取消,这套交互对做科研的人几乎没有学习门槛。
2.2 Slurm的GRES机制到底解决了什么
Slurm管理GPU靠的是GRES(Generic Resource Scheduling),直译就是通用资源调度。它的工作方式很直接:在每个计算节点上声明"我有几张什么型号的卡",调度器据此分配,任务启动时通过CUDA_VISIBLE_DEVICES把分到的卡暴露给进程。
这里有个容易被忽略的细节:GRES的配置必须和节点实际的GPU拓扑一致。我踩过一次坑,节点上插了四张卡但gres.conf里只写了Name=gpu Count=2,结果调度器以为只有两张卡,另外两张永远分不出去,白白浪费。排查时slurmd -C能看到真实硬件,但配置文件的错误它不会主动报,只能靠sinfo -o "%N %G"对比。
配置的核心就两行,写在gres.conf里:
# /etc/slurm/gres.conf NodeName=node01 Name=gpu Type=rtx4090 File=/dev/nvidia[0-3] NodeName=node02 Name=gpu Type=a100 File=/dev/nvidia[0-1]Type字段很有用,它让调度器能区分不同型号的卡。做科研时经常有"这个实验必须用A100的大显存"的需求,有了Type,提交任务时就能写--gres=gpu:a100:1精确指定,避免被分到不合适的卡上。
2.3 账户与QOS:让资源分配有据可依
多人共享最怕的就是"公地悲剧"。Slurm的**账户(Account)和QOS(Quality of Service)**体系正好治这个病。我的做法是按课题组或项目建Account,每个Account下挂成员用户,再给不同Account配不同的QOS限制。
# 创建一个账户 sacctmgr add account lab_vision Description="视觉组" Organization=lab # 给账户设QOS限制:最多同时用8张卡,单任务最长72小时 sacctmgr add qos vision_qos GrpTRES=gres/gpu=8 MaxWall=72:00:00 # 把用户挂到账户下 sacctmgr add user alice Account=lab_vision这样配下来,视觉组再多人同时提交,总量也不会超过8张卡,不会把整个池子吃干。MaxWall限制单任务时长,防止有人挂个死循环占着卡不放。实测下来,这套配额机制比任何口头约定都管用,因为它是系统强制的,不靠自觉。
提示:QOS的
GrpTRES是"组总量"限制,不是单用户限制。如果想让每个成员也有上限,需要再配一层MaxTRESPerUser,两者叠加才完整。
3. TensorFlow任务在Slurm上的提交姿势
3.1 从裸命令到sbatch脚本的改造
成员原来习惯直接敲python train.py,上了Slurm之后要改成sbatch提交。这个转变一开始有阻力,但用顺了之后大家都说好——因为任务不再依赖终端会话,关掉电脑训练照样跑。
一个典型的TensorFlow训练脚本模板长这样:
#!/bin/bash #SBATCH --job-name=resnet_train #SBATCH --partition=gpu #SBATCH --gres=gpu:1 #SBATCH --cpus-per-task=8 #SBATCH --mem=32G #SBATCH --time=24:00:00 #SBATCH --output=logs/%j.out #SBATCH --error=logs/%j.err # 加载环境 module load cuda/12.1 source /opt/conda/etc/profile.d/conda.sh conda activate tf_env # 关键:让TensorFlow只看到分到的卡 export CUDA_VISIBLE_DEVICES=$CUDA_VISIBLE_DEVICES # 防止TF吃满所有显存 export TF_FORCE_GPU_ALLOW_GROWTH=true python train.py --epochs 100 --batch_size 64这里有几个细节值得展开。--gres=gpu:1是向调度器申请一张卡,Slurm分配后会自动设置CUDA_VISIBLE_DEVICES,所以脚本里不需要手动指定卡号,这也是共享池比人肉调度优雅的地方——你永远不用关心"哪张卡空着"。
TF_FORCE_GPU_ALLOW_GROWTH=true这行是我强烈建议加的。TensorFlow默认会一次性占满整张卡的显存,哪怕你的模型只需要2G。在共享环境里这是灾难,一张24G的卡被一个2G的任务占满,别人就没法用了。开启allow growth后,显存按需增长,一张卡上并行跑两三个小任务成为可能。
3.2 多卡训练的资源申请方式
做科研难免要跑多卡训练。Slurm下有两种模式:单节点多卡和多节点多卡。单节点最简单,--gres=gpu:4一次申请四张,TensorFlow的MirroredStrategy会自动发现所有可见的卡。
import tensorflow as tf strategy = tf.distribute.MirroredStrategy() print(f"检测到 {strategy.num_replicas_in_sync} 个GPU设备") with strategy.scope(): model = build_model() model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')多节点就复杂一些,需要配--nodes=2 --ntasks-per-node=1 --gres=gpu:4,然后用tf.distribute.MultiWorkerMirroredStrategy配合环境变量TF_CONFIG。这块坑比较多,我建议除非模型真的需要跨节点,否则优先在单节点内堆卡,通信开销小得多。
3.3 交互式调试:别让调试也排队
有个现实问题:写代码阶段需要反复试错,如果每次调试都走sbatch排队,效率极低。Slurm提供了srun和salloc做交互式申请,我的经验是给调试单独开一个小配额。
# 申请一个交互式会话,1张卡,2小时 srun --partition=gpu --gres=gpu:1 --time=2:00:00 --pty bash进去之后就是一个带GPU的shell,可以随便跑python、看显存、调代码,退出即释放。为了防止有人拿交互式会话当免费算力长期占用,我在QOS里给交互式任务单独设了更短的MaxWall,比如4小时,到点自动踢。
注意:交互式会话里跑的训练任务,会话一断进程就没了。真正要跑的训练还是得走
sbatch,srun只用来调试。
4. 存储与环境的共享设计
4.1 共享存储:算力池的隐形地基
算力池搭好之后,第二个瓶颈往往不是GPU而是存储。如果每个节点都是本地盘,那用户的数据就得手动同步,训练脚本里写死本地路径,换节点就崩。解决办法是挂一个共享文件系统,让所有节点看到同一份数据。
我们用的是NFS,虽然性能不是最强,但胜在简单稳定、零学习成本。关键是把几类数据分开挂载:
| 挂载点 | 用途 | 特点 |
|---|---|---|
| /shared/datasets | 公共数据集 | 只读为主,容量大 |
| /shared/home | 用户家目录 | 每人独立配额 |
| /shared/scratch | 临时训练数据 | 高速,定期清理 |
/shared/scratch这个设计特别重要。训练过程中会产生大量checkpoint和中间文件,如果全写到家目录,很快就把配额撑爆。scratch盘给足空间但设定期清理策略,比如30天没访问的文件自动删,逼着大家把重要结果及时归档。
NFS的性能调优有个参数值得调:rsize和wsize。默认值偏保守,改成rsize=1048576,wsize=1048576之后,大文件读取速度能提升明显。这个改动在/etc/fstab里加就行:
# /etc/fstab 中的NFS挂载项 storage:/shared /shared nfs defaults,rsize=1048576,wsize=1048576,hard,intr 0 04.2 环境隔离:Conda还是容器
TensorFlow的版本兼容性是出了名的麻烦,不同项目要不同版本的TF、CUDA、cuDNN。共享环境里如果大家共用一个Python,迟早打起来。我的方案是Conda为主,容器为辅。
Conda负责日常的Python环境隔离,每个用户自己建env,互不干扰。节点上装好统一的CUDA驱动(驱动是全局的,不能隔离),但CUDA Toolkit和cuDNN通过Conda装到各自环境里。这样用户A用TF 2.10 + CUDA 11.2,用户B用TF 2.15 + CUDA 12.1,都能在同一张卡上跑。
# 用户自己创建环境 conda create -n tf215 python=3.11 conda activate tf215 pip install tensorflow[and-cuda]==2.15容器则用在需要完全复现的场景,比如论文投稿前要固定整个软件栈。这时候用Singularity(现在叫Apptainer)比Docker更合适,因为它在HPC环境里不需要root权限,能直接读宿主机的GPU驱动。
# 用Singularity跑一个容器化的训练 singularity exec --nv tf215.sif python train.py--nv这个flag是关键,它把宿主机的GPU驱动透传进容器,容器里就能直接用卡。这套组合下来,日常用Conda图方便,要复现用容器图严谨,两不耽误。
4.3 数据集缓存:别让IO拖了后腿
做视觉或NLP的,数据集动辄几十上百G,每次训练都从NFS读,IO很容易成为瓶颈。我的做法是在每个计算节点的本地SSD上做一层缓存,训练前把数据同步到本地,训练时读本地盘。
# 训练脚本开头加一段数据同步 if [ ! -d /local_cache/imagenet ]; then rsync -a /shared/datasets/imagenet/ /local_cache/imagenet/ fi这段逻辑可以封装成一个函数,用户脚本里调一下就行。实测下来,ImageNet这种规模的数据集,从NFS读和从本地SSD读,训练速度能差出20%到30%,尤其是小文件多的时候差距更明显。
5. 监控、告警与日常运维
5.1 用Prometheus + Grafana盯住GPU
算力池上线只是开始,日常运维才是长期活。最基础的监控是GPU利用率、显存占用、温度、功耗这几个指标。NVIDIA提供了dcgm-exporter,能把GPU指标暴露成Prometheus格式,再用Grafana画图。
# 启动dcgm-exporter dcgm-exporter --collect-interval 5000配好之后,Grafana上能实时看到每张卡的利用率曲线。这个面板的价值在于:一眼就能发现"哪张卡被占着但利用率长期为0"——这通常意味着有人申请了卡却没真正在跑,或者任务卡死了。发现这种情况就可以主动联系用户确认,及时释放资源。
5.2 告警规则:只报真正需要人管的
监控面板看多了会麻木,真正有用的是告警。我配了几条规则,只报那些"必须人工介入"的情况:
- GPU温度持续超过85度:可能是散热出问题,需要检查机房空调
- 节点失联超过5分钟:可能是宕机,需要重启
- 磁盘使用率超过90%:需要清理或扩容
- 某用户连续7天占用GPU但利用率低于5%:可能是僵尸任务
告警走内部消息通道,不搞花里胡哨的。关键是阈值要调准,报得太频繁大家就无视了,报得太少又漏掉真问题。我调了两周才把阈值调到合适。
5.3 定期巡检清单
运维这事,靠人记不如靠清单。我整理了一份每周巡检的checklist:
| 检查项 | 命令 | 关注点 |
|---|---|---|
| 节点状态 | sinfo | 有无down节点 |
| 队列积压 | squeue | 有无长期排队任务 |
| 磁盘空间 | df -h | 各挂载点余量 |
| GPU健康 | nvidia-smi -q | ECC错误、温度 |
| 僵尸任务 | sacct | 超长运行任务 |
这份清单执行下来大概十分钟,但能提前发现大部分隐患。比如ECC错误,单比特错误可以纠正,双比特错误就得换卡了,早发现早处理。
6. 那些让我熬夜的坑与最终解法
6.1 CUDA_VISIBLE_DEVICES被覆盖的诡异问题
有段时间用户反馈:明明申请了1张卡,TensorFlow却看到了全部4张。排查了半天,发现是用户脚本里自己写了export CUDA_VISIBLE_DEVICES=0,1,2,3,把Slurm设置的值覆盖了。Slurm分配卡是通过设置这个环境变量实现的,用户一旦手动覆盖,隔离就失效了。
解法有两个层面:一是在文档里明确写"不要手动设置CUDA_VISIBLE_DEVICES",二是在sbatch模板里加一行unset CUDA_VISIBLE_DEVICES再重新从Slurm的环境变量里取。更彻底的做法是用cgroup限制设备访问,但那配置复杂,权衡下来还是靠约定加模板约束。
6.2 显存碎片导致的"假性OOM"
另一个坑是显存碎片。TensorFlow的显存分配器在长时间运行后会产生碎片,明明nvidia-smi显示还有几个G空闲,但新任务就是分配不出来,报OOM。这个问题在共享环境里特别烦,因为一张卡上可能先后跑过好几个任务。
缓解办法是开启TF_GPU_ALLOCATOR=cuda_malloc_async,用异步分配器减少碎片。另外在QOS里限制单卡同时运行的任务数,别让一张卡上挤太多进程。实测下来,一张24G的卡同时跑不超过2个任务比较稳妥。
6.3 节点时钟不同步引发的认证失败
这个坑最隐蔽。有次新加了一个节点,用户提交任务总是失败,报认证错误。查了半天发现是新节点的系统时钟和登录节点差了十几分钟,导致认证token过期。NTP服务没配好。
# 确保所有节点时间同步 timedatectl set-ntp true chronyc sourcesHPC集群里时间同步是基础设施,但很容易被忽略。新节点上线前一定要检查NTP状态,否则会出现各种莫名其妙的认证、调度问题。
6.4 用户误删共享数据的恢复
共享存储最大的风险是误删。有人一个rm -rf把公共数据集删了,全组遭殃。NFS本身没有回收站,删了就没了。我的补救方案是给关键目录做定期快照,用rsync每天凌晨同步一份到备份盘。
# 每日备份脚本 rsync -a --delete /shared/datasets/ /backup/datasets_$(date +%Y%m%d)/保留最近7天的快照,误删了还能找回。这个成本不高,但关键时刻能救命。另外权限上做文章,公共数据集目录设成只读,只有管理员能写,从源头减少误操作。
7. 算力池跑顺之后的一些体会
整套系统跑了大半年,从最初的四张卡扩到现在的二十多张,成员从五个人到二十多人,中间经历了不少调整。回头看,最值得分享的经验不是某个技术细节,而是几个判断。
第一,调度系统的复杂度要和团队规模匹配。五个人用Slurm是杀鸡用牛刀,五十个人用脚本是自找麻烦。我们卡在中间,Slurm刚好。如果哪天扩到上百张卡,可能真得上K8s,但那是以后的事,现在不提前背这个包袱。
第二,配额机制比技术优化更能提升整体效率。GPU利用率上不去,很多时候不是技术问题而是管理问题。有了QOS限制,大家自然会珍惜申请到的资源,不会随便占着卡不用。这比任何调度算法优化都立竿见影。
第三,文档和模板的价值被严重低估。我花了两天写的sbatch模板和常见问题文档,省下了后面无数次的重复答疑。新人上手从"配环境要一天"变成"半小时跑通第一个任务",这个投入产出比极高。
最后分享一个小心得:给算力池起个内部名字,做个简单的使用排行榜(谁跑的任务多、谁的利用率高),在组会上偶尔提一嘴。这种轻量的正向激励,比硬性规定更能让大家自觉维护共享环境。技术系统终究是给人用的,把人的因素考虑进去,系统才能真正跑顺。