# 前言

本篇文章介绍多级流水线 Tile 调度算法

作为初学者,错误在所难免,还望不吝赐教。

# 深度优先调度生成指令

请允许叫他深度优先调度。我也不知道这种调度方式到底有没有一个官方的、或者大家默认的名称。但是从调度逻辑上,我把他叫做 ——“深度优先调度”。

示意图1

如图所示。我们知道,一个算子的输出 Tensor Tile 切分成多少份,就会生成多少条指令。假设我们把上面的卷积算子的输出 Tensor 切了 4 份,那么就会有四条指令等待排序。左边所有算子的输出 Tensor 都被切成 4 份,那么就有 4*7 条指令等待排序,或者说调度。
怎么进行调度呢?现在的一个基准就是:深度优先调度。即 “只要数据足够,优先执行更深层的指令”。
算法的输入是一个图结构,此刻我们有一个指针。为了形象一点, 我们称它为一只蜘蛛。蜘蛛跳到某一个节点,需要考虑两件事情:

  1. 尝试生成一条指令(也许是第一条指令,也许是第三条指令,总之是该算子还没有生成的下一条指令),如果数据足够的话,则生成一条指令 inst ,然后跳到下一个节点(后续节点)

2. 数据不够的话,跳向前序节点。

所以蜘蛛只有两个动作:向下 和 回溯。

示意图2

再进一步,如果蜘蛛恰好正位于 Add 这种有多个前序节点的节点的时候,想要往前跳,就得多考虑一点。需要判断是缺左边分支的数据,还是缺右边分支的数据,然后再决定跳向哪个分支。
尽管也存在某个节点有多个后续节点的情况,但向后跳就比较自由一点。我们可以提前生成该图结构的拓扑排序,向后跳的时候,跳向当前节点的下一个拓扑排序节点。
如果我们是为完整的图结构进行指令调度,大部分情况下,蜘蛛生成拓扑排序最后一个节点的最后一条指令的时候,所有的指令都已经调度完成。
之后再根据指令之间的数据依赖关系,添加同步控制指令 SYNC_Inst ,以实现不同硬件的指令并行。比如 DMAEngine、MAC、Vector 硬件之间的并行。

# 优化的深度优先调度

在很多情况下,单纯的深度优先调度 ——“只要数据足够,优先执行更深层的指令”,并不能释放足够的并行性。

示意图3

如上图所示,图中是个卷积和池化交错的例子,下方左边是单纯的深度优先调度,其中 “Ci” 代表 “Conv i”,“Pi” 代表 “Pool i”,“Ti” 代表第 i 个 Tile。可以看到单纯的深度优先调度,很可能会导致连续这几个 Tile 都需要等待数据,并行度不高。
右图则选择适时地生成同一算子的两条指令,这种情况下并行性就要好得多。
所以此刻蜘蛛跳到某一个节点,需要考虑三件事情:

  1. 尝试生成一条指令(也许是第一条指令,也许是第三条指令,总之是该算子还没有生成的下一条指令),如果数据足够的话,则生成一条指令 inst ,然后跳到下一个节点(后续节点)

2. 数据不够的话,跳向前序节点。

3. 适时地跳向当前节点(继续生成本节点的下一条指令)。

所以蜘蛛有三个动作:向下、回溯 和 原地。

# 部分算子的指令调度

前面写到 “如果我们是为完整的图结构进行指令调度,大部分情况下”,最后一个节点的指令全部生成完成后,不会有指令缺失(某一层缺失指令)。因为大部分模型的输出只有一个,或者就算是多个,也是由同一个节点输出的(比如做目标检测的网络。)
但是有例外。
1. 特殊的模型。比如现在常见的多任务学习(Multi-task Learning, MTL)模型,会有多个输出,且不同的输出是由不同的节点产生。
2. 专门对部分算子进行指令调度。比如编译器在搜索最优 Layer Group 的时候,只对网络模型的部分算子进行 Tile 切分和调度。
实际上,这些多个输出节点的情况还是挺常见的。
这些情况下,深度优先调度会较快地走到最后一个 DMA Out 指令,而靠前的 DMA Out 指令与其没有数据依赖关系,就会产生指令缺失。

示意图4

例如,假设以图中红圈内的节点作为 Layer Group 进行单独的 指令 调度,那么需要调度的节点会变成右侧所示,有两个 Dma Out 节点。
这时候右侧分支 Dma Out 2 对左侧分支没有数据依赖,当蜘蛛跳到右侧分支后,无法再返回左侧分支,导致左侧分支的指令缺失。
“监控所有 Dma Out 节点,当靠前的 Dma Out 节点仍缺失指令的时候,适时地跳转到前面的 Dma Out 节点”,可以是一种方法。
不过假设我们需要对更细粒度的算子进行调度,各个分支并非以 Dma Out 节点结束的时候,就要进一步监控 “前序所有缺失 consumer 节点” 的算子了。方法差不多。

所以此刻蜘蛛跳到某一个节点,需要考虑四件事情:

  1. 尝试生成一条指令(也许是第一条指令,也许是第三条指令,总之是该算子还没有生成的下一条指令),如果数据足够的话,则生成一条指令 inst ,然后跳到下一个节点(后续节点)

2. 数据不够的话,跳向前序节点。

3. 适时地跳向当前节点(继续生成本节点的下一条指令)。

4. 是否需要跳向更前方的其他指令。

所以蜘蛛有四个动作:向下、回溯、原地 和 跳转。

# 基于候选的 Tile 调度策略

上述算法是一种可行的调度算法,但是其调度规则是已经确定好的,也就是优化后的深度优先调度。
1. 如果希望在其中增加一些经验性的调度规则,就显得难以胜任了。
2. 如果把内存管理考虑在内,如果 SRAM 空间不足了,很难灵活地调整指令调度顺序。
3. 无法确定指令调度的并行度足够高。
总之该算法的拓展性不够强,不够灵活,写起来也挺复杂。我们需要一种能够统筹管理 Tile 调度、内存管理、模拟仿真的调度算法,能够选择最优的指令调度,并能够在 SRAM 不足的时候,通过调整指令调度顺序和 Tensor 换出(Dma_out 和 Dma_in)的方式,使 SRAM 空间满足运行要求。

# 减少空间占用的工具 —— 换出和延迟

先看看指令影响 SRAM 空间占用的情况。

简单网络

假设有上图这样一个简单的网络,Tile 切分策略是切 H 维度切三份。则下图中 “Conv 1-1” 代表 Conv1 的第一个切分对应的指令。其他同理。

假设的调度情况

横轴是周期,上方纵轴是不同的设备,有 DMA、MAC 等设备。下方纵轴是 SRAM 空间。上方展示了指令在不同设备上的排布调度,下方是对应的内存占用。假设在排到 “Conv 2-1” “Pool 1-2” 的时候,没有足够的空间开辟内存,我们有哪些工具可以处理这种情况呢?

调度减少内存占用

上图展示了减少 SRAM 占用的方法,即换出(SPILL)操作和推迟操作。即将 “Pool1” 算子占用的空间换出到主机内存中(图中给 Pool1 算子添加 DMA Out 节点),“Pool1” 之后的节点在对应的数据重新加载到 SRAM 之前,不会进行调度;此外,还可以通过延迟某些指令的周期,来复用释放掉的内存,如图中延迟 “Conv 1-3” 的执行,使其能够复用 “Conv 1-2” 所占用的空间。

# 如何实现该算法

第一步,根据 Tile 切分策略,提前生成所有指令,并统计 Tensor 引用计数、指令数据依赖等。提前完成的事情越多,越能减轻后续步骤的压力。

基于候选的Tile调度

第二步,初始化,将图结构中 Load 节点(可能有多个)中的首个 DMA In 指令添加到候选队列。
第三步,更新候选队列中的指令状态,即假设将候选队列中的指令 Inst 添加到执行队列,那么它的运行周期、空间分配信息是怎样的。
第四步,制定规则,选择一条指令从候选队列移动到执行队列和设备队列,并更新设备队列状态。
第五步,当前情况下,将所有满足数据依赖的指令添加到候选队列。
第六步,回到第三部,循环执行,直到所有指令添加到执行队列。

# 按照什么样的规则选择候选队列中的指令

排序优先级

这算是经验性的规则。可以将候选队列中的所有指令分成四个优先级,分别是 dma_out 指令、OP 指令、dm_in 指令、not_ready 指令。not_ready 指令指的是候选指令中,无法分配足够 SRAM 空间以满足执行的指令。
这四类指令的优先级依次降低。
在同类指令里面,优先选择模拟仿真中启动时间更早的指令。那如果两条指令启动时间也相同呢?选择拓扑图更深的指令。
若是前三个优先级都没有指令,只有 not_ready 指令,那意味着空间已满,需要回退状态,增加换出操作了。

# 后记

我也不清楚以上内容有多少错误,有哪些不足,不过这些东西总会在前行的道路上逐渐出现,并得以解决。

本博客目前以及可预期的将来都不会支持评论功能。各位大侠如若有指教和问题,可以在我的 github 项目 或随便一个项目下提出 issue,并指明哪一篇博客,看到一定及时回复!

Edited on

Give me a cup of [coffee]~( ̄▽ ̄)~*

XianMu WeChat Pay

WeChat Pay

XianMu Alipay

Alipay