假设你是一家餐厅的总厨,手里不是传统菜谱,而是一张「做菜流程网络图」:洗菜、切菜、焯水、调酱、爆香、翻炒、摆盘。每道工序可能依赖前面的工序,也可能和别的工序毫无关系。你要做的不是闷头按序号做菜,而是排出一张动线图——谁先做、谁后做、谁可以同时做、哪口锅用完了可以腾出来给下一道菜。AI 模型里的「算子调度 Op Scheduling」干的就是这件事:在计算图上,为每个算子安排执行顺序和资源分配。它不改变算子的数学含义,只改变「什么时候、在哪个硬件上、和谁一起执行」。PyTorch 2.0、XLA 这些编译器,正是替模型这个后厨派出动线图的「AI 总厨」——排得好,GPU 才不空转,显存才不浪费,模型才能在推理和部署时真正跑得又快又稳。
对 AI 从业者而言,理解算子调度意味着你能看懂 PyTorch 2.0 与 XLA 编译栈的核心优化逻辑,知道推理延迟从哪里来、显存峰值为什么能降低,也才能在实际部署中找准性能瓶颈。
可视化演示
展示HLO指令依赖图拓扑排序调度、内存约束调度峰值显存优化及多流水线并行执行重叠三种核心模式,直观呈现AI编译器图优化调度机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
HLO依赖图拓扑调度 算子间数据依赖关系构成有向无环图DAG 拓扑排序确定合法执行顺序满足依赖约束 Schedule = TopoSort(DAG) 调度器保证每个算子在所有前驱完成后执行
核心概念
总厨拿到一张「做菜流程网络图」时,第一反应不是立刻开火,而是先看全局:哪些工序必须先做,哪些工序可以穿插进行,哪些工具用完后要马上腾出来。这张「动线图」就是调度的雏形。AI 模型的计算也能画成同样的一张图:节点是算子,边是数据流向。算子调度 Op Scheduling,就是调度器给每个算子安排「开始时间」和「使用哪个资源」的过程。
在 PyTorch 或 XLA 这类编译器中,计算图先被捕获出来,然后调度器在图上做分析。它不改写算子的数学含义,只决定执行节奏。比如两个卷积之间如果夹着一个激活函数,调度器可以选择把它们放进同一个计算内核,也可以选择分开执行;选择不同,访存次数和 GPU 利用率就不同。这就像总厨决定让同一口锅「焯水后立刻爆香」,还是先洗锅再另起炉灶。
形式化地说,调度可以看作一个从算子集合到开始时间的映射函数:
总厨排动线图时,不需要改菜谱本身,只需要让每个厨师清楚「我什么时候干什么」。算子调度也是如此:不改算子,只改执行节奏。这个节奏,往往决定了模型是「飞快的」还是「卡顿的」。
- 调度函数 σ调度器用 σ 表示「把每个算子映射到开始时刻」的规则。
- 算子集合 V计算图上所有等待执行的算子,就是后厨里所有待安排的工序。
- 非负时间域开始时间不会是负数,没有工序能在时间原点之前开始。
数据依赖
一盘番茄炒蛋,必须先打蛋、切番茄,再下锅。总厨绝不会让炒锅先点火,然后干等蛋液。菜谱里的「先后箭头」是不能违背的顺序。计算图里的箭头,就是数据依赖。
如果一条边从算子 u 指向算子 v,意味着 u 的输出是 v 的输入。u 是生产者,v 是消费者。调度器必须让生产者先算完,消费者才能开工;一旦顺序颠倒,v 就会读到未初始化的数据。这种依赖关系让计算图天然成为有向无环图(DAG)——有环意味着算子永远在等一个永不出现的输入。
合法调度必须满足:对每一条依赖边,上游算子的完成时间不晚于下游算子的开始时间。这个条件写出来是:
总厨会把菜谱整理成一张「必须先做什么」的清单,让任何工序都不能插队到未完成的前一步之前。这种清单在编译器中就叫拓扑排序,它是调度方案的「草稿」,保证大方向不违反依赖。
- 依赖边 e=(u,v)从 u 指向 v 的有向边,表示 v 需要 u 的输出。
- finish(u)u 的执行完成时刻,也就是生产者交付成果的时间。
- start(v)v 的执行开始时刻,也就是消费者开工的时间。
- 整体不等式每条依赖边都必须满足「前手完成不晚于后手开始」。
硬件资源
动线图画得再漂亮,也要看厨房条件:几个灶台、几口锅、几个帮厨。总厨不能安排五道菜同时开火,因为灶台只有三个。AI 硬件同样有资源上限:GPU 的流式多处理器数量、内存带宽、显存容量,都是调度器要面对的「灶台和锅」。
调度器在排时间表时,不只是看依赖是否满足,还要保证任何时刻所有正在运行的算子占用的资源总量不超过硬件上限。如果两个算子没有依赖关系,但一共需要三块大显存,而设备只剩两块,它们就只能错开执行。也就是说,并行不是「想并行就能并行」,资源约束会直接压缩并行度。
这个约束可以写成:
资源约束像厨房的物理天花板,总厨只能在「天花板」下追求效率,而不是纸上谈兵。调度器设计得再好,也不能超出硬件能力去空想并行。
- 任意时刻 t调度器要检查的不只是开始瞬间,而是一整条时间轴上的每一个瞬间。
- 求和范围只要算子的执行区间覆盖了 t,它就在占用资源,需要被计入。
- r(v)每个算子运行需要占用的资源量,相当于一道菜要占几个灶台。
- R硬件总资源上限,相当于后厨的灶台总数。
并行分析
蒸米饭和切配菜互不依赖,总厨可以让一个帮厨去蒸饭,另一个去切菜;而「焯水 → 爆香 → 下菜」必须一条线走完。并行分析就是找出计算图里那些没有依赖关系的算子,把它们放进同一个「并行组」。
两个算子只要能找到一条依赖路径互相连通,它们就不能真正并行;反之,若它们在两条不同的分支上,且没有汇合到一个必须等待的同步点,调度器就可以让它们同时执行。关键路径就是图中从起点到终点最长的那条依赖链。即使资源无限多,模型也不可能比关键路径更快跑完,因此它是整体耗时的下界。
计算关键路径长度有一个递推式:
总厨识别出「同时进行才最省时间」的步骤,也知道什么时候必须等所有帮厨完工才能上下一道工序。关键路径就是那串最不能拖延的菜,而其他工序都可以在它周围灵活穿插。
- cp(v)从源点到 v 的最长依赖路径耗时,表示 v 最早能完成的时间下界。
- pred(v)v 的所有直接前驱节点,v 必须等它们全部完成。
- d(u)前驱 u 的执行时长。
- max 取最大值在所有前驱路径里,耗时最长的那一条决定了 v 最早何时能开工。
内存优化
一口锅焯完水,洗干净又可以拿来炒菜;如果每道步骤都买一口新锅,厨房再大也会被锅占满。计算图里的中间张量也存在显存 buffer 中:张量从被生产出来到被最后一个消费者读完,这一段存活时间称为生命周期(live interval)。
如果两个中间张量的生命周期完全不重叠,调度器就可以让它们复用同一块显存 buffer,而不是同时占两份空间。这也是为什么调度顺序会直接影响显存峰值:一个看似合理的执行顺序,可能让两个大张量「撞车」;而稍微调整先后,就能让它们错峰使用同一块内存。
内存复用条件可以写成:
总厨会紧盯「清台」时机:一道菜刚用完某口锅,下一道菜立刻接管。内存复用就是这种「抢锅」的智慧,让有限的显存容纳更大的模型、更长的序列。
- 生命周期 [s, f)左闭右开区间,表示张量从出生到被读完的存活时段。
- 交集为空两个张量任何一个时段都不会同时存活,说明不会同时占用显存。
- buffer 复用满足条件后可以用同一块显存先后服务两个算子。
- 内存池管理编译器把所有空闲 buffer 收进内存池,统一分配给新算子。
调度策略
总厨排菜讲究策略:是先做耗时大菜让它慢慢炖,同时穿插小菜,还是先全力把所有简单菜上齐?策略不同,客人的等待体验完全不同。算子调度同样需要策略,而不是把所有合法拓扑序都试一遍——那会慢得不可接受。
最经典的方法是列表调度(List Scheduling):维护一个「就绪队列」,所有前驱都完成的算子进入队列,调度器每次从队列里按优先级挑出要执行的算子。优先级怎么定?常用的做法是看关键路径:越靠近出口的算子越要优先执行,因为它在最长的依赖链上,稍有拖延就会拉长整体时间。
优先级递推式可以定义成:
总厨的「策略」就是调度器的启发式规则。策略越聪明,同样的灶台就能做出更多菜;策略越粗糙,资源再满也有可能让关键步骤排队等空位。
- d(v)算子 v 自身的执行时长。
- succ(v)v 的直接后继集合,优先级还要看下游的最紧急分支。
- max 取最紧路径选择下游优先级最高的那一条作为后续依赖。
- 递归累加从出口倒推,越靠后的关键路径会积累出越大的优先级。
工程实践
现代餐厅的中央厨房不再只靠厨师个人经验,而是用智能排菜系统统一调度多家分店订单。AI 编译器就是模型后厨的「智能排菜系统」。PyTorch 2.0 会先捕获 FX 计算图,再做算子调度和代码生成;XLA 会把高层算子图融合成底层内核,再在 GPU 或 TPU 上安排执行顺序。
工程上关注的不再是单个算子快不快,而是端到端推理快不快。调度器要在依赖、资源、内存之间做权衡:太激进的并行会让显存峰值飙升,太保守的串行又会让 GPU 大量空转。PyTorch 2.0 的 TorchInductor 与 XLA 都在做同一件事:把「后厨动线」自动化。
衡量调度收益最直观的指标是加速比:
从手工排菜到中央厨房智能调度,算子调度让 AI 模型的「后厨」从人肉指挥走向自动优化。这也是推理加速和部署优化最核心的技术支撑之一。
- T_serial所有算子一个接一个执行的总时间。
- T_parallel经过并行分析和资源分配后的端到端时间。
- Speedup加速比,大于 1 说明并行调度带来了实际收益。
AI 应用场景
场景一:PyTorch 2.0 编译栈的推理加速
阶段:模型编译与优化
解决的问题:传统 PyTorch eager 模式下每个算子即时执行,无法跨算子统筹调度,导致内核启动频繁、显存峰值高;编译栈先捕获计算图,再做算子调度与融合,像总厨统一排菜一样减少空转和等待。
具体案例:使用 torch.compile 优化 ResNet-50 推理时,FX 图被捕获后,调度器识别出相邻卷积与 ReLU 的依赖关系,把它们放进同一个 kernel 中执行,减少多次读写显存,端到端延迟可显著下降。
场景二:XLA 在 TPU 上的算子调度
阶段:后端代码生成与设备调度
解决的问题:TPU 拥有大量计算核心但显存带宽有限,如果算子执行顺序只按拓扑序排列,可能频繁切换计算模式、浪费带宽;XLA 通过调度分析找出并行组,把无依赖的矩阵乘放到不同核心并行执行,并复用中间 buffer。
具体案例:训练 Transformer 时,XLA 将注意力矩阵乘和 MLP 展开为依赖图,识别多头注意力中相互独立的头,调度到不同计算单元同时计算,同时复用临时矩阵的显存,整体吞吐明显提升。
场景三:推理引擎的部署优化
阶段:推理部署与延迟优化
解决的问题:在线服务对延迟敏感,推理引擎需要在 GPU 上高效执行;利用图优化中的算子调度,把模型拆成多个同步区块,区块内部并行,区块之间只做必要同步,避免线程空转。
具体案例:ONNX Runtime 在 GPU 上部署 BERT 时,通过图优化把 LayerNorm 与前一个算子融合后重新调度,减少 kernel 启动次数,p99 延迟得到优化。
Python 演示
# 简单列表调度器:模拟总厨在资源受限的厨房里排菜class OpNode: def __init__(self, name): self.name = name self.duration = 1 self.resources = 1 class OpGraph: def __init__(self, names, edges): self.nodes = [OpNode(n) for n in names] self.index = {n: i for i, n in enumerate(names)} self.successors = [[] for _ in names] self.indegree = [0] * len(names) for u, v in edges: self.successors[self.index[u]].append(self.index[v]) self.indegree[self.index[v]] += 1 class ListScheduler: def __init__(self, graph, resource_limit): self.graph = graph self.resource_limit = resource_limit self.remaining = graph.indegree[:] self.finish_time = [0] * len(graph.nodes) self.ready = [i for i in range(len(graph.nodes)) if self.remaining[i] == 0] def schedule(self): t = 0 running = [] finished = 0 result = [] total = len(self.graph.nodes) while finished < total: available = self.resource_limit - sum(self.graph.nodes[i].resources for i, _, _ in running) for i in self.ready[:]: if available >= self.graph.nodes[i].resources: available -= self.graph.nodes[i].resources self.ready.remove(i) running.append((i, t, self.graph.nodes[i].duration)) if running: t += 1 next_running = [] for i, start, dur in running: if t - start >= dur: self.finish_time[i] = t result.append((self.graph.nodes[i].name, start, t)) finished += 1 for succ in self.graph.successors[i]: self.remaining[succ] -= 1 if self.remaining[succ] == 0: self.ready.append(succ) else: next_running.append((i, start, dur)) running = next_running else: t += 1 return result, t if __name__ == "__main__": names = ["a", "b", "c", "d", "e", "f"] edges = [("a", "b"), ("a", "c"), ("b", "d"), ("c", "d"), ("d", "e"), ("d", "f")] g = OpGraph(names, edges) scheduler = ListScheduler(g, resource_limit=2) plan, makespan = scheduler.schedule() for name, start, end in plan: print(name, start, end) print("makespan:", makespan)
self.remaining = graph.indegree[:]复制每个算子的前驱数量,作为「还没完成的依赖」计数。self.ready = [i for i in range(len(graph.nodes)) if self.remaining[i] == 0]找出所有不需要等待的算子,放入就绪队列。for i in self.ready[:]:遍历就绪队列快照,因为循环里会修改队列本身。running.append((i, t, self.graph.nodes[i].duration))记录算子 i 在 t 时刻开始执行,并占用资源。if t - start >= dur:判断当前算子是否已经执行完自己的耗时。self.remaining[succ] -= 1下游算子的等待条件减少一个。result.append((self.graph.nodes[i].name, start, t))保存最终的调度计划。
常见误区
误区两个算子在计算图上没有箭头相连,那调度器一定可以让它们同时跑,并行度想开多少就开多少。
正解并行还要看硬件资源。就像两个菜没有先后依赖,但厨房只有一口锅,它们就无法同时开火。资源约束是并行度的第二道门槛,依赖关系只是第一道门槛。
为何常见:初学者看 DAG 时只注意「有没有边」,却忽略了硬件资源模型,误以为依赖合法就是万事大吉。
误区给计算图做一次拓扑排序,得到一个合法的执行顺序,这就是算子调度了。
正解拓扑排序只保证不违反依赖,但完全不考虑资源限制、执行时间和内存复用。算子调度是在拓扑排序之上,还要做并行分组、资源分配、优先级排序和 buffer 复用,是一个更复杂的优化问题。
为何常见:拓扑排序概念直观、实现简单,容易让人误以为它是调度的全部,其实它只是调度的「及格线」。
误区只要把能并行的算子全部并行,GPU 利用率就拉满,模型一定跑得最快。
正解过度并行可能让中间张量全部同时存活,显存峰值暴涨,甚至触发显存溢出;还会增加调度和同步开销。关键路径理论告诉我们,整体耗时受最长依赖链限制,盲目堆并行可能收益很小、代价很大。
为何常见:直觉上「同时做两件事更快」很自然,但忽略了内存与同步成本这些「看不见的锅」。
误区算子调度就是排时间表,最多影响运行快慢,不会影响显存占用。
正解执行顺序决定中间张量的存活区间。调度得好,两个大张量可以错峰复用同一块 buffer,显存峰值可能降低一半;调度得差,即使模型本身很小,也可能同时开多块大显存。内存优化正是调度器的重要目标之一。
为何常见:大家习惯把「时间」和「空间」分开看,但调度恰好同时影响时间和空间。
面试题
Q1. 算子调度的核心目标是?
- A.把算子改写成更小的数学公式
- B.规划算子执行顺序和资源分配← 正确答案
- C.把模型压缩成更少的参数
- D.把训练数据切成更小的批次
答案:B。算子调度更像总厨排菜,改的是执行节奏而不是菜谱本身。改写算子是算子融合与化简的范畴,压缩参数是剪枝量化的范畴,切分数据是数据并行的范畴,均不是调度。
Q2. 算子 v 依赖算子 u,即 u→v,合法调度的前提是?
- A.v 必须和 u 同时开始执行
- B.v 必须在 u 完成前开始
- C.v 必须在 u 完成后才能开始← 正确答案
- D.v 必须比 u 分配更多资源
答案:C。如果 v 需要 u 的输出,就必须等 u 算完再开始,否则会读到未就绪的数据。同时开始或提前开始都违反数据依赖,D 选项与依赖正确性无关。
Q3. 两个算子没有数据依赖,调度器在资源允许时可以?
- A.强制串行执行,保证正确性
- B.合并成同一个数学公式
- C.删除其中一个算子
- D.将二者放到同一时刻并行← 正确答案
答案:D。无依赖且资源足够时,并行执行是调度器提高利用率的关键手段。强制串行虽然合法但浪费资源,合并或删除算子属于图改写而不是调度。
Q4. 资源约束条件「某时刻运行算子的资源需求总和不超过硬件上限」的意思是?
- A.某时刻运行算子的资源需求总和不超过上限← 正确答案
- B.所有算子必须使用完全相同数量的资源
- C.每个算子的资源需求必须严格为 1
- D.硬件资源上限必须等于可用算子数量
答案:A。该约束要求任意时刻所有运行中算子的资源需求之和都不超过硬件总量。B、C、D 都把「不超过上限」偷换成了「完全相等」或「恰好为一」,不符合资源约束的本意。
Q5. 关键路径长度表示的是?
- A.全部算子耗时的简单累加
- B.图中最长依赖链对应的最短耗时← 正确答案
- C.占用资源最多的算子耗时
- D.所有并行路径长度的平均值
答案:B。关键路径是图中最长的那条依赖链,即使资源无限多,整体完成时间也不可能比它更短,因此它是完成时间的下界,不是平均路径,也不是简单累加。
Q6. 两个中间张量的内存 buffer 可以复用,必须满足?
- A.两者的存储大小完全相同
- B.两者的数据类型完全相同
- C.两者的活跃生命周期互不重叠← 正确答案
- D.两者出自同一算子的中间结果
答案:C。buffer 复用的核心前提是生命周期不重叠:一块显存先给算子 A 用,A 用完后再给算子 B 用,这样不会互相覆盖。大小、类型、来源都不是必要条件。
Q7. List Scheduling 的核心思想是?
- A.每次从就绪队列中按优先级挑算子执行← 正确答案
- B.随机选择任意未执行算子执行
- C.只串行执行,不处理并行关系
- D.先执行所有叶子节点再执行根节点
答案:A。List Scheduling 的框架是「就绪队列 + 优先级挑选」:前驱都完成的算子进入队列,每次选优先级最高的启动。随机执行、只串行或先叶子后根都不符合这个框架。
Q8. 以下哪些因素会影响算子调度结果?
- A.算子之间的数据依赖关系← 正确答案
- B.可用计算资源数量上限← 正确答案
- C.中间张量的内存占用← 正确答案
- D.训练数据的采样顺序
答案:ABC。依赖关系决定调度是否合法,资源上限决定并行度,内存占用决定峰值显存,三者都会直接影响调度方案。训练数据采样顺序影响的是训练过程,与计算图上的算子调度无关。
Q9. 关于拓扑排序与调度,正确的是?
- A.拓扑排序是调度方案合法的必要条件← 正确答案
- B.拓扑排序必然给出执行时间最优方案
- C.同一计算图可能存在多种合法调度← 正确答案
- D.拓扑排序只适用于无环的计算图← 正确答案
答案:ACD。拓扑排序保证顺序合法,但合法顺序通常有很多种,并不保证时间最优。只有 DAG 才存在拓扑排序,所以 A、C、D 正确,B 错误。
Q10. 在资源受限且存在关键路径时,下列哪种说法正确?
- A.关键路径上的算子并行可以缩短总耗时← 正确答案
- B.缩短非关键路径算子的耗时必定缩短总耗时
- C.硬件资源越少,关键路径长度越短
- D.并行度越高,内存占用一定越低
答案:A。关键路径决定最短完成时间的下界,所以缩短关键路径上的耗时可以直接加快整体。缩短非关键路径不一定影响关键路径;资源越少关键路径只会越难满足;并行度提高也可能推高显存峰值,不能一概而论。