数据截至 (上游 commit 118e812b316f)
03 · 调度器:从张量图到 kernel 序列
这一章讲什么: tinygrad 的「大脑」。一张任意大的 UOp 图,怎么决定哪些中间结果要落地成 buffer(切 kernel 的边界),怎么把剩下的全部融进 kernel,又怎么排出执行顺序。全程还是图重写,没有一行命令式调度代码。
1. 它要解决的小问题
(a @ b).sum() + c 这张图理论上可以融成一个 kernel,但 (a @ b) 的结果如果后面还要单独用一次,就必须先落地。再放大:一个 transformer 的一次前向是上千个节点的一张图。
调度器要回答三个问题:
- 哪里切:哪些中间值必须写成 buffer(= 一个新 kernel 的输出),哪些可以融进下游;
- 怎么排:切出来的 kernel 之间有读写依赖,执行顺序是什么;
- 怎么省内存:中间 buffer 的生命期已知,能不能复用显存。
2. 全流程一张图
张量图(若干 Tensor 的 SINK)
│ ① callify:分配输出 buffer,输入换 PARAM
▼
一个 CALL(PARAM0, PARAM1, …) ← 一张「可缓存的函数图」
│ ② rangeify:movement op 消成 RANGE 算术;按消费者关系做融合决策
▼
rangeified 图(STAGE = 候选 buffer,REDUCE 带 RANGE)
│ ③ 化简 + STAGE→STORE
▼
若干 STORE/END 子树
│ ④ split_store:每棵 → 一个 kernel CALL(SINK + KernelInfo)
│ ⑤ create_schedule:AFTER 链上建 RAW/WAR 依赖,拓扑排序
▼
LINEAR(排好序的 kernel CALL 列表)+ ⑥ 内存规划(arena 子分配)
对应入口 create_linear_with_vars(tinygrad/schedule/__init__.py:181-210),它把 ②~⑥ 串成三个 graph_rewrite 加一个内存规划。下面逐段拆。
3. 第①步:callify——把「图」变成「一次函数调用」
transform_to_call(tinygrad/tensor.py:219-239)在 Tensor.realize 之前跑,做三件事:
- 打编号:给需要落地的节点打
tag,编出一张uop_list(add_tags,tinygrad/tensor.py:44-51); - 分配输出 buffer:每个被 tag 的节点换成「新 buffer + STORE + AFTER」(
replace_contig_with_store_after,tinygrad/tensor.py:53-61); - 输入参数化:把输入 BUFFER/bound Variable 换成带槽位的
PARAM(pm_replace_buf,tinygrad/tensor.py:208-217)。
产物是一个 CALL(body, PARAM0, PARAM1, ...) 加一张 buffer_map(原节点 → 新 buffer)。之后同一张图再来一次,内容哈希相同,直接命中调度缓存(schedule_cache,tinygrad/schedule/__init__.py:118)——参数化让缓存键与具体 buffer 无关。
4. 第②步:rangeify——融合决策的核心
这是整套调度器的心脏,思想一句话:不给张量选「计算位置」,而是给每个维度生成一个循环变量(RANGE),让 movement op 全部消成对这些变量的算术。
4.1 movement op 的「消失」
apply_movement_op(tinygrad/schedule/indexing.py:169-185)定义了六种 movement op 对 RANGE 元组做什么:
| op | 对 RANGE 的变换 |
|---|---|
SHRINK | 加偏移:a → a+off |
PERMUTE | 重排元组 |
FLIP | 反向:a → (s-1)-a |
EXPAND | 丢弃前几个(广播维不需要变量) |
PAD | 变成带 valid 条件的表达式 |
RESHAPE | 展平成一维再按新形状取模/整除拆回(_apply_reshape,tinygrad/schedule/indexing.py:152-165) |
重点看最后一行:reshape 合并这个传统编译器里的大麻烦,在这里被变成「一维化 + 符号化简」。这就是 tinygrad 不再需要旧式 "ShapeTracker/View 栈" 的原因。
4.2 哪里必须落地:realize map
先跑 pm_generate_realize_map(tinygrad/schedule/indexing.py:45-54)标记「非落地不可」的点:
CONTIGUOUS和用户STORE(assign):语义要求;MSTACK/MSELECT(多设备)的输入;STORE的源里若读到目标自身(WAR 冒险),源要先落地(realize_store_after_src,tinygrad/schedule/indexing.py:34-36)。
4.3 融合决策:按消费者关系配 RANGE
run_rangeify(tinygrad/schedule/indexing.py:188-280)先建 consumer map,再逆序遍历图,给每个节点配输出 RANGE。注释里把规则写得明明白白(tinygrad/schedule/indexing.py:223-227):
- 节点在 realize map 里 → 造新 RANGE(它是 kernel 输出,维度独立);
- 只有一个消费者 → 继承消费者的 RANGE(融进下游,免费);
- 多个消费者且大家的 RANGE 一致 → 合并 valid,继续融;
- 多个消费者且 RANGE 对不上 → 对不上的轴部分 realize(
_realize_axis,tinygrad/schedule/indexing.py:252-266)。
另外 REDUCE 还有一条特殊规则:它的「被消掉的轴」直接变成 REDUCE 自己的 RANGE(convert_reduce_to_reduce_with_ranges,tinygrad/schedule/indexing.py:113-119),PAD/STACK 则分别变成 WHERE 条件(tinygrad/schedule/indexing.py:107-129)。
走完这一步,图里再也没有 movement op——只剩算术、LOAD/STORE 风格的 INDEX、REDUCE 和 RANGE。「融合」不是被显式执行的,而是「没被选去落地」的自然结果。
4.4 原理演示
# 示意,非源码 —— (x.reshape(6)*y).sum() 经 rangeify 后的样子
# 输入:x:(2,3), y:(6,)
# reshape 不产生循环,只是换坐标;只有一个消费者,全程同一组 RANGE
for r0 in range(6): # 唯一的新 RANGE,长在 sum 的输出上
acc += x_flat[r0] * y[r0] # x.reshape(6) 被消成「按 r0 一维读 x」
真实图里 x.reshape(6) 会变成 x[(r0//3, r0%3)] 这样的索引算术,再被 symbolic 化简。