跳转至

依赖模型

任务是什么、运行时怎么定出任务之间的先后,以及那条让所有人都吃过一次亏的性质。

前置类型 § 参数方向

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=

所以 deps= 在普通 auto 作用域里就能用,作为补齐推导够不着那条边的精修工具。把推导关掉是另一个决定,而且更重 —— 那是 pl.manual_scope

边界情况

致命陷阱: 如果没有任何东西表达先后,两个任务就可能重叠 —— 结果是一个偶发复现、一上调试器或一加打印就消失的竞态。语句相邻不是证据,"我跑了一次是对的"也不是。

症状 可能原因 修复
多次运行结果不同 两个必须有序的任务没有任何东西表达这个顺序 deps= 声明 —— 见 声明一条边
本该重叠的工作串行执行 推导看到了一次并非真实依赖的重叠 让该实参退出跟踪 —— 见 精修依赖图
后续任务里缓冲区是旧数据 生产任务没有排在消费者之前 先查方向;方向写错会静默地抹掉那条边
加一条 print 或上调试器就正确了 变的是时序,不是语义 那条缺失的边仍然缺失 —— 别当作已修复

See Also