拓冰建站拓冰建站
首页 / 资讯中心 / 正文

科研计算防坑指南:环境、数值、OOM与集群作业排查实战

老吴做科研计算这几年最常被同门问的一句话是你代码都写对了怎么还不出结果每次听到这种问题我都想先把最近的踩坑记录拍在桌上再回答。科研计算这件事写代码本身最多占一半时间另一半时间基本都在跟环境、数值、资源、集群作业这些“不直接产生成果”的东西搏斗。更麻烦的是这类坑往往隐蔽报错信息模棱两可甚至完全不报错只是结果悄悄变差。这篇文章就把老吴踩过最多、代价最惨的几个坑集中梳理一遍从环境配置到数值陷阱从资源崩溃到集群作业每条都附上当时的排查链路和现在的固定动作希望能帮正在被类似问题折磨的同行省点时间至少少走几次弯路。1. 环境配置的“地狱级”排错现场依赖、版本与随机性1.1 libcudnn.so.8 not found 背后的版本三角纠纷先说一个几乎所有用过深度学习框架的人都绕不开的经典场景。某次复现一篇论文老吴按README说明创建了一个conda环境conda create -n paper python3.8 conda activate paper pip install torch torchvision然后满怀信心地运行训练脚本结果第一条报错就让人血压升高libcudnn.so.8: cannot open shared object file。第一反应是缺cudnn于是用conda装了一个conda install cudnn8.9.0装完再跑还是报同样的错。到这里很多人会陷入“反复卸载重装”的循环但这其实是环境管理的经典陷阱。再往下排查用ldd看torch运行时实际依赖的cudnn库位置ldd $(python -c import torch; print(torch.__file__)) | grep cudnn发现在torch的lib目录里确实需要libcudnn.so.8但conda list | grep cudnn显示包是装上了的。再用find去conda环境目录里搜索才发现环境里根本没有生成libcudnn.so.8这个符号链接只有libcudnn_ops_infer.so.8、libcudnn_adv.so.8这类拆分子库。加上系统/usr/lib/x86_64-linux-gnu下原来就有一个老版本的cudnn被conda环境的LD_LIBRARY_PATH优先遮蔽结果是运行时loader找到的库和torch编译时用的库完全对不上。这个坑的本质是“编译期依赖”和“运行期依赖”不一致。类似的情况在CUDA工具链上更明显——PyTorch的预编译轮子按特定CUDA版本编译运行时却不管你的LD_LIBRARY_PATH指向哪里。那种用apt install nvidia-cuda-toolkit就把CUDA装上、然后跑深度学习框架的做法在老吴这里至少踩坏过两个环境。目前的固定做法是用conda创建环境时直接让conda的cudatoolkit和cudnn版本与框架要求匹配conda create -n paper python3.8 conda activate paper conda install pytorch torchvision pytorch-cuda11.8 cudnn8.9 -c pytorch -c nvidia装完之后立刻做一个环境健康检查python -c import torch; print(torch.__version__, torch.version.cuda, torch.backends.cudnn.version())只有这几样输出和官方文档对应才继续往下走。类似的坑在手动编译C扩展时也很常见系统里的gcc版本太新或太旧编译出来的so文件在目标机器上跑不起来。科研计算里环境不一致是复现失败的第一个嫌疑犯没有之一。1.2 换台机器结果就变复现实验要锁定的不止是代码老吴有一次帮师兄复现一个分子模拟的分析结果。师兄的原话是“这代码肯定能跑我在我机器上出图了”。结果在实验室服务器上跑出来的曲线虽然趋势类似但关键峰位偏移明显怎么看都不能用于论文。一开始怀疑是随机种子问题但代码里明明设了np.random.seed(0)。后来对比两侧环境才发现机器A上scipy是1.6.1机器B上因为requirements.txt里写的是scipy1.5pip装的是1.11.2。某些旧接口在新版本里的数值算法实现细节已经变了——scipy.spatial.distance里部分距离函数不同版本对布尔数组的处理策略不同更重要的是某个依赖库在旧版本里对异常分支的默认处理是“继续算”新版本则直接抛异常代码里恰好catch了异常又返回了一个默认值结果整个分析流程在中间步骤就悄悄降级了。这个案例给老吴最大的教训是复现实验锁定的不只是代码而是完整环境。现在的习惯是每个项目至少保留三样东西conda env export environment.yml锁定conda层所有包版本包括build号pip freeze requirements.lock锁定Python包精确版本如果是跑在自己的服务器上还会额外记录一块Docker镜像或者至少记录CUDA driver版本。没有lock文件时尽量不要用pip install -r requirements.txt这种“裸奔式”安装至少先用pip install pip-tools或者uv把依赖解析成lock文件再装。C扩展相关的包尤其注意能装预编译的轮子就绝不源码编译因为编译器版本不同会直接改变浮点行为和性能。1.3 固定seed却还是波动随机性是实验的基础也是变量随机种子这个问题比大多数人想象的复杂得多。老吴在训练一个神经网络模型时明明在脚本开头做了全套seed设置random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0)但连续跑两次loss曲线的抖动完全对不上。用CPU版推理时结果是完全可复现的一上GPU就不稳定。排查后发现问题出在三个隐藏位置PyTorch的cuDNN benchmark模式默认是False但如果某些代码或库隐式设置成TruecuDNN会在多个算法里挑最快的不同运行轮次可能选不同kernel结果就有微小差异。需要强制设置torch.backends.cudnn.deterministic True和torch.backends.cudnn.benchmark False。DataLoader用了shuffleTrue但没传入固定的torch.Generator每次迭代顺序完全不同。代码里的np.random全局状态被某个底层库悄悄推进了导致后续取值错位。现在的做法是把seed封装成一个全局函数并且连DataLoader的generator也一起固定def set_seed(seed, rank0): random.seed(seed rank) np.random.seed(seed rank) torch.manual_seed(seed rank) torch.cuda.manual_seed_all(seed rank) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False train_loader DataLoader( dataset, shuffleTrue, generatortorch.Generator().manual_seed(42) )如果数据加载过程本身涉及随机性还需要给每个DataLoader子进程单独配一个worker_init_fn。这个坑的隐蔽之处在于它不报错只是让结果“看起来差不多但又不完全一样”浪费的是你反复调试到深夜的时间。2. 结果没报错却不对数值计算里的隐性故障2.1 NaN的N种来法稳定版本的softmax/logsumexp是怎么来的数值计算里最让人头疼的不是程序崩溃而是程序正常运行但loss变成NaN或者某个分析结果变成NaN然后后面的所有步骤全部脏掉。老吴刚开始写自定义loss时用过一个很自然的写法probs F.softmax(logits, dim-1) loss -torch.log(probs[range(N), labels])正常跑没问题但只要logits里出现一个较大数值比如30F.softmax的输出在float32下就可能变成0.0然后log(0)直接得到-inf反向传播梯度就变成NaN。这还算是能排查到的。更麻烦的是softmax内部如果直接算exp(x)数值会溢出——exp(1000)在float32下直接是inf整个softmax归一化分母变成inf结果NaN。数值稳定的softmax其实就多做一步把所有输入减去该行的最大值再做exp和归一化。def softmax_stable(x): m np.max(x, axis-1, keepdimsTrue) exp_x np.exp(x - m) return exp_x / np.sum(exp_x, axis-1, keepdimsTrue)这个技巧在整个科学计算领域到处都是。最典型的封装就是logsumexp它要算log(sum(exp(x)))直接算会溢出改成m log(sum(exp(x - m)))后无论输入多大都不会溢出。PyTorch里torch.logsumexp、torch.nn.CrossEntropyLoss内部都做了类似处理但如果你自己实现论文里的特殊loss就很容易掉进这个坑。有一个非常实用的排查手段在训练脚本里给反向传播开一个检测开关torch.autograd.set_detect_anomaly(True)它能在梯度变NaN的地方直接给出操作栈虽然慢但定位效率极高。另外凡是涉及除法的loss养成习惯给被除数加一个eps1e-8凡是涉及协方差矩阵求逆或Cholesky分解的矩阵可能非正定加一个小的jitter对角项A 1e-6 * I是常规做法。2.2 浮点数不满足结合律同一程序换个并行方式结果就变很多做数值计算的人第一次遇到“并行与串行结果不一致”时都会怀疑并行代码写错了但有一个数学层面的根本原因IEEE 754浮点数不满足加法结合律。(a b) c和a (b c)在实数代数里相等在浮点数里不完全相等。老吴曾经在一个粒子模拟的统计分析里单线程算出的总能量和8路MPI并行的结果从第7位有效数字开始不同。当时的第一反应是自己搞坏了归约逻辑后来发现单纯因为各进程做partical sum的顺序不同舍入误差路径不同。对大多数统计量来说误差在可接受范围内但如果做的是需要长时间迭代的动力学模拟微小误差会逐渐累积成可观察的轨迹偏移。在Python里numpy.sum默认对一部分axis采用pairwise sum本身就和手写for循环的累加顺序不同。要减少这类误差可以用Kahan补偿求和算法def kahan_sum(xs): s 0.0 c 0.0 for x in xs: y x - c t s y c (t - s) - y s t return s如果一定要并行归约可以先在每个rank内部用补偿求和再对各个rank的结果做一次补偿求和。更彻底的办法是对这种敏感计算不要轻易换编译器优化选项-ffast-math这类选项会改变浮点运算顺序同一套代码换一种编译方式结果就可能对不上。老吴现在的习惯是凡是论文里的关键数值结果都要在记录里写明编译器版本、优化选项、并行方式和浮点精度否则三个月后连自己也复现不出来。2.3 多进程下的随机状态一批“看似正常”的重复数据另一个特别阴的坑是并行随机数。某次做蒙特卡洛模拟任务是用multiprocessing.Pool(8)跑8个独立worker每个worker算一批采样最后汇总统计量。跑完之后老吴用可视化图检查采样分布发现图上出现了非常有规律的峰状结构有些概率密度明显异常。查了很久才发现这根本不是什么统计现象而是8个worker里的随机数序列是高度相关的。原因很简单程序在父进程里对numpy的全局RandomState做了一次np.random.seed(0)然后multiprocessing在Linux上默认用fork创建子进程。子进程出生时继承了父进程当时的随机数状态。如果所有worker都从同一个状态往后推进8个worker各自产生的随机序列实际上是同一个序列的不同区间有些区间甚至高度重叠。表现出来就是“每个worker看起来都有随机性但整体样本严重缺多样性”。这种状态下你加再多样本都没有用统计偏差是系统性的。正确做法是让每个worker拥有独立的随机数生成器并且种子从rank派生def worker(rank, base_seed12345): rng np.random.default_rng(base_seed rank) samples rng.uniform(0, 1, size10000) return samples.mean()另一个容易踩的是PyTorch的DataLoader子进程如果不给每个worker单独设置random seed同一个epoch内多个worker采样出的数据会重复或高度相关。给每个worker设置一个基于worker_id的独立seed是标准操作。这个坑不报错、不崩溃需要用统计直觉和可视化才能发现在所有并行计算场景里都值得秒级警醒。3. 资源崩溃现场OOM、inode与“假死”程序的急救笔记3.1 显存OOM的三种假象batch调小为什么不一定有用老吴见过太多人遇到显存OOM就机械地把batch_size减半减到16还是OOM然后彻底崩溃。这里有一个容易被忽略的规律如果batch从128减到64再减到32显存占用只是线性下降但显存监控显示占用还在缓慢增长——那问题大概率不在batch_size而在代码里存了太多中间结果。最容易出事的情况是验证阶段没开torch.no_grad()。推理阶段如果还保留autograd的graph每一层输出都会被记录下来显存占用和训练几乎一样大。验证集比较大时这直接让显存翻倍。其次是训练循环里把每个batch的loss或embedding塞进一个list准备后面分析用忘了这些tensor是带着graph的占用不释放。排查OOM有一套固定流程先用nvidia-smi确认是哪个进程在占显存避免误杀别人。在代码里加torch.cuda.max_memory_allocated()看单步最大占用是多少。把batch固定为一个基数值分别跑训练阶段和验证阶段对比峰值显存。如果模型很大用torch.utils.checkpoint激活重计算前向传播时不保存中间激活值反向传播时重新计算一遍用时间换显存。如果batch确实小了显存还是爆再考虑混合精度训练AMP。Pytorch AMP在大部分场景能省接近一半显存代价是训练曲线可能需要额外调loss scaling但性价比很高。另外一个小细节torch.cuda.empty_cache()不能解决碎片化问题但有时候能立刻把碎片整理回可分配状态。它不是银弹但在调试时非常管用。这个部分还要提一句数据加载的坑DataLoader的num_workers不是越大越好。有些代码在worker里不小心引用了模型或大Tensor在spawn模式下每个worker会把模型复制进自己的内存8个worker就是8份拷贝CPU内存先爆然后GPU也受影响。出现这类情况时把num_workers临时调成0如果显存占用骤降那问题就在数据加载的进程设计上。3.2 “磁盘还有空间却写不进去”的inode惨案有一次运行一个批量训练脚本每跑完一个epoch就写一次checkpoint突然报了OSError: [Errno 28] No space left on device。老吴第一反应是磁盘满了df -h一看根目录还有23GB可用当时以为是代码的路径写错了。又跑了一遍还是在同一个位置报错这次意识到问题没这么简单。检查df -i才发现根目录的inode使用率已经是100%。inode是文件系统里记录文件元数据的索引节点一个小文件无论内容多小都要占用一个inode。几百万个几KB级的小文件总容量没占多少但inode全被吃光了新文件一个也建不出来。排查来源df -i find /tmp -xdev -type f | wc -l du -sh /tmp/* 2/dev/null | sort -rh | head结果是某个脚本每天往/tmp下写几千个临时文件连续跑了几个月也没人清理加上conda环境的__pycache__和一次测试留下的百万级小文件数据集直接把inode占满。从那以后老吴的项目里多了一条铁律临时文件的清理不能靠自觉要写进调度脚本的收尾步骤。日志也要限制大小nohup.out如果无限增长先在启动命令里用nice nohup ... run.log 21 再配合logrotate按天切割避免几个月后日志文件变成几十GB。另外如果有大量小文件需要处理尽量把它们合并成hdf5、parquet或tar包。科研数据里几百万张小文件是最常见的inode杀手同时也是文件系统性能杀手。3.3 程序假死不是死循环先留现场再做诊断程序“假死”和“死循环”有本质区别。老吴有一次写并行模拟程序运行到某个阶段后CPU占用从80%降到接近0但进程一直活着也不报错。第一反应是死锁直接kill掉重跑结果第二次又在同一个地方卡住。这一次没有急着kill用py-spy dump --pid pid查看Python调用栈发现进程卡在multiprocessing.Queue的put操作里。问题就出在一个worker往Queue里塞了一个巨大的numpy数组序列化开销极大其他worker都在等这个对象进入管道。数据一多Queue本身就变成了瓶颈。解决方案很直接不要往Queue里传大对象传文件路径或者小消息真正的大数据传输用磁盘临时文件或共享内存multiprocessing.shared_memory完成。另一个真实案例多进程同时写同一个日志文件文件锁竞争导致所有进程都阻塞在日志写入上看起来像死锁实际上是日志锁的活锁。解决方法是每个进程写到独立日志文件或者用标准库的logging.handlers.QueueHandler汇总。遇到假死时正确顺序是top看进程状态D表示不可中断的IO等待S表示睡眠或锁等待R表示在运行。py-spy dump看Python层调用栈不需要gdb。如果是C/C程序用gdb attach pid然后thread apply all bt拿到所有线程栈。strace -p pid看当前阻塞在哪个系统调用是read、futex还是网络请求。这个流程的核心是先保留现场再诊断再决定是否kill。尤其是NFS或共享存储IO卡住时进程可能陷入D状态这时kill -9都杀不掉强制重启节点还可能带来文件系统问题。科研计算里稳定跑完一个长任务比什么都重要诊断假死时沉住气往往比直接重跑更快。4. 集群作业里的“薛定谔现场”一系列玄学问题的排查全链路4.1 作业提交后找不到文件的根因相对路径与调度系统用Slurm提交作业时老吴遇到过一个特别经典的“薛定谔”问题在登录节点上手动跑脚本一切正常但sbatch run.sh提交后日志里一直报FileNotFoundError: data/input_001.csv not found可这个文件明明就在脚本目录下。排查发现sbatch启动的非交互式shell工作目录和脚本所在目录不一定是同一个。默认情况下作业的工作目录可能是$HOME也可能是调度器配置的某个目录。如果脚本里用了相对路径访问数据就会找不到。这个问题在手动跑的时候永远不会出现因为你已经在数据目录里了。解决方式很简单但必须在脚本开头写清楚#!/bin/bash #SBATCH --job-namervsim #SBATCH --outputrun_%j.out #SBATCH --errorrun_%j.err cd $SLURM_SUBMIT_DIR python run_simulation.py --data /work/project/input --output /work/project/outputSLURM_SUBMIT_DIR是提交作业时所在的目录。不过更稳妥的做法是在程序代码里所有路径都用绝对路径或基于环境变量构造的路径不要依赖“当前工作目录”。多节点并行任务尤其注意不同节点上如果/home和/work的挂载路径不同硬编码路径也要先验证一遍再大规模起任务。4.2 NFS缓存和跨节点一致性文件“失踪”与监控误判集群项目里最常见的“灵异事件”之一是A节点写完一个结果文件B节点立刻去读读不到过了几分钟又能读到了。有人会怀疑程序有bug但其实多半是NFS网络文件系统的缓存和一致性延迟在作祟。老吴有次写了一个监控脚本定期检查某个计算节点是否生成了结果文件如果不存在就认为任务失败并重启任务。结果监控脚本因为NFS缓存延迟误判了好几次把本来可能成功的任务杀了一轮又一轮。后来把监控逻辑改成“文件不存在时等待10秒再重试连续三次确认才判定失败”问题就消失了。这里的核心经验是在集群环境里不能假设一个节点写出的文件在另一个节点上立即可见。如果任务之间有依赖关系用“轮询重试”而不是“一次性判断”如果对数据一致性要求很高先把结果写到本地磁盘比如节点上的/tmp任务全部结束后再统一拷贝到共享目录。很多用户在本地开发机上跑逻辑没问题一上集群就出幺蛾子大概率就是环境交互方式不同。另一个容易被忽略的是节点时间同步。如果两个节点的系统时间相差几十秒所有依赖文件时间戳判断“谁新谁旧”的逻辑都会出错。用chronyc tracking检查节点时间同步状态是集群排错的基本素养。4.3 作业被kill之后的善后工作残留进程与断点再跑长任务被调度器kill是集群上最无奈的事。有时候是超过时间限制有时候是内存超限有时候是队列被管理员取消。但最糟心的不是任务没了而是任务结束后留下一堆残留进程占着GPU显存不放。老吴就见过一个跑了大半的模拟因超时被kill但某个子进程没死导致下次提交新任务时CUDA直接报“out of memory”而nvidia-smi一看有一个消失了两小时的僵尸进程还在占着8GB显存。解决方案分为两个层面。第一层在作业脚本里注册trap清理cleanup() { echo Job terminated, cleaning up... pkill -f run_simulation.py || true pkill -f data_loader.py || true } trap cleanup EXIT第二层程序内部处理SIGTERM信号。Slurm默认在超时前发送SIGTERM给业务代码一个“优雅退出”的机会。训练代码里捕获这个信号后当前步的模型参数、优化器状态、随机数状态全部checkpoint下次启动时自动加载续跑。checkpoint要存的不止是模型torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), epoch: epoch, global_step: global_step, rng_state: torch.get_rng_state(), np_rng_state: np.random.get_state(), last_loss: loss, }, fmodel_epoch_{epoch}.pt)很多任务失败后如果连随机数状态都没有保存那么续跑和重跑没有任何区别。老吴有一次因为没存rng_state断点续跑后发现“恢复”出来的结果和从头跑完全不一样白白浪费了算力。现在不管任务多小只要总时长超过半天都会把rng_state放进去这是血泪换来的经验。另外重复提交任务前养成检查GPU残留进程的习惯nvidia-smi --query-compute-appspid,used_memory --formatcsv如果发现有残留确认是安全进程后手动清理。集群算力资源紧张谁都不希望自己的新任务死在一个旧任务留下的“鬼魂”手上。如果非要给这些乱糟糟的经历做一个总结老吴最大的体会就是90%的科研计算“玄学”问题最后都能归因到环境、随机性、路径和资源这四个位置。很多问题看起来是偶发其实是环境变了、状态没控制好、路径不完整或者资源被别的过程悄悄吃掉了。优先怀疑这四个方向不超过半个小时就能定位到大多数故障。分享几条老吴现在已经变成条件反射的操作每个项目都用独立环境并锁定版本任何代码第一次运行前先核对一遍框架和CUDA版本随机种子作为配置项写进参数文件凡是运行时间超过一小时的任务全部做checkpoint遇到假死进程先留现场再决定怎么处理。这几条看着简单也确实不复杂但每一项都是拿计算资源和大把时间换回来的。科研计算没有捷径坑踩过一次就成了经验祝各位同行都能少踩坑跑出可复现的结果。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门