清晨的景区大巴调度中心,十几辆大巴整齐停在发车区。广播响起:“8 号旅行团,请到 3 号门集合!”导游举起名单,在车前清点人数。他完全可以先让先到的几个人上车坐着,可他偏不——规矩是:一个旅行团必须全员到齐、同时上车,缺一个人,全团都在车下等着,车也不发。听起来有点死板?但在分布式世界里,这正是救命的原则。那个旅行团,就是一个分布式训练任务;每一位团员,就是集群里的一个并行进程;大巴座位,就是宝贵的 GPU 资源。如果让先来的进程先跑起来,它们会发现队友迟迟不出现,只能在集合通信中干等,直到超时崩溃。于是调度器(导游)选择了 All-or-Nothing:要么全员上车,整车发车;要么无人上车,全员等待。这篇文章,就带你听懂调度大厅里的全部门道。
理解 Gang Scheduling,是每一位 AI 平台架构师和分布式训练工程师的必修课——它决定了你的千卡集群是高效满载,还是每天都在空转与重启中烧钱。
目录
可视化演示
演示Gang Scheduling要求一组任务必须全部同时获得资源才能启动,展示排队、资源匹配与all-or-nothing调度语义,避免资源碎片。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Gang调度 任务组 资源槽 已启动 全部任务同时获得资源才启动
All-or-Nothing:整组资源的原子分配
景区的清晨,调度中心广播响起:“8 号旅行团请到 3 号门集合。”导游拿着名单站在车前清点人数——他可以先让先到的几个人上车坐着吗?不行。大巴的规矩是:一个旅行团必须全员到齐、同时上车,缺任何一个人,全团都在车下等着。这就是 Gang Scheduling 最核心的一条规则:All-or-Nothing,要么全上,要么全等。
在分布式系统里,“旅行团”是一个并行任务组(gang),每位团员是一个并行进程。它们要跑的是需要集体协作的计算,比如分布式训练。调度器(导游)手里攥着集群资源(大巴座位),只有整组任务所需的资源能一次性、同时分配到位时,才允许这一组启动。
这个语义也叫资源原子性(resource atomicity):调度器要么同时把 n 个任务全部放到资源上,要么一个都不放,绝不出现“先启动 7 个、缺 1 个”的中间状态。为什么这么决绝?因为训练程序启动时会建立全局通信,部分启动会让已启动的进程阻塞在初始化等待上,直到网络超时崩溃,反而浪费更多资源。因此 gang 任务在调度器眼里是“不可分割”的。
正因为导游坚持“名单上画满勾才发车”,才避免了车开出去却发现人没到齐的尴尬。在集群里,这份坚持换来的是分布式程序启动后不会因为“缺人通信”而整段崩掉。
- 发车决策 S(G)调度器对旅行团 G 做出的“是否启动”决策,1 表示发车,0 表示继续等。
- 全员点名 ∀i∈G把旅行团成员逐个检查,一个都不能漏。
- 车票到手 A_i=1第 i 个任务获得所需资源,标志位置 1。
- 分段决策所有成员都为 1 时输出 1;只要有人是 0,整体输出 0。
排队与抢占:等待区里的博弈
游客服务中心前有一排长椅,那是“等待区”。旅行团凑不齐座位时,导游就带大家在此排队:先来的团排前面,后来的团自动往后排,调度中心保证先来后到。可偶尔会来一个 VIP 考察团(高优先级任务),调度中心就会协调:把散客请下车腾出座位,先让 VIP 团发车。
排队对应调度器的队列(queue),先进先出(FIFO)是最朴素的公平策略。但纯公平会让紧急的大团等太久,于是引入优先级(priority)。当高优组需要资源时,系统可以抢占(preemption)低优任务占用的资源——挂起或驱逐低优任务腾出位置。与此同时,回填(backfill)机制发挥着精妙作用:在队首大团还没凑齐资源时,让排后面的小任务(散客)使用零散空位,前提是不耽误队首大团预定的发车时间。
具体来说,回填允许散客任务 j 插入,需要满足两个条件:它的资源需求不超过当前空闲资源;它的预计运行时间不超过队首旅行团的预计剩余等待时间。这样既不推迟大团发车,又能利用本来会浪费的空位,是调度大厅里“螺蛳壳里做道场”的智慧。
等待区、VIP 插队、散客补位——调度中心用这套组合拳,在“公平”与“效率”之间找到了平衡点。
- 回填许可 Start(j)散客任务 j 获得回填上车的许可。
- 有空位 r_j≤R_free散客需要的座位数不超过剩余空位数。
- 不误正主 d_j≤T_head散客预计占用时长不超过队首团的剩余等待时间。
- 逻辑与 ∧两个条件缺一不可,同时成立才放行。
资源碎片:凑不齐的座位
中午,调度中心接到一个 8 人旅行团。调度员一看屏幕:4 辆大巴各剩 2 个空位。8 个空位总数够,但每辆车都坐不下 8 人——这就是“碎片”。座位总数不少,却凑不出一整车的空位。
把整组任务塞进一台或几台机器时,资源碎片(fragmentation)成为头号敌人。单任务按需占用部分 GPU,导致每台节点都剩“零头”,零头无法满足 gang 的整体需求。于是集群总利用率看着不低,大任务却永远等不到资源——这正是队首阻塞的根源。
常见应对手段有四种:其一,bin-packing 打包,调度器优先把散客任务集中压到少量节点上,把整节点“留白”给大团;其二,按节点对齐放置,让 gang 的一次分配尽量落在一块完整区域;其三,弹性 gang,允许任务组在等待中缩减成员规模(如 16 卡降到 8 卡)来适配碎片;其四,错峰整理,在低峰期集中迁移任务、合并空闲资源。
调度员们逐渐学会了“聚拢散客、留白整车”的整理术,让零碎的座位也能重新拼成大团的完整车厢。
- 总空位 R_free集群当前空闲座位总数。
- 最大连续块 C_max同一辆车上最长的连续空位段长度。
- 可利用率比值最大连续块占空闲总量的比例,衡量资源“能被大团直接整块利用”的程度。
- 求补得碎片率1 减去上述比例,越接近 1 说明资源被切得越碎。
分布式训练为何必须整组发车
为什么旅行团非得整团坐同一辆车?因为车上配了对讲机(通信网络),导游要实时跟每个团员确认位置。如果团里有人坐上另一辆车,对讲机就联系不上;有人没上车,导游喊话也没人应答。分布式训练正是如此:所有并行 worker 就像戴着同一频道对讲机的团员。
在 MPI 或 PyTorch DDP 中,程序一启动就要执行集合通信(collective communication):MPI_Init 要求所有进程同时入场,DDP 的 init_process_group 需要所有 rank 在超时时间内建立连接,而训练中每一轮迭代结束都要做 all-reduce 梯度同步。任何一个 worker 缺席或迟到,其余 worker 全部阻塞等待,直到通信超时抛出异常,整组任务宣告失败并重启。
这就是“掉队者效应”(straggler effect):一轮迭代的耗时被最慢的 worker 决定。Gang Scheduling 通过“整组同时启动”消除了最常见的掉队来源——“有人还没被调度上”;而一旦运行起来,每轮的时间构成可以用公式衡量。
正因为“一圈人都要对讲机呼应”,分布式训练才如此依赖 Gang Scheduling 的整组发车——少一个人,整趟车都是哑的。
- 队友耗时 t_i第 i 个 worker 完成一轮前向加反向计算的耗时。
- 取最慢 max对全部 worker 的计算耗时取最大值,即拖后腿的那个人。
- 对讲机时间 t_allreduce全员同步梯度(all-reduce)的通信耗时。
- 串行相加计算与通信按轮串行,相加得到一轮迭代的墙钟时间。
主流调度器的 Gang 支持对比
不同景区用不同的管理软件:老牌景区用班车时刻表系统(Slurm),互联网园区用智能预约系统(Kubernetes),专门接待大团的景区用定制版“包车系统”(Volcano、Ray)。它们都能管大巴,但对“整团发车”的支持力度差别很大。
Slurm 是超算标配,通过 salloc 或 sbatch 的 --ntasks 一次性分配一组计算节点,天然支持整组并发启动;Kubernetes 默认调度器按 Pod 逐个调度,不支持“整组原子分配”,需要借助 Volcano 的 PodGroup 或 Ray 的 Placement Group 这类协同调度组件。Volcano 引入 PodGroup 自定义资源,声明 minMember 表示“组内最少成员数”,只有就绪成员数达标才允许整个 PodGroup 启动。
选型时看三点:是否原生支持 gang(Slurm 是,K8s 默认否);是否支持优先级抢占(Volcano 支持 preemption);是否支持回填(backfill)。云原生 AI 平台大多选 Volcano 来补足 K8s 的整组调度能力,这也是当前生产环境最主流的技术栈组合。
选对调度中心的管理系统,旅行团的发车体验天差地别——大团多的场景,务必选择带“包车协议”的调度器。
- 已报到人数 n_readyPodGroup 中资源已就绪的成员数量。
- 最少成员数 minMemberVolcano 配置中声明的组成员数量下限。
- 达标判断 ≥就绪人数达到下限即允许启动。
- 充要条件 ⇔满足则启动、不满足绝不启动,二者互为充要条件。
生产案例复盘:千卡训练的上车记
某年“五一”黄金周,调度中心接到一个 512 人的超大型旅行团(千卡大模型预训练)。如果按老办法“来多少座发多少车”,这 512 人可能在停车场耗上一整天。调度中心提前做了三件事:分区规划、错峰发车、实时监控。
大模型预训练通常需要成百上千张 GPU 同时就位。实践中,平台会把集群划分成多个资源分区(partition/queue),每个分区配置独立的配额与优先级;超大 gang 任务放在专门的大分区,避免与小任务互相踩踏。同时启用回填,让零散小任务填充等待期的空隙,提高 GPU 利用率。
某 AIGC 公司 512 卡 A100 集群的复盘数据显示:使用 Volcano PodGroup 声明 minMember=512 后,任务不再“部分启动→通信超时→反复重启”,启动成功率从 68% 升到 97%;配合回填与小任务错峰,集群利用率从 61% 提升到 78%。在故障排查中,“任务进入运行态却迟迟无法完成”这类问题的第一排查项,就是确认任务是否真正同时获得了全部资源。
复盘报告贴在调度中心墙上:一次成功的团建(训练),靠的是提前规划、整组发车和实时盯盘。
- 有效算力时长求和把每张 GPU 上真正执行计算的时间累加起来。
- 理论上限 N×T_wallN 张卡乘以墙钟时间,即所有卡同时跑满的上限。
- 取比值有效计算时间占理论上限的比例,反映资源真实产出。
- 换算百分比乘以 100% 得到直观的利用率读数。
旋钮调优:等待与利用率的平衡
调度中心想把运营再升级,就要调一堆旋钮:等待区设多大?最多等多久?几点发车最划算?配置调得好,旅行团和散客双赢;调得差,要么团等太久退票,要么车空着烧油。
核心可调参数有三类。其一是超时参数:例如 PyTorch DDP 的初始化超时(默认约 30 分钟)、NCCL 的通信超时,还有 gang 等待资源的时间上限;其二是队列配额:每个团队或租户可同时占用的 GPU 上限与优先级权重;其三是策略旋钮:是否开启回填、是否允许抢占、预留资源比例。监控指标上重点盯任务等待时间、资源碎片率、GPU 利用率和任务失败率。
调优背后有个“利用率-等待”跷跷板:参数设得越激进,利用率越高,但排队时间越长。可以用排队论估算集群负载强度 ρ(算力需求密度):当 ρ 接近 1 时,队列会无限拉长,必须加配额限制或扩容。
调度中心的旋钮虽多,但方向只有一个:让每一张 GPU(每一辆车)都载满真正在跑的计算(真正出行的人),而不是在等待中空转。
- 到达速率 λ_job单位时间内提交到集群的任务组数量。
- 平均团规模 k̄每个任务组平均需要占用的 GPU 卡数。
- 平均占道时长 t̄_run任务组从启动到结束的平均运行时间。
- 总车位数 N_GPU集群 GPU 总数;比值越接近 1 说明集群越饱和。
AI 应用场景
千卡大模型预训练的“包场发车”
阶段:模型训练 / 资源调度
解决的问题:直接提交 512 个独立 Pod 时,默认调度器逐个分配,部分 Pod 先启动后因初始化阻塞等待掉队者,通信超时导致整轮反复重启。Gang Scheduling 通过 PodGroup 一次性预留整组资源,从根本上消除“部分就绪”状态。
具体案例:某 AIGC 公司的 7B 大模型预训练任务,512 张 A100 分 4 个分区部署,Volcano 声明 minMember=512 并开启回填,启动成功率从 68% 提升到 97%,集群利用率从 61% 提升到 78%。
气象 MPI 模拟作业的准点发车
阶段:高性能计算 / 批处理调度
解决的问题:MPI 程序要求 4096 个进程同时完成 MPI_Init,部分进程先跑会导致集合通信阻塞、大面积超时失败,机时被白白浪费。
具体案例:某超算中心用 Slurm 调度气象模拟任务,通过 sbatch --ntasks=4096 保证整组同时分配,并开启 backfill 让短任务插入等待空隙,机时利用率提升约 20%。
多租户 AI 平台的高并发保障
阶段:平台调度 / 多租户隔离
解决的问题:多家团队共享集群时,低优任务占据大量零散资源,高优训练任务长期饥饿。Gang Scheduling 配合队列配额与优先级抢占,保证关键任务不被碎片资源饿死。
具体案例:某云平台为每个租户配置队列配额与优先级权重,启用协作调度后,高优任务 P95 排队时间从 40 分钟降至 8 分钟,租户投诉率明显下降。
Python 演示
import randomfrom dataclasses import dataclass NODES = 4 # 景区大巴数量(节点数)SLOTS = 4 # 每辆车的座位数(每节点 GPU 数)GANG_SIZE = 3 # 旅行团人数(组成员数)ARRIVALS = 12 # 提交任务数量 @dataclassclass Job: jid: int kind: str # gang=旅行团, single=散客 size: int remain: int # 还需运行多少步 node: int = -1 # 被分配到的车辆 start: int = -1 # 启动时刻 class ClusterSim: def __init__(self): self.t = 0 self.slots = [[None] * SLOTS for _ in range(NODES)] self.queue = [] # 等待发车的旅行团 self.finished = 0 # 已完成的乘客数 def free_seats(self, node): return [i for i, s in enumerate(self.slots[node]) if s is None] def try_place(self, job): # All-or-Nothing:旅行团必须在同一辆车凑齐 GANG_SIZE 个空位 if job.kind == 'gang': for n in range(NODES): free_idx = self.free_seats(n) if len(free_idx) >= job.size: for i in free_idx[:job.size]: self.slots[n][i] = job job.node = n job.start = self.t return True return False # 散客回填:任意一个空位即可 for n in range(NODES): free_idx = self.free_seats(n) if free_idx: i = free_idx[0] self.slots[n][i] = job job.node = n job.start = self.t return True return False def step(self): self.t += 1 for n in range(NODES): for i, s in enumerate(self.slots[n]): if s is not None: s.remain -= 1 if s.remain <= 0: self.slots[n][i] = None self.finished += 1 # 等待队列中的旅行团,每步都尝试整组发车 waiters = [] for job in self.queue: if self.try_place(job): print(f"[t={self.t}] 旅行团 {job.jid} 全员上车,发车运行 {job.remain + 1} 步") else: waiters.append(job) self.queue = waiters def submit(self, job): if self.try_place(job): print(f"[t={self.t}] {job.kind} 任务 {job.jid} 立即获得资源") elif job.kind == 'gang': self.queue.append(job) print(f"[t={self.t}] 旅行团 {job.jid} 资源不足,整组排队等待") else: print(f"[t={self.t}] 散客 {job.jid} 无空位,本次放弃") def main(): random.seed(42) sim = ClusterSim() for jid in range(1, ARRIVALS + 1): if random.random() < 0.4: job = Job(jid, 'gang', GANG_SIZE, random.randint(3, 6)) else: job = Job(jid, 'single', 1, random.randint(2, 5)) sim.submit(job) for _ in range(2): sim.step() while sim.queue or any(any(row) for row in sim.slots): sim.step() print(f"[t={sim.t}] 模拟结束:完成 {sim.finished} 个任务") if __name__ == '__main__': main()
random.seed(42)固定随机种子,让每次运行结果可复现,方便对照理解。if random.random() < 0.4:用 40% 概率模拟提交旅行团,其余是散客单卡任务。if len(free_idx) >= job.size:只有同一辆车(节点)的空位数达到团人数,才允许整组上车,一个座位都不将就。for i in free_idx[:job.size]:一次占满所需座位,体现 All-or-Nothing 的资源原子性。self.slots[n][i] = job把任务登记到座位上,标记为运行中。s.remain -= 1已上车的任务每步消耗 1 单位运行时间。self.slots[n][i] = None任务运行完毕,释放座位给后续任务使用。waiters.append(job)凑不齐座位的旅行团进入等待区排队,绝不“先上车一部分”。while sim.queue or any(any(row) for row in sim.slots):持续推进调度,直到所有等待团发车、所有座位清空。
常见误区
误区我以为 Gang Scheduling 就是让任务在队列里排着,排到队首自然就有资源了,等待本身不会造成什么损失。
正解排队时资源并不是“原地等你”,零散资源会被其他任务占用,形成碎片。如果队首大团一直凑不齐整组资源,队列可能被堵死,后面的任务全被顶住(队首阻塞)。所以必须搭配回填、抢占和弹性调整,否则集群明明有空位,大任务却永远发不了车。
为何常见:从日常排队经验出发,默认“排队等于公平、总会轮到我”,没意识到分布式资源的碎片化会让队首可能永远凑不齐。
误区我分别提交 n 个任务,让它们各自抢占资源,只要都抢到了,效果和 Gang Scheduling 一样。
正解分步调度无法保证“同时”:先启动的任务会立即执行集合通信初始化,阻塞等待后到者;当等待超过通信超时(如 30 分钟),先启动的进程直接报错,整组重启,浪费的算力更多。Gang 调度的价值正是资源分配的原子性——要么同时给,要么都不给。
为何常见:只关注资源总量,忽略了时间上的同时性,不了解分布式程序初始化阶段的同步依赖。
误区把整个集群的训练任务都放在一个 gang 里统一调度,管理最省心,效率最高。
正解gang 越大,凑齐所有资源所需等待的时间越长,碎片越难补,队首阻塞风险越高。实践中通常按“一次迭代必须全员同步”的粒度划分 gang:一个 DDP 训练作业就是一个 gang,而不是把多个作业合并。组规模应与通信同步域一致,而不是越大越好。
为何常见:把“统一管理”误解成“统一成一个组”,忽略了组规模带来的等待时间和碎片成本。
误区抢占就是直接把低优任务杀掉腾地方,集群整体效率一定提升,没有任何副作用。
正解被抢占任务需要保存 checkpoint、释放显存、重新排队,这些开销都要计入总账;频繁抢占还可能引发活锁(两个任务互相驱逐)和连续重启风暴。生产环境通常设置抢占次数上限和不可抢占保护期,把抢占当作最后手段而非第一手段。
为何常见:只看到资源立刻空出来的即时收益,忽略了被驱逐任务的重启成本与稳定性风险。
面试题
Q1. Gang Scheduling 最核心的调度语义是?
- A.所有任务同时获得资源← 正确答案
- B.任务按优先级逐个启动
- C.任务失败后单独重试
- D.资源空闲时立即分配
答案:A。Gang Scheduling 的核心是 All-or-Nothing,即整组任务必须同时获得资源才能启动,类似于旅行团全员到齐才发车。B 是优先级调度,C 是容错重试,D 是即时调度,都不是“整组原子分配”的语义。
Q2. 旅行团比喻中,“全员到齐才发车”对应调度的哪一环节?
- A.任务排队等待阶段
- B.资源原子分配阶段← 正确答案
- C.任务运行监控阶段
- D.结果聚合返回阶段
答案:B。导游在车门前清点人数,等所有团员到齐后同时安排上车,对应的是资源分配阶段的一次性、原子化操作。排队是上车前的等待,监控是发车之后的事,聚合返回则完全不在调度范围内。
Q3. 允许散客任务回填空闲资源的条件是?
- A.散客必须拥有更高优先级
- B.散客任务必须整组运行
- C.运行时间不超过队首等待← 正确答案
- D.仅限集群完全空闲时刻
答案:C。回填的核心约束是“不耽误正主”:散客的预计运行时间不能超过队首旅行团的剩余等待时间,否则会推迟大团发车。优先级不是回填的必要条件,散客本身就是单任务,也不要求整组运行,更不必等集群完全空闲。
Q4. 下列哪种训练架构对 Gang 调度依赖最强?
- A.异步参数服务器训练
- B.单机单卡模型推理
- C.离线日志批处理任务
- D.同步数据并行训练← 正确答案
答案:D。PyTorch DDP 和 MPI 这类同步数据并行架构,每一轮迭代都要 all-reduce 全员同步,任何 worker 缺席都会导致整轮阻塞直至超时,因此最依赖整组同时启动。异步训练和单机任务都能容忍部分成员掉队或根本不需要协同。
Q5. 以下属于 Gang Scheduling 典型代价的是哪些?
- A.部分就绪也需整组等待← 正确答案
- B.资源碎片导致利用率下降← 正确答案
- C.组内任务通信带宽提升
- D.队列前方任务饥饿加剧← 正确答案
答案:ABD。整组等待意味着只要有一个成员没就绪,全部成员都不能启动;等待期间零散资源被占,形成碎片;队首大团一直凑不齐还会导致队首阻塞,让后面的任务饥饿加剧。C 是通信收益,不是代价。
Q6. Volcano 调度器中实现 Gang 调度的核心资源对象是?
- A.Deployment 工作负载
- B.PodGroup 资源组← 正确答案
- C.Service 服务暴露
- D.ConfigMap 配置项
答案:B。Volcano 通过 PodGroup 声明组内最少成员数(minMember),只有就绪成员数达标才启动整个组,这是标准 K8s 调度器不具备的能力。Deployment、Service、ConfigMap 都是普通 K8s 资源,不提供协同调度语义。
Q7. MPI 任务为何必须整组同时启动?
- A.计算任务无需彼此通信
- B.数据可以事后异步同步
- C.通信双方需同时就绪← 正确答案
- D.节点故障不影响全局
答案:C。MPI 程序启动时要执行 MPI_Init,集合通信要求所有进程同时就绪才能建立通信域;部分进程先启动只会阻塞等待队友,直到超时崩溃。就像旅行团的对讲机,喊话必须双方都在频道上才能呼应。
Q8. 生产中缓解资源碎片的手段有哪些?
- A.按节点打包同构任务组← 正确答案
- B.允许小任务回填空闲资源← 正确答案
- C.禁用所有低优先级任务
- D.动态调整任务组规模← 正确答案
答案:ABD。把散客任务集中打包到少数节点、把整节点留给大团是经典的 bin-packing 策略;回填让零散空位被小任务利用而不浪费;弹性调整组规模(如 16 卡降为 8 卡)能适配碎片化的剩余资源。禁用低优任务只会让资源更闲置,不是缓解手段。
Q9. 关于抢占机制,以下描述正确的有哪些?
- A.抢占总能提升集群利用率
- B.被抢占任务无需重新排队
- C.高优任务可驱逐低优任务← 正确答案
- D.抢占会引入重启与回滚开销← 正确答案
答案:CD。抢占允许高优任务驱逐低优任务占用的资源,是保障关键任务的手段;但被驱逐任务需要保存 checkpoint、释放显存并重新排队,重启与回滚开销不可忽视。抢占并不总能提升利用率,频繁抢占甚至可能引发活锁和重启风暴。
Q10. 集群 4 节点各 4 卡,旅行团需同节点 4 卡,当前每节点恰好 3 卡空闲,调度器应如何做?
- A.等待某节点凑齐 4 卡再发车← 正确答案
- B.拆散旅行团分到四个节点
- C.先启动并允许成员稍后加入
- D.拒绝该任务并移出等待队列
答案:A。每节点只有 3 卡空闲,没有任何节点满足同节点 4 卡的需求,所以调度器应让旅行团继续等待,配合回填让散客使用空位,直到某节点凑齐 4 卡。拆散到四个节点违背集合通信的同节点/同网络要求,先启动再补人会引发初始化超时,直接拒绝则过于粗暴且不公平。