
1. 把稳定性重新定义成结果完整性刚开始做批量任务的时候我对稳定的理解特别朴素机器别宕机、别掉卡、ssh 别断。后来跑的任务量一上来尤其是成百上千个分片丢到队列里跑一整夜我才发现真正让人崩溃的从来不是机器挂了——机器挂了反而省心任务状态直接失败重跑就行。真正要命的是那些看起来跑完了、状态显示成功、文件也写出来了但内容是空的、被截断的、或者中途某一步算错了的任务。这类问题不会报警只会安安静静躺在你的结果目录里等到你拿结果去做下游分析的时候才炸出来。所以我现在衡量一个 GPU 平台稳不稳第一指标不是可用性百分比而是结果完整性在一个批量任务集里最终能通过校验、可复现、可对账的结果占比。这篇内容就是我把同一套批量任务在四家 GPU 服务商上各跑了几轮之后围绕稳定性和结果完整性这两个词整理出来的东西包括我怎么设计评测口径、四家的表现差异在哪里、差异背后的机制是什么、以及我最后沉淀下来的一套六道防线。适合正在做模型训练、批量推理、渲染、仿真这类长时间批量作业的人看刚入门的人也能照着抄一部分老手可以只看第 3、4 章的对账和排查部分。需要提前说明一点下面提到的服务商一律用 A、B、C、D 代称具体是哪家不重要因为我关注的不是谁家最好而是哪些机制会导致结果不完整这个规律在换平台之后依然成立。1.1 从机器不宕机到结果可对账的认知转变我以前在监控面板上盯的是 GPU 利用率曲线和实例存活数后来发现这两个指标对结果完整性的解释力弱得可怜。有一次我跑一批图像预处理任务实例存活率 100%利用率曲线漂亮得很任务状态全是 success结果抽检的时候发现大约百分之三的输出图是纯黑的。查了半天原因是某个分片在解码环节遇到了一个异常样本异常被上层 try/except 吞掉了程序继续往下走最后写了个空数组出去。整个过程没有任何报错状态是成功文件存在大小也正常只是内容无意义。这件事之后我把监控重点换了从进程是否活着改成产物是否符合契约。所谓契约就是每个任务必须产出一个 manifest 描述文件里面写清楚输入指纹、输出文件列表、每个文件的字节数、校验和、以及关键指标的统计摘要。任务成功的判定不看进程退出码而是看 manifest 能不能通过校验。这个转变听起来只是加了个校验步骤实际上把整个问题的性质变了——稳定性从运维指标变成了数据契约问题而数据契约是可以被工程化保证的运维指标只能靠祈祷。顺带说一句这个思路并不新鲜做数据管道的人早就这么干了。但在 GPU 场景下有一个额外变量算力本身是有不确定性的。同一份代码、同一份输入在不同的卡上、不同的驱动版本下、甚至同一张卡的不同时刻浮点结果都可能存在微小差异。所以校验不能简单地做逐比特相等得区分两种东西结构性完整数据没丢、没截断、没被写坏和数值可复现重新算一遍结果在容差内一致。这两件事的排查路径完全不同混在一起看只会把自己绕晕。1.2 结果不完整的四种典型形态我把踩过的和见过的失败模式归了归类大概是下面这四种。搞清楚形态很有用因为不同的形态需要的防线完全不同用一种手段去防所有问题基本等于没防。形态典型表现危害主要诱因静默缺失状态成功、无输出文件或输出为空极高下游才暴露异常被吞、分片逻辑漏片、调度器丢任务静默截断文件存在但字节数不足、末尾损坏高解析时才报错存储写入中断、进程被回收、缓冲区未 flush数值污染结果存在但数值偏离、NaN/Inf 增多中高训练指标异常但不报错掉卡、ECC 错误、驱动/CUDA 版本漂移、非确定性算子卡死超时进程还在、GPU 利用率归零中浪费算力但不污染结果通信挂起、死锁、数据加载阻塞这四类里静默缺失和静默截断是我最不能忍的因为它们完全绕过监控。数值污染稍微好一点通常会在 loss 曲线或者指标上露出马脚但如果你跑的是批量推理而不是训练那就没有曲线可看只能靠抽样比对。卡死超时反而是最好处理的因为它至少还活着有心跳就能抓出来。所以后面我讲的六道防线重点全压在前两类上。1.3 我设计的评测口径与执行框架为了让四家的对比有意义我得先把口径固定下来。我用的是一套模拟项目 X一个中等规模的批量训练任务集每个分片跑固定步数的训练输入是合成数据输出是模型权重快照加一份指标摘要。之所以选训练而不是纯推理是因为训练链路更长涉及数据加载、前向反向、优化器状态、checkpoint 写入能把更多环节的坑暴露出来。具体参数是这样单分片固定 800 步batch 大小和序列长度按显存调到一个既不满也不闲的档位让单分片耗时稳定在 6 到 9 分钟之间。每轮提交 1200 个分片分成 12 个批次提交每批 100 个。为什么是 100 个一批而不是一次性全丢进去因为我需要观察调度器在高并发下的排队行为一次性全丢进去的时候队列积压会让超时阈值失去意义。每个分片必须产出三样东西权重快照文件、指标摘要 JSON、以及一个 manifest 描述文件。manifest 里记录输入数据的 SHA256 前缀、输出文件列表和字节数、训练结束时的 loss、梯度范数、以及随机种子。校验脚本会自动把 manifest 汇总成一份总账逐条核对提交数 成功数 失败数 缺失数。实测下来这个缺失数字段是整份报告里最有价值的一列很多平台的问题就藏在这里。另外我还设了两个容差结构性校验要求字节数完全一致、文件头尾标记完整数值校验允许一定范围波动因为不同代际的卡浮点实现有差异卡死要求逐比特一致那是自找麻烦。评测跑三轮每轮之间换不同的提交时间段避免赶上某一家恰好在做维护窗口。这个设计不完美样本量也不算大但足以把机制层面的差异暴露出来。2. 四家服务商跑下来差异主要不在算力2.1 环境锁定这块四家的差别超出预期我以为对比的重点会是卡型和网络结果第一轮跑完发现真正拉开差距的是环境一致性。同一份自建镜像在服务商 A 上前后两轮跑出来的结果稳定对得上在服务商 D 上第二轮有一小批任务的结果偏离明显查下来是驱动版本在你不知情的时候升级了。这种事特别隐蔽因为你不会每次跑任务前都去 dump 一遍驱动版本而且就算 dump 了你也不一定知道该拿它跟什么比。CUDA 和驱动的组合会影响到算子实现的具体选择进而影响浮点累加顺序。举个具体例子cuDNN 在卷积算法选择上是启发式的它会根据当前的卡型、显存余量、输入尺寸挑一个最优实现挑到哪个取决于运行时的状态。同一份代码在两张不同的卡上跑可能选了不同的算法结果就差出小数点后好几位。如果你的下游逻辑对精度敏感比如要做阈值判断或者去重这点差异就足以让两个结果是否等价这个问题变得无法回答。所以我在评测里加了一个固定动作任务启动前先打印完整的环境指纹包括驱动版本、CUDA 版本、cuDNN 版本、卡型号和显存、以及容器的镜像 ID 摘要。这个指纹写进 manifest。等到对账阶段先按指纹分组只在同组内比较数值跨组只比结构性完整。这个做法帮我省了大量误判——很多我以为的平台不稳定其实是分组比较做错了。2.2 四家的实测表现对照下面是三轮跑完之后的汇总口径。数字是平均值我用的是同一套校验脚本所以横向可比。要强调一下这些数字反映的是特定任务、特定时间段的快照不是对这些平台的终审判决换任务形态结论会变。观察维度服务商 A服务商 B服务商 C服务商 D分片缺失率0.3%0.8%1.6%2.4%静默空输出占比0.1%0.2%0.5%0.9%输出截断次数三轮合计251119实例被动回收次数39622卡死需人工介入任务1247环境指纹漂移无1 次2 次4 次对账一次通过率99.4%98.6%97.1%95.3%这张表最值得看的是两个列实例被动回收次数和输出截断次数。这两个指标高度相关。当实例被回收的时候如果进程没有机会优雅退出正在写入的文件就会留下半截。而很多训练框架在写权重快照的时候是先写主文件再写索引回收发生在中间就会产生一个看起来存在但加载不了的产物。我在服务商 D 上遇到的截断问题绝大多数都能对应到回收时间点附近。服务商 C 的表现有点意思它的回收次数不算高但截断次数不低。后面查了一下它的存储挂载在高负载时写入延迟抖动比较大进程被 kill 之前有一部分缓冲没落盘。这类问题在纯算力指标上完全看不出来只有对账能发现。服务商 A 的数据最好看但也别急着下结论。它的实例回收少一方面是调度策略偏保守另一方面是它的可抢占池和常规池的划分比较清晰我用的是常规池所以受抢占影响小。如果换成便宜的抢占式资源这个数字会完全不同。所以看这种对比表的时候一定要先问清楚跑的是什么资源类型不然容易被带偏。2.3 差异背后的四个机制把这些现象归因我觉得主要有四条机制在起作用。第一条是实例生命周期管理的透明度。A 和 B 在回收实例之前会有比较明确的通知窗口虽然这个窗口不一定很长但足够让我的任务处理函数捕捉到信号并做一次紧急 checkpoint。D 上我遇到几次是直接消失日志停在某个中间步骤就断了。这个差别对结果完整性的影响非常直接——你能不能优雅收尾决定了产出是完整文件还是半截文件。第二条是存储层的写一致性。有些平台的对象存储是最终一致的写入之后短暂时间内读到的可能是旧版本或者不完整分片。这个在单机场景下很难感知但在批量任务里做汇总对账的时候会集中暴露。我的应对方式是所有输出先落到本地临时目录写完之后重命名原子操作再整体上传上传完回读一次字节数比对。这个做法牺牲了一点时间但把截断问题压到接近零。第三条是调度器的排队公平性。批量任务最大的特点是长尾1200 个任务里总有几十个跑得特别慢。如果调度器在你排队的时候又插进来优先级更高的任务你的长尾会变得更长超时阈值就越难设。我后来的做法是把批次切小每批 50 到 100 个宁可多提交几次也不让单批次的排队时间波动太大。第四条是镜像与驱动的耦合方式。有的平台允许你指定驱动大版本有的不允许有的平台在你重启实例之后会悄悄换一个次版本。这件事在短期任务里几乎无感在跨天甚至跨周的批量任务里就是灾难。我现在的习惯是把关键任务的运行环境做成完全自包含的镜像把框架版本、算子库版本全部钉死宁可镜像大一点启动慢一点。提示如果你要做跨平台或跨批次的结果对比务必在第一轮就跑一遍环境指纹采集合规性检查把驱动、CUDA、cuDNN、卡型、镜像摘要全部落盘存档。这个动作成本极低但没有它后面所有的数值差异讨论都是无源之水。3. 批量任务结果完整性的六道防线这一章是整篇内容里最能直接抄的部分。我不太相信选一个最稳的平台这种思路因为再稳的平台也会出问题而且问题往往出在你控制不了的地方。可靠的做法是在任务设计层面把结果完整性做成默认属性让问题发生时能被自动发现、自动重试、自动对账。3.1 第一道防线任务分片与幂等设计分片设计的核心原则只有一条任何一个分片都必须可以被独立、重复执行且重复执行不改变最终结果。这条听起来是废话实际上很多人做不到因为分片处理里常常带状态——比如读取上一轮的处理进度、依赖某个全局计数器、或者往同一个追加文件里写日志。我习惯的做法是给每个分片算一个稳定的 ID用输入内容加参数哈希生成而不是用时间戳或者自增序号。这样任务重提交的时候同一个输入分片拿到的 ID 一定相同输出路径也一定相同。区别只在于我可以用 ID 判断这个分片是不是已经成功过实现了天然的去重。清单生成这一步我一般写一个小脚本把输入目录枚举出来算哈希生成 JSON Lines 格式的任务清单。用 JSONL 而不是一个大 JSON是为了后续可以流式读取、可以按批切分、也可以直接丢给队列系统。import hashlib, json, os from pathlib import Path def shard_id(input_path: Path, params: dict) - str: h hashlib.sha256() h.update(input_path.name.encode()) h.update(str(input_path.stat().st_size).encode()) h.update(json.dumps(params, sort_keysTrue).encode()) return h.hexdigest()[:16] def build_manifest(inputs_dir: str, params: dict, out_file: str): lines [] for p in sorted(Path(inputs_dir).glob(*.bin)): sid shard_id(p, params) lines.append(json.dumps({ shard_id: sid, input: str(p), input_bytes: p.stat().st_size, params: params, output: fout/{sid}/, }, ensure_asciiFalse)) Path(out_file).write_text(\n.join(lines) \n, encodingutf-8) print(fshards{len(lines)} - {out_file})注意这里我把input_bytes也写进去了。这一步看起来多余但它是我做下游校验的锚点如果最终产物里记录的输入字节数和清单里的对不上说明任务读到的输入和你以为的不一样可能是存储同步延迟也可能是路径映射错了。这类问题在挂载式存储上发生过不止一次。3.2 第二道防线checkpoint 与断点续跑的粒度控制checkpoint 的粒度是个需要算的账不是越密越好。太密写盘开销吃掉算力太稀回收一次损失半小时。我的估算方法是设单分片总时长为 Tcheckpoint 间隔为 t单次写 checkpoint 耗时为 w实例回收概率为 p这个可以从平台历史记录里估个大概那么期望浪费时间约等于 p × t/2 (T/t) × w。对 t 求导取极值可以得到一个粗略的最优间隔。举个具体数单分片 T8 分钟写一次 checkpoint 约 12 秒含序列化和落盘p 估 0.03那么 t 在 2 到 3 分钟之间比较合适。实际我取 2.5 分钟也就是每 200 步左右存一次一个分片存 3 次。实测下来这个配置在 D 上跑被回收的任务平均只损失两分多钟的算力。如果按 1 分钟存一次写盘开销会涨到 20% 以上反而亏。续跑逻辑里有个坑我必须提醒checkpoint 必须原子写。如果你直接往目标文件写写到一半被 kill这个 checkpoint 就坏了续跑的时候加载失败任务直接报废。正确做法是先写临时文件fsync然后重命名覆盖。重命名在同一个文件系统内是原子操作这样要么是旧版本要么是新版本不会出现中间态。这个细节看起来很小但它是我在服务商 D 上把报废率从百分之八降到接近零的关键动作。另外别忘了在 checkpoint 里存优化器状态和随机数生成器状态。只存权重的话续跑之后的训练轨迹会和连续跑的不一致你后面做数值对账的时候就会看到莫名其妙的偏差然后误以为是平台算错了。3.3 第三道防线产物校验与总账对账这是六道防线里我认为最重要的一道也是最容易被跳过的一道。做法不复杂每个任务结束时写一份 manifest汇总阶段用一个脚本把所有 manifest 收集起来跟任务清单做差集算出缺失、截断、校验失败三类。校验我分三层。第一层看文件是否存在、字节数是否大于零且与 manifest 记录一致第二层看文件头尾的魔数或标记位确认没有被截断第三层对关键产物算 SHA256跟 manifest 里写的比。前两层很便宜可以每次都做第三层贵一点我只对最终交付的产物做。#!/usr/bin/env bash # 汇总对账清单 vs 实际产物 set -euo pipefail MANIFESTjobs.jsonl OUTROOTout MISSINGreport/missing.txt TRUNCreport/truncated.txt OK0 : $MISSING; : $TRUNC while IFS read -r line; do sid$(printf %s $line | python3 -c import json,sys;print(json.load(sys.stdin)[shard_id])) dir$OUTROOT/$sid if [ ! -d $dir ]; then echo $sid no_output_dir $MISSING continue fi if [ ! -s $dir/metrics.json ]; then echo $sid empty_metrics $MISSING continue fi # 用 manifest 中记录的大小做截断检查 expect$(python3 - $dir PY import json,sys,pathlib dpathlib.Path(sys.argv[1]) mjson.loads((d/manifest.json).read_text()) print(m[weights_bytes]) PY ) actual$(stat -c %s $dir/weights.bin 2/dev/null || echo 0) if [ $actual -ne $expect ]; then echo $sid expected$expect actual$actual $TRUNC else OK$((OK1)) fi done $MANIFEST echo ok$OK missing$(wc -l $MISSING) truncated$(wc -l $TRUNC)这个脚本很土但它每天晚上自动跑一遍跑完把三个数字推到群里我一眼就知道昨晚那批活干得怎么样。有了这个总账我才敢说这批结果是完整的而不是看日志好像都成功了。注意对账脚本本身也要做幂等和可重入。如果你的汇总脚本会修改原始产物比如把结果挪到另一个目录重跑一次就会把待核查的证据破坏掉。我的原则是汇总脚本只读不写报告单独输出到 report 目录。3.4 第四道防线心跳与超时专门对付静默卡死静默卡死是我最讨厌的失败形态因为它同时具备两个坏特性占用算力且不报错。表现形式是进程活着、GPU 利用率掉到零、日志停在某一行动也不动。多机多卡任务里这种情况尤其常见通常是通信层面的等待某个 rank 已经挂了或者没跟上其他 rank 在等一个永远不会来的数据。处理方式两层。第一层是任务内部的心跳训练循环里每 N 步往一个本地文件或者标准输出打一个带时间戳的进度标记同时让进程在处理信号时把当前状态写下来。第二层是外部看门狗一个独立的小脚本定期检查每个任务的心跳文件修改时间超过阈值就判定卡死采集诊断信息后重试。阈值怎么定我用的是单步耗时的 10 倍加上固定宽限。比如单步平均 40 毫秒那么 400 毫秒加 5 分钟宽限等于超时 5.4 分钟没心跳就处理。这个宽限留得比较大是因为数据加载偶尔会有长尾尤其是在共享存储上卡个一两分钟不算异常。设太紧会造成大量误杀反而降低吞吐。处理卡死任务的时候别急着直接 kill。先采集一份诊断快照所有 rank 的调用栈用 py-spy 或者类似工具 dump、当时的 GPU 状态、最近几百行日志、以及网络连接情况。这些东西在事后定位根因时价值极高而 kill 之后你就再也拿不到了。我一般是先 dump 再 kill多花二十秒换来一次彻底排查的机会。3.5 第五道防线环境锁定与确定性配置前面说过环境漂移的危害这里讲具体怎么锁。思路是把影响数值结果的变量尽量钉死钉不死的就显式记录、在对账时分组。框架层面有几个开关可以打开。PyTorch 里打开确定性算法会牺牲一点性能但能让卷积之类的操作走确定的实现路径。cuBLAS 那边需要设置一个工作区配置否则矩阵乘法的结果可能在不同运行之间不一致。这些设置在批量任务里我基本是默认开启的因为可复现性对我来说比那百分之几的速度重要得多。import os, random import numpy as np import torch # 必须在 import torch 之后、任何 CUDA 操作之前设置 os.environ[CUBLAS_WORKSPACE_CONFIG] :4096:8 torch.use_deterministic_algorithms(True, warn_onlyTrue) torch.backends.cudnn.benchmark False torch.backends.cudnn.deterministic True SEED 20240117 random.seed(SEED) np.random.seed(SEED) torch.manual_seed(SEED) torch.cuda.manual_seed_all(SEED) # 环境指纹写进 manifest def env_fingerprint(): import subprocess driver subprocess.run( [nvidia-smi, --query-gpudriver_version,name,memory.total, --formatcsv,noheader], capture_outputTrue, textTrue).stdout.strip() return { torch: torch.__version__, cuda: torch.version.cuda, cudnn: torch.backends.cudnn.version(), gpu: driver, deterministic: True, }warn_onlyTrue这个参数是有意加的。完全严格模式在某些算子上会直接抛错导致任务跑不起来而我们真正需要的是知道哪里不确定不是一有不确定就罢工。开 warn 之后日志里会出现告警我把这些告警也收进 manifest对账的时候就能解释一部分数值偏差的来源。3.6 第六道防线日志聚合与失败归因最后这道防线不直接提升完整性但它决定了你能不能持续提升完整性。做法是把所有任务的日志按统一格式输出集中到一个地方然后按失败原因打标签。我用的标签不多oom显存不足、timeout超时、killed被回收、nccl通信问题、data数据问题、unknown。有了标签每次对账之后我可以出一张分布表。如果某天killed标签突然变多说明平台那边可能在调整资源池如果nccl变多可能是网络或者拓扑变化如果unknown占比超过两成说明我的排查工具不够用得补。这个习惯让我从每次出事都从零开始查变成看趋势就知道往哪个方向查效率差别很大。日志格式我建议统一成单行 JSON字段包括时间戳、分片 ID、级别、事件类型、以及关键数值。好处是可以用最土的工具处理——grep、awk、jq 都能上不需要搭一套完整的日志系统。代价是稍微占点空间但压缩之后完全可以接受。4. 常见问题与排查实录4.1 高频问题速查表这张表是我根据自己记录的故障日志整理的覆盖了批量任务里八成以上的结果完整性问题。遇到异常先按表对一遍能省掉大部分无头苍蝇式的排查。现象最可能原因快速验证处理动作状态成功但输出目录为空分片逻辑漏片或异常被吞查总账缺失数与清单差集补 try/except 记录、加产物存在性断言文件存在但加载报错写入被中断、非原子写比对字节数与 manifest改临时文件重命名重跑该分片结果数值明显漂移驱动或算子库版本变化对比环境指纹固定镜像按指纹分组对账GPU 利用率突然归零通信挂起或数据加载阻塞看心跳文件时间戳dump 调用栈后重启检查存储延迟同一分片重复产出不同结果非确定性算子 未固定种子关闭 benchmark 重跑两次比对开启确定性配置记录种子任务排队很久后被回收长尾任务 抢占策略看回收时间和排队时长切小批次、提高 checkpoint 频率上传后回读文件不完整存储层最终一致回读比对字节数本地落盘后整体上传上传后校验4.2 三个让我损失过算力的坑第一个坑是过度信任退出码。早期我的调度脚本只看进程退出码0 就算成功。有一次框架在捕获到数据异常之后打了日志然后正常退出了退出码是 0任务被判成功实际上那个分片什么都没算出来。后来我加了一条硬规则任务结束前必须写出 manifest没有 manifest 一律判失败不管退出码是多少。这条规则救了我很多次。第二个坑是用错了比较口径。有一阵子我发现重跑之后结果对不上怀疑是平台算错了折腾了很久才发现两次跑在了不同代际的卡上本来就不该逐比特比较。这件事让我养成了先看指纹再比数值的习惯。现在我甚至会在任务清单里直接把指纹作为分组键不允许跨组做严格比较。第三个坑是kill 太急。有一次看到卡死就直接发了强杀信号结果那个任务正好在写 checkpoint杀完之后留下一个损坏的文件续跑加载失败又白跑一遍。后来改成先发温和的终止信号给三十秒收尾时间超时再强杀。同时配合原子写双保险。这三十秒听起来无关紧要但在批量场景下能显著降低返工率。4.3 成本与稳定性的取舍最后说说钱的事因为脱离成本谈稳定性没有意义。我的经验是为稳定性付溢价是划算的但溢价有上限。算法很简单算一下单次任务失败的重跑成本再算一下为了降低失败率需要多付多少。举个例子。假设一批任务总共消耗 400 个卡时单价每小时 10 个单位那么直接成本是 4000。如果失败率是 5%重跑需要额外 200 个卡时加上排队和人工排查的时间成本假设折算下来又损失 30%总成本大约到 5500 左右。如果换一个贵 15% 但失败率降到 1% 的方案总成本大约是 4000×1.15×1.05再加上更少的人工排查时间算下来是更低的。但这笔账在失败率很低的时候会反转。如果本来就是 1% 的失败率为了把它压到 0.5% 而付 30% 溢价就不划算了因为省下的重跑成本远小于溢价。所以我的判断标准是失败率在 3% 以上优先花钱换稳定1% 到 3% 之间优先改任务设计加分片、加校验、加续跑1% 以下基本可以接受把精力放在自动对账上就够了。这套思路也解释了为什么我不建议盲目追求最稳的平台。绝大多数结果不完整的问题根源在任务设计而不在平台。把幂等、原子写、manifest、对账这几件事做扎实你在任何一家平台上的结果完整率都能上到 98% 以上。剩下的那点差距才是平台之间真正的差异而这点差异值不值得付溢价就要看你自己的任务规模和失败代价了。我在实际操作中的体会是对账脚本写得好不好比选哪家平台影响大得多。一个能在十分钟内告诉你哪几个分片有问题、问题是什么类型的脚本价值远超平台上那些花哨的监控面板。面板告诉你机器活着对账告诉你活干完了没有这是两件事。