依赖模型¶
任务是什么、运行时怎么定出任务之间的先后,以及那条让所有人都吃过一次亏的性质。
前置:类型 § 参数方向。
Concept¶
一个任务就是一次 kernel 派发。运行时并不逐条执行你的编排函数 —— 它建起一张任务依赖图,谁就绪就跑谁。
这张图是推导出来的,不是写出来的。运行时维护一张 OverlapMap:对每个任务,它记下这个任务触碰了哪些缓冲区、以及以什么方式触碰 —— 依据就是每个参数声明的方向。两个任务触碰同一块缓冲区就产生一条边,方向由它们各自的方向决定:读者等在它之前的写者后面,写者等在它之前的读者和写者后面。两个任务毫无交集则没有边,可以同时跑。
由此得到本章存在的理由:
语句顺序什么都不表达。 一前一后写下的两次派发,只有当某样东西这么说了才是有序的 —— 一次运行时看得见的缓冲区重叠,或者一条你声明的边。源码里的相邻不是那个"某样东西"。
Quickstart:一条谁也没声明的边¶
import pypto.language as pl
@pl.jit.incore
def twice(x: pl.Tensor[[256, 128], pl.FP32], out: pl.Out[pl.Tensor[[256, 128], pl.FP32]]):
out[:] = pl.add(x, x)
return out
@pl.jit
def two_stage(
x: pl.Tensor[[256, 128], pl.FP32],
scratch: pl.Out[pl.Tensor[[256, 128], pl.FP32]],
out: pl.Out[pl.Tensor[[256, 128], pl.FP32]],
):
scratch = twice(x, scratch) # writes scratch
out = twice(scratch, out) # reads scratch -> ordered after the write
return scratch, out
| 运行时看到的 | 结果 |
|---|---|
任务 1 把 scratch 声明为 Out |
它被记为 scratch 的生产者 |
任务 2 把同一块缓冲区声明为 In |
推出一条写后读的边 |
| 其余没有交集 | 没有别的边;互不相干的工作照旧并行 |
没有 deps=,没有作用域,除了签名上本来就有的方向之外没有任何标注。这种情形不需要本章的任何东西。
Mechanics¶
方向对定序意味着什么¶
| 方向 | 运行时的结论 |
|---|---|
| In(默认) | 读它。排在最后写它的那个任务之后 |
pl.Out[...] |
写它、不读它。排在此前的读者与写者之后 |
pl.InOut[...] |
都有。与一切触碰过它的任务之间都有序 |
这就是为什么方向写错是正确性缺陷而不是风格问题:把一个 InOut 缓冲区声明成 Out,等于告诉运行时"在本任务写它之前不需要等任何人"。见 类型 § 参数方向。
推导看不见什么¶
OverlapMap 基于缓冲区工作,所以它推出的恰好是那些本身就是缓冲区重叠的关系。两种情形落在外面:
是重叠但不是依赖。 兄弟任务写同一个张量的互不相交区域,在这张图看来就是重叠,于是被串行化。如果不相交是真的、只是无法证明(比如写偏移由数据决定),推导就会保守,你损失掉并行度。精修依赖图 讲各种退出方式。
是依赖但不是重叠。 如果任务 B 必须排在任务 A 之后,而这个理由从不体现为共享缓冲区,那就没有任何东西会推出这条边。得由你声明 —— 见 声明一条边。
自动边与显式边是叠加的¶
它们不是二选一。submit 上的 deps=[...] 是加在运行时已推导出的结果之上的:
所以 deps= 在普通 auto 作用域里就能用,作为补齐推导够不着那条边的精修工具。把推导关掉是另一个决定,而且更重 —— 那是 pl.manual_scope。
边界情况¶
致命陷阱: 如果没有任何东西表达先后,两个任务就可能重叠 —— 结果是一个偶发复现、一上调试器或一加打印就消失的竞态。语句相邻不是证据,"我跑了一次是对的"也不是。
| 症状 | 可能原因 | 修复 |
|---|---|---|
| 多次运行结果不同 | 两个必须有序的任务没有任何东西表达这个顺序 | 用 deps= 声明 —— 见 声明一条边 |
| 本该重叠的工作串行执行 | 推导看到了一次并非真实依赖的重叠 | 让该实参退出跟踪 —— 见 精修依赖图 |
| 后续任务里缓冲区是旧数据 | 生产任务没有排在消费者之前 | 先查方向;方向写错会静默地抹掉那条边 |
加一条 print 或上调试器就正确了 |
变的是时序,不是语义 | 那条缺失的边仍然缺失 —— 别当作已修复 |
See Also¶
- 运行时作用域 —— 这套推导运行在哪个边界内,以及怎么关掉它。
- 声明一条边 —— 给任务起个名字,好让后面的任务等它。
- 类型 § 参数方向 —— 推导所读的那个声明。
- AutoDeriveTaskDependencies —— 这件事的编译器侧一半。