假设你是一位餐厅后厨主管。客人点了一份全家福套餐,后厨里有十几个帮手、好几口锅、一个备菜台。怎么安排这场“工序”直接决定一桌菜多久能上桌:是让一个帮厨从头炒到尾,还是把肉切成小块让几个灶同时烧?葱姜蒜要不要提前切好放在手边?炖汤和炒菜怎么错开用火?这些安排,放到 AI 的世界里,就是“调度”(schedule)。 深度学习模型在被 TVM 这样的编译器翻译成机器码之前,会先被表示成一张“计算图”——相当于这份菜单。TVM 提供了一批调度原语(primitive),就像后厨的刀具和灶台:split(切菜)、fuse(合并工序)、cache_read(把食材搬到灶台边)、rfactor(让帮厨们分工再汇总)。把一批原语组合起来,就得到一份完整的“工序表”,也就是一个调度方案。 可原语的组合方式多得惊人——就像工序安排的方案数可以爆炸。本文要讲的“调度原语空间枚举”,就是把这套组合一个不漏地列出来、再挑出最快那套的方法。读完你会明白每个原语解决什么问题、组合空间为什么这么大,以及自动调度器怎样在空间中聪明地搜寻。
学完本文,你能理解 TVM 自动调度器(Ansor/MetaSchedule)的设计核心,为算子调优与编译优化实战打下基础——这是 AI 编译器工程师的核心竞争力。
可视化演示
展示TVM三大调度原语:split/fuse循环拆分合并、cache_read多级内存层次数据搬运、以及rfactor归约分解并行化策略。
本节配有交互动效(进入视口后自动加载,约 3KB)。
split/fuse:循环拆分与合并变换 split将大循环i拆为外层i_o和内层i_i 拆分后可对不同层级施加不同优化 fuse将相邻循环合并,减少调度开销 split+fuse是构建调度搜索空间的基础变换。
调度原语总览
想象后厨刚接到一张“全家福套餐”订单:蒸鱼、炒时蔬、炖汤,好几道菜要同时端上桌。主厨拿到菜单后第一件事不是点火,而是画一张工序表——谁先备菜、哪个灶台烧什么、炖汤和炒菜怎么错开用火。这张工序表,在 TVM 里就叫“调度”(schedule)。调度不改变菜谱本身,只改变做菜的方式;对应到编译器里,调度不改变计算结果,只改变算子的循环结构与数据流向。
深度学习模型在交给 GPU 之前,会被编译器拆成一张计算图(computational graph),图里的每个节点是一个算子,比如卷积、矩阵乘法。每个算子最终会写成一层层嵌套的循环去执行。调度要做的事,就是决定这些循环怎么组织、数据怎么流动。TVM 把调度的动作抽象成一批原语(primitive),按性质分成三大类:循环变换类(split、fuse、reorder)、数据缓存类(cache_read、cache_write)、归约分解类(rfactor)。任何一种调度方案,都是这些原语按一定顺序组合的结果。
把所有合法组合收集起来,就得到调度空间(schedule space)。数学上可以这样描述:给定一张计算图 \( G \),再给一组合法的原语序列 \(\pi\),每应用一次就得到一个调度方案;全部方案构成集合 \(\mathcal{S}\)。
就像主厨手里的刀、铲、锅,原语本身没有好坏,关键在于怎么组合。认识了工具清单,接下来就一把一把地试。
- 调度函数表示把一组合法原语序列 \(\pi\) 作用到计算图 \(G\) 上,得到一个新的调度。
- 合法集合这里的 \(\pi \in \Pi\) 意思是原语序列必须属于合法集合 \(\Pi\),不是随便排个序就行。
- 空间集合所有满足条件的调度结果被收集进集合 \(\mathcal{S}\),这就是我们要枚举的“空间”。
循环变换原语
炖一锅肉要 40 分钟,但灶台只空出 10 分钟。聪明的做法是把肉切成四小块,分给四个灶同时炖。“切块”这个动作,对应调度里的 split 原语。split 把一层循环 \([0, N)\) 按因子 \(f\) 拆成两层:外层负责分块数量,内层负责块内迭代。
与 split 相对的是 fuse,它把紧邻的两层循环合并成一层,减少循环启动和同步开销;reorder 则交换嵌套循环的先后顺序,让访存更连续。三者配合,循环结构几乎可以“揉圆捏扁”。有了切块、合并、调序这三板斧,主厨就能按硬件的脾气重塑工序——这为后面的数据缓存和并行归约打下了基础。
- 原循环空间表示循环 \(L\) 的迭代编号从 0 到 \(N-1\),一共 N 次迭代。
- 拆分因子\(f\) 是希望的拆分粒度,比如 \(f = 4\) 表示每块装 4 次迭代。
- 外层循环表示循环被切成 \(\lceil N/f \rceil\) 块,这是外层循环的迭代次数。
- 内层循环表示每块内部还有 \(f\) 次迭代,内外两层合起来仍是原来的 N 次。
数据缓存原语
主厨发现每次炒菜都去仓库拿盐太慢了,于是在灶台边放一个调料架,开工前一次性把常用调料摆上。这个调料架就是 cache_read 原语。它在靠近计算单元的地方创建一块局部缓冲区,计算前把要用的数据从主存拷进来,之后计算全部命中这块缓冲区,避免反复访问慢速主存。
副作用也要考虑:缓存数据需要搬运时间,搬运本身不是免费的。cache_write 则负责另一个方向——计算先把结果攒在缓冲区里,攒够再一次写回主存,避免频繁的小写操作。TVM 中缓存分多级,如 global、shared、local,对应后厨的仓库、备菜台、手边调料碟;用哪一级要看数据复用距离和硬件结构。
从性能模型看,一次数据访问的平均时间由“命中缓存”和“未命中”两部分加权得到:
调料架摆好后,炒菜不再满后厨跑,但架子本身也占地方。缓存换来速度,也要付出空间和搬运成本——这正是后面调度搜索要权衡的地方。
- 命中代价表示数据在缓存中被读到时的耗时,非常短。
- 缺失率\(\rho\) 是访问数据不在缓存中的比例,取值在 0 到 1 之间。
- 缺失代价表示访问主存的耗时,通常比缓存慢一个数量级。
- 平均耗时整体平均访问代价等于两者加权,缺失率越低越便宜。
归约分解原语
洗 100 筐菜,如果只有一个帮厨,他只能从头洗到尾;主厨叫来十个人,每人洗 10 筐,最后把洗好的菜归拢到一处。“先分工再汇总”,就是 rfactor 的思路。归约循环做的是 sum、max、min 这类“把一组值合成一个值”的运算,天然满足结合律:先算部分和,再算部分和的和,结果不变。
以双重求和为例,交换内外层求和的顺序不会改变最终答案:
帮厨们并行洗菜靠的是“菜怎么洗都还是那些菜”——结合律。rfactor 能安全分解的底气也正在于此。它把一个归约循环拆成两段:多线程先各自算局部归约,再做一次最终合并。
- 双重遍历左边先对下标 \(i\) 求和,再对外层下标 \(j\) 求和,依次累加全部 \(n \times m\) 个元素。
- 交换次序右边把求和顺序反过来,先对 \(j\) 求和再套上对 \(i\) 的求和。
- 结合律保证加法满足结合律与交换律,所以两种顺序结果完全相同。
- 并行意义右边可以先并行计算每一列的和,再汇总——这正是 rfactor 能提升并行度的核心。
调度组合空间
每道菜有几十种切法、十几种火候,把这些工序排列组合,后厨的可能方案多到数不清。调度也一样:原语种类不多,但参数和次序让组合数量急剧膨胀。每种原语都带参数:split 的因子可以取 2、4、8……;cache_read 可以指定缓存层级;rfactor 可以选分解轴。每一步选择都是在为调度空间“添一个维度”。
如果第 \(i\) 步有 \(C_i\) 种可选方案,总共 \(k\) 步,那么调度空间的大小遵循乘法原理:
更麻烦的是,并非所有组合都合法:拆分必须整除或处理余数、fuse 只对相邻循环有效、rfactor 只对归约循环安全、数据依赖不能被破坏。空间不是简单笛卡尔积,而是一棵带约束的枚举树。组合多到爆炸,后厨不可能把每道工序都试一遍,所以下一步要想办法:怎样聪明地枚举。
- 逐层选择\(C_i\) 表示第 \(i\) 步调度时可选的参数个数,比如 split 的因子有 4 种取值。
- 乘法原理把每步选择数连乘起来,得到完整组合数。
- 空间大小得到的乘积就是调度空间中合法调度的总量,步数越多增长越恐怖。
枚举策略方法
真正的主厨不会一小时试一种做法。他会先排除明显不行的方案——比如整块肉直接下锅——再对剩下候选做快速估算,只对最有希望的精细尝试。枚举调度空间,就是在以计算图为根、以原语为边的树上做遍历。最朴素的办法是深度优先搜索:逐个生成完整调度并评估,但空间太大时根本跑不完。
于是枚举器引入三个关键技巧:第一是合法性剪枝,碰到破坏数据依赖的分支立刻退回;第二是下界估计,如果某个前缀调度已经不可能比当前最优解更好,就整棵子树放弃;第三是启发式评估,用代价模型(访存量、并行度、缓存命中率)给候选调度打预估分。TVM 的 Ansor 和 MetaSchedule 正是沿着这条路做的。
剪枝决策可以写成:
至此,从一张菜单到一桌菜,从原始算子的循环到组合爆炸的调度空间,再到聪明的枚举搜索——TVM 的后厨就这么开张了。
- 部分调度是已经应用的前缀原语序列,从根节点到当前枚举树节点的路径。
- 下界估计用简化代价模型估算从这个前缀出发能做到的最好成本。
- 已知最优是当前搜索过程中已经找到的最佳完整调度的成本。
- 剪枝决策表示当前缀下界都不优于最优时,放弃扩展这条分支。
AI 应用场景
Ansor 自动调度搜索
阶段:编译优化 / 自动调优
解决的问题:传统编译器工程师依赖手工经验写调度,一个算子要调好几天。AI 模型算子一多,手工根本调不过来。Ansor 基于本文的枚举思想,自动生成调度候选并评估,把调优从人力活变成机器活。
具体案例:在 TVM 中用 auto_schedule 对 ResNet-50 的卷积层自动搜索调度。系统先枚举 split/cache_read/rfactor 的各种组合,再用代价模型筛选,几小时内找到比手工调度快 30% 以上的方案。
MetaSchedule 大模型推理调优
阶段:推理部署 / 算子生成
解决的问题:LLM 的注意力算子在 A100、H100、消费级显卡上最优调度差异极大。MetaSchedule 吸收枚举加学习思路,自动适配不同硬件。
具体案例:部署 Llama-2-7B 时,对 attention 中的矩阵乘算子枚举 rfactor 分解轴与 cache_read 缓存层级组合,使 FP16 推理吞吐提升约 40%。
Python 演示
"""简化版 TVM 调度原语空间枚举演示。"""from itertools import product class Schedule: """一次调度:循环大小为 size,记录已应用的原语。""" def __init__(self, size): self.size = size self.loops = 1 self.stages = [] def split(self, factor): if self.size % factor != 0: return False self.size //= factor self.loops += 1 self.stages.append(('split', factor)) return True def fuse(self): if self.loops < 2: return False self.loops -= 1 self.stages.append(('fuse',)) return True def cache_read(self): self.stages.append(('cache_read',)) return True def rfactor(self): if self.loops < 2: return False self.stages.append(('rfactor',)) return True def clone(self): s = Schedule(self.size) s.loops = self.loops s.stages = self.stages[:] return s def describe(self): if not self.stages: return 'raw-loop' return ' -> '.join( f'{name}({arg})' if arg is not None else f'{name}()' for name, arg in self.stages ) OPTIONS = [ ('split', [2, 4]), ('fuse', [None]), ('cache_read', [None]), ('rfactor', [None]),] def enumerate_schedules(size, max_steps=3): frontier = [Schedule(size)] results, visited = [], set() for _ in range(max_steps): nxt = [] for sched in frontier: for name, args in OPTIONS: for arg in args: cand = sched.clone() ok = cand.split(arg) if name == 'split' else \ cand.fuse() if name == 'fuse' else \ cand.cache_read() if name == 'cache_read' else \ cand.rfactor() if ok: key = cand.describe() if key not in visited: visited.add(key) results.append(cand) nxt.append(cand) frontier = nxt return results if __name__ == '__main__': found = enumerate_schedules(16, max_steps=3) for idx, sched in enumerate(found, 1): print(f'{idx:3d}. {sched.describe()}') print(f'共 {len(found)} 个合法调度序列')
self.stages.append(('split', factor))每成功应用一个原语就记录下来,形成调度路径。if self.size % factor != 0: return False不能整除就拒绝,保证拆分后的循环边界合法。def clone(self)深拷贝当前调度状态,枚举时各分支互不干扰。cand.split(arg) if name == 'split' else ...按原语名分派到对应方法,模拟调度器的派发逻辑。if __name__ == '__main__':给脚本一个可执行入口,直接运行即可打印全部枚举结果。
常见误区
误区“我把循环拆得越细,并行度越高,一定能跑得越快。上次我把 split 的 factor 从 4 改成 64,结果反而慢了,是不是编译器有 bug?”
正解split 不是越细越好。过度拆分会让循环启动开销变大、缓存利用率下降,还可能引入额外同步。最佳粒度由硬件特性决定,比如线程数、缓存行大小。就像后厨一个人切肉,切成 1000 块并不会更快,反而更费案板和盘子。拆分的目标是让每个计算单元吃饱,而不是切得最碎。
为何常见:直觉上“更多并行”容易和“更快”划等号,忽略了并行本身也有调度与同步成本。
误区“cache_read 和 cache_write 都是把数据放进缓存,名字不同而已,效果应该差不多。”
正解两个原语方向完全相反。cache_read 是“计算前把输入数据从主存拷贝到局部缓存”,cache_write 是“计算后把输出结果先写进缓存再冲刷”。一个管读、一个管写,搭配使用才能减少主存流量。像后厨里“备菜”和“上菜”是两套动作,不能混为一谈。
为何常见:两个名字都带 cache 前缀,初学者容易只记前缀而忽略 read/write 的方向差异。
误区“rfactor 不是能提升并行度吗?那我给所有循环都加一个 rfactor 不就行了?”
正解rfactor 只适用于归约循环,也就是 sum、max、min 这类满足结合律的运算。非归约循环没有“汇总”阶段,强行分解会破坏计算语义。就像洗菜可以分工,但炒菜时“放盐”和“颠勺”的顺序依赖不能拆开。判断依据是看运算是否满足结合律。
为何常见:只看到 rfactor 的并行收益,忽略了它的适用前提——归约语义与结合律。
误区“枚举调度空间不就是把 split、fuse、cache_read 这些原语做笛卡尔积吗?写个排列组合就能列完。”
正解远没那么简单。原语应用要满足循环边界整除、相邻层级引用、数据依赖不被破坏等约束,非法组合大量存在。枚举器必须边枚举边过滤,空间实际上是一棵带约束的树。就像后厨不是所有工序顺序都允许——总得先备菜再下锅,这个顺序约束就是调度合法性检查。
为何常见:把“数学上可排列”和“语义上合法”混在一起,忽略了编译器底层的约束。
面试题
Q1. 在 TVM 调度中,split 原语的主要作用是什么?
- A.合并循环为一个大循环
- B.拆分循环为内外两层← 正确答案
- C.调整循环执行顺序
- D.把数据读入共享缓存
答案:B。split 的动作是把一层循环拆出内外两层:外层迭代块数,内层迭代块内元素,答案为 B。A 是 fuse 的职责,C 是 reorder,D 是 cache_read。就像把一块肉切成小块分给多个灶台,循环结构从一层变成两层嵌套。
Q2. fuse 原语用于完成什么操作?
- A.把多个串行循环合为一个← 正确答案
- B.把结果写入全局内存
- C.分解归约的计算因子
- D.交换嵌套循环的先后
答案:A。fuse 把紧邻的多个串行循环合并成一个,减少循环切换与同步开销,选 A。B 是 cache_write,C 是 rfactor,D 是 reorder。类比后厨把“切完再腌”两个动作合并成一个连贯工序,省去中间停顿。
Q3. 下列哪些属于 TVM 的调度原语?
- A.cache_read 数据缓存原语← 正确答案
- B.rfactor 归约分解原语← 正确答案
- C.gradient 梯度回传操作
- D.reorder 循环重排原语← 正确答案
答案:ABD。TVM 调度原语包括 cache_read、cache_write、split、fuse、reorder、rfactor 等,因此 A、B、D 正确。C 的 gradient 属于训练中的反向传播概念,不是调度原语。就像后厨工具清单里不会出现“收银”——工具要跟工序匹配。
Q4. cache_read 原语的核心作用是什么?
- A.将大循环拆为内外两层
- B.创建局部缓冲暂存数据← 正确答案
- C.调整各循环之间的先后
- D.分解归约循环的计算
答案:B。cache_read 在计算前创建一块局部缓冲区,把主存数据复制进来,之后计算命中缓冲区,答案为 B。A 是 split,C 是 reorder,D 是 rfactor。就像炒菜前把调料先摆上灶台,避免做一步去仓库取一次。
Q5. rfactor 原语主要应用于什么类型的循环?
- A.数据并行的普通循环
- B.包含分支判断的循环
- C.具备结合律的归约循环← 正确答案
- D.只读数据加载循环
答案:C。rfactor 只对归约循环有效,归约运算如 sum、max、min 满足结合律,可以分段计算再合并,选 C。普通并行循环、分支判断循环、只读加载循环都不具备这种“分而合之”的归约语义,不能安全分解。
Q6. 关于调度组合空间的描述,下列说法正确的是?
- A.原语可组合产生新调度← 正确答案
- B.组合数量呈指数级增长← 正确答案
- C.所有组合皆合法调度
- D.合法调度受数据依赖约束← 正确答案
答案:ABD。原语可以按合法顺序自由组合产生新调度,A 对;组合数量随原语种类和步数指数级增长,B 对;但并非所有组合都合法,数据依赖会淘汰大量组合,C 错、D 对。就像后厨工序虽多,但“先下锅后切菜”这种颠倒顺序是被禁止的。
Q7. reorder 原语的目的是什么?
- A.调整嵌套循环的执行顺序← 正确答案
- B.把大循环切成小循环块
- C.缓存中间计算结果
- D.生成向量化计算指令
答案:A。reorder 的职责是交换嵌套循环的先后次序,选 A。B 是 split,C 是 cache_read,D 是 vectorize。调整顺序后访存更连续,缓存命中率更高——就像上菜顺序调整后,传菜员少跑冤枉路。
Q8. 在枚举调度空间时,常用的剪枝策略有哪些?
- A.检查数据依赖是否合法← 正确答案
- B.用代价模型估算耗时← 正确答案
- C.剪掉明显低效的调度← 正确答案
- D.只保留 split 全部方案
答案:ABC。剪枝手段包括数据依赖合法性检查(A)、代价模型估算(B)、砍掉明显低效分支(C)。D 只保留 split 方案过于极端,会误删 cache_read、rfactor 等可能更优的调度。剪枝的本质是在“找得全”和“来得及”之间取平衡。
Q9. 一个长度为 16 的循环,先 split(4) 再对内层 split(2),最终有几层嵌套循环?
- A.合并后只剩一层循环
- B.拆分成四个独立循环
- C.形成外中内三层循环← 正确答案
- D.保持两层循环不变
答案:C。16 次迭代 split(4) 后得到外层 4 次乘内层 4 次;内层再 split(2),内层 4 被拆成中层 2 乘内层 2,总计外层 4、中层 2、内层 2 三层循环,选 C。就像 16 人先分成 4 组,再每组分成 2 个小组。
Q10. 一个算子经 3 步调度,每步可选参数数分别是 4、3、2,但第 2 步有一半组合被依赖检查剪掉,最终调度空间大小是多少?
- A.所有组合数相乘等于 24
- B.与二步选择数均无关
- C.三种选择数相加得九
- D.剪掉一半后剩十二种← 正确答案
答案:D。三步选择数 4、3、2,先按乘法原理得到总数 4×3×2=24;由于第 2 步有一半被依赖检查剪掉,剩余有效调度为 12 种,选 D。A 只是剪枝前的粗算值,C 误用加法原理,B 否定乘法原理。可见枚举时必须同时考虑合法性过滤。