tinygrad 张量调度:图到 LINEAR 的四步与 256 字节内存规划

作者:

在

结论:tinygrad 把张量图变成可执行的 LINEAR 调用序列,分四步:Tensor.realize() 挑出需要存储的输出 → 图转 CALL → prepare_rangeify()/get_kernel_graph() 定物化边界与内核图 → create_schedule() 按依赖排序。排序必须同时满足 RAW(生产者先于消费者)与 WAR(读旧状态者先于覆盖者),否则可变张量会读到错值。要注意两点边界:调度缓存命中只表示跳过了图准备与排序,并不代表内核已编译或设备内存已分配;内存规划按「首次/末次出现」预测生存期并以 256 字节块放进 arena,是对调度图的推演,不是对设备完成时刻的测量。

从 Tensor.realize() 到内核图:把四步串起来

入口是 Tensor.realize(),它只挑 needs_storage() 为真的输出,再经 linear_with_vars() 调用 transform_to_call()。后者收集待实现的存储、给图加 tag、把已实现输入替换为 PARAM,生成外层 SINK.call(...) 以及原图到新缓冲的映射。调度不只是创建一张临时执行列表——_apply_map_to_tensors() 会更新作用域内引用旧节点的 Tensor,使它们指向新的 UOp。

随后 lower_sink_to_linear() 只处理没有 KernelInfo 且输出已绑定的 SINK 调用;未绑定的 value call 留给前置准备阶段内联。它按 function.key 查进程内的 schedule_cache,未命中时依次执行准备、内核图生成与排序三步。下表把这一段的职责摊开,最后一列是容易被前端忽略的契约。

阶段 / 关键函数 代码行为 硬件或插件契约
prepare_rangeify()、resolve_function() 先处理多设备图、移动算子与最早的重写;未绑定输出函数可按参数 slot 内联,检查尺寸和 dtype。 动态形状、数据类型与多设备分片不是可忽略的装载细节。
run_rangeify() 建消费者关系,给各节点传播/新建 range;多消费者不兼容或归约、广播边界可触发物化。 「一个图节点 = 一个内核」不成立,实际 fusion 与中间缓冲须从生成的内核图核对。
get_kernel_graph()、split_store() 清理符号和临时物化,限制缓冲,转 STORE,把可分割存储封成带 KernelInfo 的 SINK.call。 这里只输出候选内核图;具体 ISA、GPU 源码与二进制在后续 codegen 阶段产生。
create_schedule() 从 AFTER 状态构建生产者→消费者的 RAW 与读旧状态→覆盖写入的 WAR 边,按入度排序为 LINEAR;剩余边构成环时抛错。 可变 Tensor 的旧值和新值有顺序语义;只按数据流拓扑排序会漏掉覆盖风险。
resolve_linear_call() 把参数 slot 绑定到外层缓冲/符号变量;嵌套 LINEAR 的位置参数覆盖外层同名绑定,未传的新变量可继承外层。 多层预编译函数与插件调用必须保持参数作用域及 dtype/尺寸一致。
pm_copy_from_store、assert_all_same_devices() 特定跨设备纯复制模式可转 COPY;普通 SINK 若含多个设备的缓冲会报错。 算法插件不能隐式假定计算内核可直接跨设备读写;跨设备传输是独立调度节点。

依赖排序:RAW 与 WAR 缺一不可

create_schedule() 从每个 AFTER 提取待执行内核及其前置状态:读某状态的内核依赖写出该状态的内核,这是 RAW;而后续覆盖同一旧状态的写入还必须排在既有读者之后,这是 WAR。排序阶段生成的是 k.body.call(*buf_uops) 调用,不是立即执行。如果依赖图有环,函数抛 RuntimeError,不会返回一个「尽力而为」的顺序。

之后 create_linear_with_vars() 解析嵌套调用与 bound Variables,仅保留调度实际用到的变量值,并对同名值冲突报错。这里有一个分叉:处于 JIT 捕获且 CAPTURING 开启时,它把 LINEAR 加入捕获器并返回空列表;只有非捕获路径才会调用内存规划。也就是说,「用 JIT 跑一遍」和「真正规划内存并执行」不是同一条路径。

COPY 与内存规划:lane 分离与 256 字节 arena

memory_plan_rewrite() 按 LINEAR 中内部缓冲的首次/末次出现估算生存期,以 256 字节块把可规划缓冲放进同设备、同 lane 的 arena。它排除传入的 held buffers 与 DISK/CL/WEBGPU 缓冲,把 COPY 缓冲和计算缓冲放在不同 lane,并为 COPY 延长释放时点,以减少因复用引入的依赖。

这一层的措辞很重要:它按调度图预测生存期,不是对设备驱动的实际完成时刻做测量。因此 arena 复用依赖调度顺序与同步是否被正确尊重——不要把内部视图当成独立分配,也不要把它当成可以长期持有的外部指针。

边界 源码语义 误读风险
缓存命中 SCACHE 控制是否读写 schedule_cache,命中即跳过图准备与调度 命中不代表内核已编译或设备内存已分配
图校验 SPEC 只在未命中分支验证 Tensor 图 缓存命中本身不重复该检查
COPY 延长 为 COPY 缓冲延长释放时点以降低复用依赖 这是减少依赖的手段,不等于已同步设备完成
可变 Tensor 旧值/新值有顺序语义,WAR 边防止读者被覆盖 只按数据流拓扑排序会漏掉覆盖风险

多设备不是「把同一张量复制到所有设备」

alu_multi() 尝试在分片上逐设备计算;copy_multi() 可把分片拼接回单设备,或经 allreduce 汇聚到多设备;handle_allreduce() 按设备数、元素数、具体形状以及 RING/ALL2ALL 配置,选择朴素、ring 或 all-to-all 路径。这带来一个明确的插件契约:新硬件若只实现单设备复制,并不能宣称支持这些分片与聚合语义。跨设备传输是独立调度节点,普通计算内核不能假定可以隐式跨设备读写。

验证边界:缓存用例通过,视图与多张量用例未跑

本机执行 python3 -m unittest test.null.test_schedule_cache -v,两个缓存/变量绑定用例通过。但尝试导入 test.null.test_schedule.TestContiguous 时,依赖模块因环境缺少 NumPy 而失败,视图用例并未运行;test/null/test_multitensor.py 也未运行。因此还须以实际模型产物比对调度内核数、COPY 次数、中间峰值内存与跨设备同步。硬件插件另需验证缓冲别名、重连后视图失效、设备完成信号与最坏时延——调度成功不等于车上可用。

常见问题

调度缓存命中,是否说明内核已经编译好了?

不是。缓存命中只表示跳过了图准备与调度两步,源文档明确它并不意味后续内核已编译或设备内存已分配。此外 SPEC 只在未命中分支验证 Tensor 图,缓存命中本身不重复该检查。

为什么调度要做 WAR 依赖,而不只做数据流的 RAW?

因为可变 Tensor 的旧值和新值有顺序语义。create_schedule() 除了 RAW,还让后续覆盖同一旧状态的写入排在既有读者之后;只按数据流拓扑排序会漏掉覆盖风险。依赖图有环时函数直接抛 RuntimeError,不返回「尽力而为」的顺序。

内存规划里的 256 字节块是实际分配单位吗?

它是内存规划阶段用于把可规划缓冲放进同设备、同 lane arena 的起始粒度。规划按 LINEAR 中内部缓冲的首次/末次出现估算生存期,是对调度图的预测,不是对设备驱动实际完成时刻的测量,因此 arena 复用依赖调度顺序与同步是否被正确尊重。

硬件只实现单设备复制,能声称支持多设备分片吗?

不能。alu_multi() 做分片逐设备计算、copy_multi() 做拼接或 allreduce、handle_allreduce() 按设备数与形状选朴素/ring/all-to-all 路径。源文档明确:新硬件若只实现单设备复制,并不能宣称支持这些分片和聚合语义。

延伸阅读

参考资料

  • 项目 02 —— openpilot-research/TINYGRAD_TENSOR_SCHEDULING_MEMORY_DEEP_DIVE.md(取用了张量图到 LINEAR 的四步、RAW/WAR 依赖、COPY 与 256 字节内存规划、多设备边界与验证结果)。

说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

更新记录

首次发布 2026-10-11 最近核对:2026-10-11

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注