☰
分布式AI系统工程实践:并行策略、调度容错与可观测性
2026/11/3 7:37:29 网站建设 项目流程

1. 从“单机能跑”到“多机可控”:分布式AI系统要跨过的第一道门槛

1.1 单脚本到分布式训练脚本之间,差的不只是机器数量

我在这个系列前面几篇里,把并行策略、通信原语、参数服务器这些基础骨架都梳理过了,按理说第八篇可以聊点更细的工程问题。但真落到实际项目里,我发现很多团队连“单机改多机”这一步都会在数据加载上翻车,而且翻得毫无征兆。

单机训练脚本里,DataLoader每次shuffle之后把数据集切分成一个个batch喂给模型,这个流程没有任何问题。一旦把它改成多进程分布式训练,如果每个进程都加载完整数据集、各自shuffle,那就相当于每个节点都在看同一批样本。你以为是4卡并行,实际上模型一个epoch里看到的有效数据量直接缩水到原来的四分之一。更麻烦的是验证集指标会变得非常诡异,loss曲线震荡得像心电图,明明模型结构没动,结果却完全对不上。这时候我最先怀疑的不是模型,而是数据采样器有没有按rank正确切片。

PyTorch里有现成的DistributedSampler可以直接解决这个问题,但很多人不知道它的一个关键细节:每个epoch都要调用set_epoch,否则shuffle顺序在多个epoch之间会保持同一个随机序列。分布式训练里每个rank拿到的数据本来就不同,如果epoch之间不换顺序,模型很容易过拟合到固定数据排列上。

sampler = DistributedSampler( dataset, num_replicas=world_size, rank=rank, shuffle=True, seed=seed ) train_loader = DataLoader( dataset, batch_size=batch_size, sampler=sampler, num_workers=num_workers, pin_memory=True ) for epoch in range(epochs): train_loader.sampler.set_epoch(epoch) for batch in train_loader: ...

这段代码看起来简单,但set_epoch(epoch)那一行最容易漏。漏掉之后,每个epoch的数据排列顺序都一样,模型会在固定顺序上产生隐式记忆,训练前期不明显,跑到几十个epoch之后就会暴露成泛化能力下降。

还有一个很容易被忽略的点:分布式训练里全局batch size是batch_size * world_size,这会让BatchNorm这类依赖batch统计量的层变得非常不稳定。很多人在单卡上跑得好好的BN层,换到分布式后验证集指标突然崩了,以为是模型实现问题,其实是每个rank看到的batch太小,统计量噪声过大。处理办法要么把BN换成分组归一化或层归一化,要么在分布式环境里同步BN统计量,后者通信开销不小,小batch场景下我更推荐直接换归一化层。

1.2 初始化失败比训练失败更隐蔽:通信后端的选择与验证

另一类特别折磨人的问题,是训练代码写得没问题,但启动脚本一执行,进程就卡在init_process_group那里半天没有反应。这种卡住不是计算问题,而是通信初始化没完成。

分布式训练启动时,每个进程都要通过MASTER_ADDR、MASTER_PORT、RANK、WORLD_SIZE这些环境变量找到彼此,然后建立通信组。现在主流框架都提供了类似torchrun的启动工具,会自动帮我们把环境变量配好,但底层网络环境依然是自由发挥的领域。最常见的坑有两个:端口冲突,以及多网卡环境选错了网络接口。

一台机器上通常同时存在管理网和计算网,管理网可能是千兆,计算网可能是RoCE或InfiniBand。NCCL默认会在多个网卡里自动选一个,一旦选错,集合通信速度可能直接掉到千兆水平,甚至根本建立不了连接。我自己碰到过的情况是,训练任务在前1个小时一切正常,之后突然开始每隔几分钟报一次NCCL超时,进程又不退出,GPU利用率持续掉到个位数。查到最后发现是网卡bonding配置在运行过程中发生了切换,NCCL连接的socket被切到了管理网。

遇到这类问题,第一步不是改代码,而是确认通信后端到底走的哪条路径。设置NCCL_SOCKET_IFNAME指定计算网的网卡名称,设置NCCL_IB_DISABLE=0启用RDMA,如果网卡不支持RoCE就改成NCCL_IB_DISABLE=1强制走TCP。这些环境变量看着不起眼,但对多机训练的性能影响是数量级的。我建议在正式训练之前跑一个小的allreduce测试脚本,打印出实际带宽和延迟,再开始大任务,十几分钟的投资能省掉后面几天的排查时间。

2. 并行策略不是单选题:数据、模型、流水线怎么配才不打架

2.1 数据并行:省钱但不背锅

数据并行是分布式AI系统里最普及的并行方式,因为实现门槛低、适用范围广,几乎所有主流框架都原生支持。但数据并行有一个容易背锅的点:它在每个step结束时都要做一次全量梯度同步,也就是allreduce。这个同步过程如果做得不好,整个系统的扩展效率会非常难看。

我见过不少团队为了减少通信频率,在每几个batch之后才同步一次梯度,相当于把梯度累积和分布式通信组合在一起。短时间内确实能降低通信开销,但副作用是模型的实际优化路径和原始训练逻辑产生了偏移,收敛轨迹会变得不一样。特别是当你配合学习率调度器使用的时候,很容易出现“明明loss下降了,但泛化效果反而变差”的情况。

学习率这块也值得多说一句。分布式训练把global batch size放大之后,很多人会机械地按线性缩放规则调学习率。这个规则在小规模缩放时还好用,但放大到几十卡、几百卡时经常失效。真实项目中我倾向于用一个更保守的组合:学习率小幅增加、warmup steps拉长、必要时加入梯度裁剪。这三个调节方向同时做,往往比单纯线性放大学习率更稳。

数据并行还有一个隐性成本:如果模型大到单卡放不下,数据并行本身无法解决显存问题。这时候就需要切模型,问题就变成了下面要说的并行策略组合。

2.2 模型并行与流水线并行:显存问题的代价

模型并行有两种主流形态。一种是张量并行,把某一层的权重按行或列切到多张卡上,每张卡只负责一部分计算,但层与层之间需要做allreduce或all-gather来同步结果。这种切法通信频率高,适合放在节点内部用NVLink高速互联的几张卡之间,跨节点做张量并行基本是灾难。

另一种是流水线并行,按层把模型切成多段,每张卡负责其中一段,数据以micro-batch的形式在卡间流动。流水线并行的通信量比张量并行小很多,但它会引入气泡,也就是某些卡空闲等待的时间。经典的F-then-B调度气泡非常大,后来大家普遍改用1F1B调度,让前向和反向交错执行,气泡明显减少。

很多初学者看到流水线并行就想着把micro-batch调大来填满流水线,但micro-batch调大的同时,前向和反向中间的激活值缓存也会同步增长,显存峰值跟着水涨船高。我在实际项目中通常是先固定一个较小的micro-batch,再通过调高流水线stage数量来均衡负载,而不是一味加大batch。模型并行落到工程上的代价不只是显存,还有调度和调试的复杂度。一个8卡数据并行任务出问题,定位起来还相对简单;一旦加入流水线并行,某个rank上的一个NaN可能要跑好几个step才能被其他rank发现。

2.3 混合并行落地的一个可执行参考

以现在常见的大语言模型训练为例,一套比较稳妥的组合是:节点内部用张量并行,节点之间用流水线并行,外层再套数据并行。这个组合方便调度器把通信量大的部分限制在物理拓扑更近的卡上,跨节点流动的只有流水线边界上的激活和梯度,压力小很多。

但也不是所有模型都适合照搬这套方案。我自己在项目里的选择逻辑是:如果模型比较规整、以Transformer结构为主,可以放心用Megatron风格的3D并行;如果模型结构复杂、自定义算子多,我更倾向用ZeRO这类参数分片方案。ZeRO把参数、梯度、优化器状态分片到所有rank上,通信模式只有all-gather和reduce-scatter,对计算图的侵入比张量并行小得多,Python生态里各种自定义层都能直接跑起来。

代价当然也有。ZeRO在需要访问完整参数时会触发跨节点all-gather,通信开销随模型规模增长,长期训练时的稳定性和调试体验各有利弊。我的建议是,先用Profiler跑一下看看通信占比,再决定用哪种并行组合,不要凭感觉选定型。

3. 调度的重点:资源碎片和节点拓扑对训练速度的影响

3.1 调度器要解决的三个实际问题

分布式AI系统到了一定规模后,瓶颈往往不再是单个训练任务,而是多个任务共享集群时的调度策略。我接触过的平台里,很多调度器只按“显卡数量”来分配资源——用户申请16张卡,调度器就找16张空闲卡出来,完全不看这些卡之间的物理位置和网络拓扑。

这个做法在小集群里还能忍,集群一超过几十台机器就出大问题。一个训练任务被拆到两个节点上,每节点各8卡,和全部集中在一个节点的8卡+另一个节点的8卡,通信开销完全不是一个量级。跨节点做allreduce,数据要先经过PCIe、网卡、交换机,延迟轻松翻倍到三倍以上;而节点内走NVLink,时延可以控制在微秒级别。如果调度器不做拓扑感知,任凭任务跨节点散落,用户会以为是模型代码出了问题,实际上可能是通信拓扑在拖后腿。

调度要解决的第二个问题是排队。共享集群里,任务队列经常因为某几个大任务占着资源不释放而整体阻塞。用户等了几个小时发现任务还没调度,平台又没有任何提示,体验就很差。解决思路是给任务设置明确的排队超时和优先级策略,让短任务优先穿插到碎片资源里跑,而不是跟大任务硬抢。

第三个问题是预占。很多调度框架允许高优先级任务抢低优先级任务的计算资源,但在AI训练场景下,预占意味着被抢任务需要先保存checkpoint再退出,这个开销可能比任务本身还大。生产环境里我一般建议不要轻易启用抢占式调度,优先用节点耗尽策略或者挂起等待,把“打断”的代价留给真正紧急的任务。

3.2 资源碎片:动态调度与亲和性的设计思路

资源碎片是共享集群里的常态。一台机器上剩下3张卡,另一台剩下2张卡,而用户任务需要4张卡,调度器要是死板地等同一节点凑满4张卡,集群利用率就很难上去。一个务实的做法是允许任务跨节点组合,但必须让用户感知到性能差异;另一个做法是引入“最小资源单元”的概念,把GPU按物理拓扑分组,任务的亲和性偏好作为调度权重来权衡。

结合AI平台的实际设计,我会在调度器里同时维护两层约束:亲和性约束决定了任务的所有worker尽量落在同一个交换机域里面,减少跨机通信;反亲和性约束则让不同用户的任务尽量分散在不同节点上,避免干扰。这两个约束需要综合成本函数来平衡,不能只取其一。

真正容易被人忽略的,是调度器对显存和CPU资源的一视同仁。一个深度学习任务如果CPU核数给得不够,DataLoader的数据加载会成为瓶颈,GPU一直在等数据,利用率上不去。调度时如果只看GPU显存,不看CPU和内存配额,就会出现一堆GPU空闲但CPU跑满的“僵尸任务”。我在平台上查问题的时候,经常发现某些节点的GPU利用率只有20%,数据加载线程却已经把CPU全部打满,调度的锅跑到训练代码头上。

3.3 从调度策略反推训练任务的资源申请习惯

对于正在使用共享集群做分布式训练的同学,我建议提交任务的时候明确申请多维度资源,不要只说需要几张卡。至少要包含显存大小、CPU核数、内存大小、是否需要RDMA网卡这几个维度。很多平台的调度器支持节点亲和标签,如果能给节点打上“有NVLink”、“有RoCE”、“有共享存储”这类标签,调度器就可以更精细地匹配任务需求。

我自己常用的做法是,给每个训练任务加上一个“可接受的跨节点数”参数。如果任务能容忍跨节点通信,调度器就可以利用碎片资源快速启动;如果任务对通信极度敏感,则明确要求节点内分配。这个参数看似简单,但能极大缓解集群里的资源碎片问题,也能帮用户在性能和启动速度之间做取舍。

4. 容错与断点续训:分布式训练能不能让人睡个安稳觉

4.1 先分清故障类型:进程崩溃、节点失联、慢节点

分布式训练跑在几十块GPU上的时候,故障不是“如果发生”的问题,而是“什么时候发生”的问题。最常见的故障类型有三种:进程崩溃、节点失联、慢节点。

进程崩溃还算好办,日志会留下traceback,进程退出码也能说明问题,只要监测到退出码异常就触发重启流程。节点失联稍微麻烦一点,可能是机器重启、网卡挂掉,也可能是被管理员维护下线。最难搞的是慢节点,也就是所谓的长尾节点或straggler。慢节点不会报错,进程还在跑,GPU利用率却明显低于其他节点,整个并行任务被它拖住,所有其他节点都在空等它完成通信。

慢节点是最浪费钱的故障类型,因为它不会引发告警,很多平台只会监控“进程是否存活”,不会监控“这个节点的step时间是否比其他节点慢了20%以上”。我建议每个训练任务都记录每个rank的step耗时,计算所有rank的P50和P95之间的漂移。一旦漂移超过某个阈值,比如P95比P50慢30%以上,就认为存在慢节点嫌疑,触发自动隔离或人工介入。这一步看起来简单,但能挡掉大量莫名其妙的性能问题。

4.2 checkpoint不是越频繁越好

断点续训听起来很美好,实际做起来比想象中麻烦得多。首先要决定checkpoint里存什么东西,至少需要包括模型参数、优化器状态、学习率调度器状态、RNG状态、数据采样器状态、当前epoch和step编号。其中优化器状态往往比模型参数大好几倍,Adam优化器每个参数要保存一阶矩和二阶矩,显存和磁盘开销都很大。

存储策略上,很多团队会在每个固定step数之后同步保存一份checkpoint,并且把历史checkpoint全部保留,结果就是共享存储被checkpoint撑爆。我见过一个训练任务跑了一个月,checkpoint占了几百GB存储空间,最后全平台存储告警。

更务实的做法是:checkpoint先异步保存到本地磁盘,再异步上传到共享存储,断开保存环节和训练loop的耦合,避免保存过程中阻塞训练。同时只保留最近N份checkpoint,加一个周期性的“关键里程碑”保存,比如每个epoch结束时保存一份完整的,其他中间step只保存模型参数,不保存完整优化器状态,降低存储压力。

4.3 断点恢复的隐性风险

恢复训练时最容易翻车的是随机数状态。很多人只恢复了模型参数和优化器状态,没有恢复RNG,结果训练从同一个loss值继续,但数据的shuffle顺序完全变了。模型相当于突然换了一个训练集顺序,显示上的loss会有一个明显跳变,验证集指标也可能出现短暂波动。

另一个恢复到坏点的典型场景,是异步保存导致的checkpoint内部不一致。比如模型参数保存的是一份新版本,优化器状态保存的却是旧版本,中间差了好几百个step。恢复后的训练会挂着奇怪的数值继续走,很难察觉。要规避这个问题,最好在保存的时候用统一的版本号,把模型参数和优化器状态通过同一个commit写入,保证恢复时读到的是一份逻辑上一致的快照。

并行环境下还有一个更隐蔽的坑:模型并行或流水线并行时,每个rank只保存自己的分片,但如果某个rank的本地文件损坏,整个任务就无法恢复。要不要为每个rank保存多副本,或者引入checkpoint冗余度,这是容量和安全性之间的权衡。我的建议是至少保证同一节点的多个rank不要把checkpoint写到同一块磁盘上,否则节点故障会一次性带走多个分片。

5. 从训练到推理:分布式AI系统后半场的三个实践问题

5.1 推理与训练的设计逻辑差异

训练阶段的分布式系统追求的是吞吐,延迟稍高一点可以容忍,反正一个step跑完再跑下一个。但推理服务完全不同,用户在意的是P50、P99延迟,一次全链路超时就可能让用户体验崩盘。所以当训练系统要走向推理服务时,设计逻辑要做相当大的转变。

首先是批量策略。训练时为了稳定,通常固定batch size,推理时却经常遇到变长请求,不同用户发来的输入序列长度差异很大。如果数据并行地去填充batch,短序列会被浪费,长序列又会拖慢整个batch。现在很多推理框架都支持连续批处理,也就是在同一个batch里动态插入新请求、移出已完成请求,配合流式调度器能显著提升GPU利用率。

其次是网络要求。推理服务往往要跟训练任务共享同一套集群网络,但如果训练任务在做大流量allreduce,推理流量的网络延迟会立刻恶化。我建议推理节点和训练节点在物理网络层面做隔离或至少做QoS优先级配置,否则训练任务一跑起来,推理P99直接翻倍。

5.2 模型路由、版本管理与多租户隔离

推理服务起来之后,流量入口需要一个路由层。这个路由层不能只是简单地把请求按模型名转发到某个服务实例,还要处理模型版本切换、实例容量感知和故障摘除。

我见过最简单也最容易出问题的做法:用负载均衡按轮询转发请求。一旦某个推理实例因为显存碎片或模型加载失败,所有转发到那个实例的请求都会超时,用户感知到的就是服务“抽风”。更稳妥的方案是按权重或哈希做一致性路由,让同一个模型版本尽量路由到同一组实例;在模型版本升级时,先启动新版本实例,做一小段金丝雀流量验证,再把流量逐步切过去。

多租户隔离在推理侧也会遇到训练侧没有的问题。多个模型部署在不同实例上,有些模型显存占用大,有些模型计算耗时长,如果某个租户的请求激增,可能占满整个推理池的资源,影响其他模型的延迟。最简单的手段是按模型维度建立独立的资源池或配额,复杂一点的做法是引入优先级队列,把离线推理任务和在线推理任务的请求分类到不同队列。

5.3 弹性伸缩与冷启动

训练任务的扩展是手动可控的,推理服务则需要根据流量自动扩缩容。但GPU服务的自动扩缩容比Web服务麻烦很多,核心原因是冷启动太慢。一个Web容器可能几十秒就能拉起,一个带大模型的推理服务可能需要几分钟加载权重、构建KV cache索引。如果等流量高峰到了再扩容,服务早就被打满了。

我在实际项目里的做法是:维护一个预热的空闲实例池,让它预先加载好模型权重,只是不接收流量;流量上涨时,把空闲实例直接接入路由,秒级生效;流量回落后,再把实例从路由摘掉,等一段时间确认没有残留请求再销毁。这样缩容不是立刻释放GPU,而是先让实例回到预热池待命。

另一个容易忽略的点是背压。推理请求量超过服务承载能力时,路由层不应该无限重试,否则下游被打爆后链路雪崩。我习惯在路由层设置队列长度上限,超过上限就快速失败返回“服务忙”,同时把重试次数限制在1到2次,并且带指数退避。这才是分布式AI系统在推理侧正确的“自我保护”姿势。

6. 可观测性排查实录:一次训练卡顿背后的完整定位链路

6.1 分布式AI系统三板斧:指标、日志、链路

分布式智能系统的可观测性,看起来和普通微服务没有区别,无外乎指标、日志、链路追踪三件套,但落到训练场景里,具体做法完全不同。

指标层面,除了常规的CPU、内存、网络IO,更要关注的是训练step耗时、GPU利用率、显存占用、NCCL通信耗时、数据加载耗时这些训练特有指标。日志层面,每一行训练日志都应该带上rank编号和节点标识,否则几十个进程的日志混在一起,根本无法定位问题。链路追踪在分布式AI里的含义也和微服务不太一样,微服务追踪的是“一个用户请求经过哪些服务”,而训练任务追踪的是“一个step经过数据加载、前向、反向、梯度同步、参数更新这几个阶段各花了多长时间”。

我强烈建议在框架里给每个阶段打耗时标签,哪怕是简单的自定义计时器。这个习惯一旦养成,排查性能问题的速度会快一个数量级。因为“慢”本身不会告诉你慢在哪一段,只有阶段耗时分解能告诉你。

6.2 从一次真实卡顿谈起:链路是怎么一步步定位出来的

前阵子我负责的一个8节点64卡训练任务,跑了快两周,突然从某天下午开始,step耗时从稳定的1.4秒一路涨到4秒,GPU利用率掉到50%以下,所有节点都没有报错,进程也全部存活,看起来就像系统突然“发呆”。

我当时的排查顺序是这样的。第一步,先看worker日志,确认没有OOM和NCCL异常报错,排除了进程崩溃的可能。第二步,按节点维度去看step耗时,发现大部分节点依旧保持1.4秒,只有两个节点变成了4秒以上,问题范围立刻缩小到这两个节点及其连接关系。第三步,登录这两个节点看GPU利用率,发现GPU确实空闲,但CPU使用率偏高,说明瓶颈不在计算侧,而在数据侧或通信侧。第四步,检查网络接口流量,发现计算网几乎闲置,RDMA流量很小,说明也不是跨节点通信的问题。同时检查存储系统,发现挂载盘的IO延迟已经跑到几百毫秒,数据读取明显卡顿。

到这一步,根因基本浮出水面:共享存储上另一个团队正在批量导出数据集,把存储IO带宽全部挤爆,我们的训练任务每次从存储读样本都等了几秒钟。训练代码本身没有任何问题,但跨团队的共享资源争用把整个任务的效率拖垮了。

这个问题暴露出来之后,我做了两个调整:一是给训练任务的数据目录划分独立的存储配额并限制吞吐,防止共享存储被单一大任务打满;二是设置存储IO监控告警,当IO延迟超过阈值时主动提醒,而不是等训练步时间异常才开始查。经过这两项改动,同类问题再也没有发生过。

6.3 给故障预案留下的“三个抽屉”

经历过这次排障之后,我把分布式AI系统的故障应急预案整理成了三个抽屉,分类存放,方便需要时直接取用。

第一个抽屉是进程级故障:以自动重启为主。只要监测到进程退出、rank掉线或者OOM日志,就自动拉起新的进程组,并从最近的checkpoint恢复。这里要注意的是,重启后rank编号可能发生变化,需要把旧rank的本地状态清理干净,避免新旧状态混用。

第二个抽屉是节点级故障:需要在调度层面预分配配额。节点宕机时会带走几个worker,训练任务如果只申请了精确的GPU数量,就没有了替换空间。我会在初始申请时多预留一小部分冗余节点,专门用来承接故障后重调度的worker。

第三个抽屉是慢节点响应:这是最容易被漏掉的。每个训练任务都要记录rank维度的step耗时,设置漂移阈值,一旦超过阈值就自动摘除慢节点并触发重新分配。按我的经验,先把step耗时按阶段打点,再结合这三个抽屉的预案,比事后翻日志排查要省太多时间。分布式AI系统的稳定运行,靠的不是运气,而是提前把每一种失败路径都演练一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询