LLVM笔记(5) - SMS

1. SMS介绍
  SMS(Swing Modulo Scheduling, 摇摆模调度)是一个基于循环的与架构无关的SWP(software pipelining)指令调度框架, 其目的是通过将当前迭代的指令与上一迭代同时发射来提升并行度. 考虑以下伪代码:

1 BB1:
2   A0 = A1  (1)
3   A2 = A1  (2)
4   A3 = A2  (3)
5   b BB1 if cond


  不考虑跳转指令, 该循环需要独立发射三条指令(指令之间相互依赖). 但如果将其改变为以下方式:

1   A0 = A1
2 BB1:
3   A2 = A1
4   A3 = A2; A0 = A1;
5   b BB1 if cond
6   A2 = A1
7   A3 = A2


  指令1与指令3间无直接依赖, 因此可以将本次迭代的指令3与下次迭代的指令1放在同一时刻发射, 提高并行度.

  关于SMS原理的更多了解, 参见include/llvm/CodeGen/MachinePipeliner.h中涉及的三篇论文.
  1. "Swing Modulo Scheduling: A Lifetime-Sensitive Approach"
  2."Lifetime-Sensitive Modulo Scheduling in a Production Environment"
  3. "An Implementation of Swing Modulo Scheduling With Extensions for Superblocks"

2. SMS实现分析
  SMS算法由三部分组成. 首先, 计算出最小初始间隔(MII, minimal initiation interval). 第二步建立dependence graph, 计算每条指令的相关信息(ASAP ALAP MOV Height Depth ...). 最后使用论文中提及的方式为节点排序.

3. 常见概念
  MII(minimal initiation interval)指完成循环所需的最小间隔, 它等于ResMII与RecMII两者的较大值, MII是理想的调度结果. ResMII(Resource MII)是根据一次循环所需的功能单元(FU, function unit)去除以机器所有功能单元的结果, 即如果一个循环包含4条指令而芯片只有两个运算单元则(不考虑指令间依赖)最少需要2 cycle才能完成一次循环. 更进一步, 如果循环中包含多类FU的使用(且每类FU之间相互无法替换), 则ResMII是分别对每一类FU计算ResMII后的最大值. RecMII(Recurrence MII)是循环中环路完成一次迭代所需的最小间隔, 如果循环存在多条数据链路则分别计算后取其最大值.
  Prolog / Epilog分别指实现SWP后被提前到循环之前/放到循环之后执行的代码块. Kernel指实现SWP后的循环代码块.
  Dependence Graph指指令间存在的依赖关系, 具体分为对寄存器的依赖与对内存的依赖. 对寄存器的依赖又分三种: 先写后读(RAW, read after write, 数据依赖, 又称真依赖)在LLVM中用SDep::Data表示, 先读后写(WAR, write after read, 反依赖)在LLVM中用SDep::Anti表示, 先写后写(WAW, write after write, 输出依赖)在LLVM中用SDep::Output表示. 对同一地址的读写也会产生依赖, 在LLVM中统一使用SDep::Order表示(不论是laod-store还是store-load还是store-store), 另外特殊指令带有side-effect属性时也使用顺序依赖表示. 顺序依赖又分为Barrier, AliasMem, Artificial, Weak等多个子类型, 其具体定义见include/llvm/CodeGen/ScheduleDAG.h.

4. 代码分析
  MachinePipeliner类的入口为MachinePipeliner::swingModuloScheduler(), 函数首先检查循环是否仅包含一个BB(即没有change of flow). SwingSchedulerDAG是ScheduleDAGInstrs的子类, 父类的startBlock()/enterRegion()/exitRegion()/finishBlock()都是virtual的, 用于特定调度器的initial/clean up. SwingSchedulerDAG::schedule()(defined in lib/CodeGen/MachinePipeliner.cpp)实现swing modulo调度算法.
  4.1. 建立dependence graph
  为建立依赖图首先需要获取alias分析结果(否则无法分析order dependence), 再调用公共框架接口ScheduleDAGInstrs::buildSchedGraph()(defined in lib/CodeGen/ScheduleDAGInstrs.cpp)建立依赖图(关于ScheduleDAGInstrs类后文分析). 注意公共框架返回的依赖图是基于顺序执行的代码, 由于SWP的特殊性我们还要加上跨迭代的依赖(loop carried dependence, 即本次迭代的指令与下次迭代间的依赖).
  另外当前SMS是在phi elimination之前实现的(基于SSA模式), 而公共的框架用于post RA后的调度分析, 因此我们还需为phi node添加依赖. SwingSchedulerDAG::updatePhiDependences()(defined in lib/CodeGen/MachinePipeliner.cpp)会为phi node添加data dep(如果存在指令引用该phi), 并为phi node添加anti dep(在下次迭代的定义了phi的loop value的指令与本次迭代的phi指令间存在先读后写的关系).
  对于跨迭代的内存访问间依赖交给SwingSchedulerDAG::addLoopCarriedDependences()(defined in lib/CodeGen/MachinePipeliner.cpp)处理. 该接口会遍历循环内所有load/store指令, 对每条load指令如果存在由alias关系的store则检查是否能通过load指令reach到store指令(一般是顺序load后store的情况, 不需要额外dep), 如果不能reach则为该sotre添加barrier dep. 再调用ScheduleDAGTopologicalSort::InitDAGTopologicalSorting()(defined in lib/CodeGen/ScheduleDAG.cpp)为节点排序方便之后处理, 至此依赖图基本建立完毕.
  当前框架下还有两个针对依赖图的优化, SwingSchedulerDAG::changeDependences()会尝试转换指令使用前次迭代的值来降低RecMII(主要针对pre-inc/post-inc的load/store, 缩短dep chain). SwingSchedulerDAG::postprocessDAG()用来调用一些基于特定目的实现的Mutation的hook, 当前框架实现一个名为CopyToPhiMutation, 这个没看懂干嘛的, 为什么延后COPY的调度可以获得更好的性能? 对于其它架构相关的dependence变换都可以通过添加mutation实现.

  为更具象的描述代码, 这里以一个testcase为例:

 1 [23:34:53] hansy@hansy:~$ cat test.c
 2 void test(int * __restrict in, int * __restrict out, int cnt)
 3 {
 4     int i;
 5     #pragma nounroll
 6     for (i = 0; i < cnt; i++) {
 7         *in = *out;
 8         in++;
 9         out++;
10     }
11 }
12 [23:34:59] hansy@hansy:~$ ~/llvm/llvm_build/bin/clang test.c -w -O2 -S -mllvm -debug-only=pipeliner --target=hexagon -mllvm -print-after-all 2>1.ll
13 [23:35:03] hansy@hansy:~$ 


  其中__restrict是c99引入的关键字, 在这里用以减少内存访问依赖, #pragma nounroll是llvm的pragma, 保证循环不被展开优化, 用在这里减少循环内指令数(变相减少打印), 也可以去掉以上语法糖来观察SMS的变化. 篇幅有限, 这里截取部分打印.

  loop block before SMS

448B    bb.4.for.body (address-taken):
    ; predecessors: %bb.4, %bb.6
      successors: %bb.5(0x04000000), %bb.4(0x7c000000); %bb.5(3.12%), %bb.4(96.88%)

464B      %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4
480B      %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4
496B      %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)
512B      %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)
528B      ENDLOOP0 %bb.4, implicit-def $pc, implicit-def $lc0, implicit $sa0, implicit $lc0
544B      J2_jump %bb.5, implicit-def dead $pc


  dependence graph

SU(0):   %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4
  # preds left       : 0
  # succs left       : 2
  # rdefs left       : 0
  Latency            : 0
  Depth              : 0
  Height             : 1
  Successors:
    SU(3): Data Latency=0 Reg=%2
    SU(3): Anti Latency=1
SU(1):   %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4
  # preds left       : 0
  # succs left       : 2
  # rdefs left       : 0
  Latency            : 0
  Depth              : 0
  Height             : 1
  Successors:
    SU(2): Data Latency=0 Reg=%3
    SU(2): Anti Latency=1
SU(2):   %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)
  # preds left       : 2
  # succs left       : 1
  # rdefs left       : 0
  Latency            : 1
  Depth              : 1
  Height             : 0
  Predecessors:
    SU(1): Data Latency=0 Reg=%3
    SU(1): Anti Latency=1
  Successors:
    SU(3): Data Latency=0 Reg=%15
SU(3):   %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)
  # preds left       : 3
  # succs left       : 0
  # rdefs left       : 0
  Latency            : 1
  Depth              : 1
  Height             : 0
  Predecessors:
    SU(2): Data Latency=0 Reg=%15
    SU(0): Data Latency=0 Reg=%2
    SU(0): Anti Latency=1
ExitSU:   ENDLOOP0 %bb.4, implicit-def $pc, implicit-def $lc0, implicit $sa0, implicit $lc0
  # preds left       : 0
  # succs left       : 0
  # rdefs left       : 0
  Latency            : 0
  Depth              : 0
  Height             : 0


  NodeFunction & NodeSets

    Node 0:
       ASAP = 0
       ALAP = 0
       MOV  = 0
       D    = 0
       H    = 1
       ZLD  = 0
       ZLH  = 1
    Node 1:
       ASAP = 0
       ALAP = 0
       MOV  = 0
       D    = 0
       H    = 1
       ZLD  = 0
       ZLH  = 2
    Node 2:
       ASAP = 0
       ALAP = 0
       MOV  = 0
       D    = 1
       H    = 0
       ZLD  = 1
       ZLH  = 1
    Node 3:
       ASAP = 0
       ALAP = 0
       MOV  = 0
       D    = 1
       H    = 0
       ZLD  = 2
       ZLH  = 0

  Rec NodeSet Num nodes 2 rec 1 mov 0 depth 1 col 0
   SU(0) %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4
   SU(3) %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)

  Rec NodeSet Num nodes 2 rec 1 mov 0 depth 1 col 0
   SU(1) %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4
   SU(2) %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)

  NodeSet Num nodes 2 rec 1 mov 0 depth 1 col 0
   SU(0) %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4
   SU(3) %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)

  NodeSet Num nodes 2 rec 1 mov 0 depth 1 col 0
   SU(1) %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4
   SU(2) %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)

NodeSet size 2
  Bottom up (default) 3 0
   Switching order to top down
Done with Nodeset
NodeSet size 2
  Bottom up (preds) 2 1
   Switching order to top down
Done with Nodeset


  Schedule

Try to schedule with 1
Inst (3)   %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)

    es: -2147483648 ls: 2147483647 me: 2147483647 ms: -2147483648
    insert at cycle 0   %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)
Inst (0)   %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4

    es: 0 ls: 0 me: 2147483647 ms: -2147483648
    insert at cycle 0   %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4
Inst (2)   %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)

    es: -2147483648 ls: 0 me: 2147483647 ms: -2147483648
    insert at cycle 0   %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)
Inst (1)   %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4

    es: 0 ls: 0 me: 2147483647 ms: -2147483648
    insert at cycle 0   %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4
Schedule Found? 1 (II=2)
cycle 0 (0) (0) %2:intregs = PHI %7:intregs, %bb.6, %5:intregs, %bb.4

cycle 0 (0) (1) %3:intregs = PHI %8:intregs, %bb.6, %6:intregs, %bb.4

cycle 0 (0) (2) %15:intregs, %6:intregs = L2_loadri_pi %3:intregs(tied-def 1), 4 :: (load 4 from %ir.out.addr.07, !tbaa !2)

cycle 0 (0) (3) %5:intregs = S2_storeri_pi %2:intregs(tied-def 0), 4, %15:intregs :: (store 4 into %ir.in.addr.08, !tbaa !2)


  先来看下dependence graph, 循环内总共四条Machine IR, 其中两条为PHI, 另外两条分别为load与store. 先看第一条PHI, 因为它的def-reg %2被store指令使用(先写后读)所以存在真依赖, 同理它的use-reg %5被store指令定义所以又存在反依赖. 另一条PHI与load指令间依赖关系类似. 最后load定义的%15被store使用, 所以两者间还有一个真依赖. 注意虽然PHI与store之间都存在反依赖, 但其含义不同: PHI指令的反依赖表示PHI不能调度到本迭代的store后, store的反依赖表示不能调度到下次迭代的PHI之后.

  4.2. 计算ResMII/RecMII
  ResMII比较容易计算(最简单的办法是指令数除以功能单元数, 当然代码中使用更复杂的DFA做更精确的评估), SwingSchedulerDAG::calculateResMII()基于DFA实现了ResMII的计算.
  RecMII的计算则复杂许多, 首先我们要找到所有的关键路径(critical path). SwingSchedulerDAG::findCircuits()会首先查找循环中所有的环路并将其记录到NodeSets中. 思路是首先交换反依赖(否则跨迭代环路不结束), 然后构建一个依赖矩阵, 依赖矩阵的作用是记录所有backedge, 判断回边的依据: 到phi的反依赖(必然是回边), 顺序的输出依赖链上第一个与最后一个(最后一个不能调度到下个迭代的第一个之前), 以及读写之间的顺序依赖(为什么写写之间的顺序依赖不算回边? 我的猜测是如果循环中只有写写那么相对顺序就不重要了, 只要保证最后一次写正确即可?). 最后通过索引改矩阵查找从每个节点出发回到该节点为止的一条链路.
  可以看到例子中一共找到两条环路, 第一条phi到store指令的基址, store指令基址再到phi, 第二条类似phi到load指令再到phi. 找到所有环路后即可计算RecMII, 对每条环路计算所需的latency总和取最大值即可.

  4.3. 计算NodeFunction与NodeOrder
  为了方便之后的调度还需要计算节点的一些特殊属性, 关于这些属性的解释可以参见paper, 同时SwingSchedulerDAG::computeNodeFunctions()代码注释已经很好的说明这些属性的作用. 如字面意思ASAP指该节点最早调度时机(ASAP(n) = max(ASAP(n), ASAP(p) + L(p, n) - distance(p, n) * II), 其中p为pred(n), L(p, n)为p到n的latency, distance(p, n)为迭代差, II为当前调度的II), ALAP值该节点最晚调度时机(ALAP(n) = min(ALAP(n), ALAP(s) - L(n, s) + distance(n, s) * II), 其中s为succ(n)), MOV指该节点可调度空间(等于ALAP - ASAP), D指节点深度, 即不考虑跨迭代因素也不考虑不同指令见latency差异下, 起始节点(没有pred的节点)到该节点的最大长度(等于L(p, n) = 1且distance(p, n) = 0时的ASAP), H指节点高度, 类似于D, 指的是终止节点(没有succ的节点)到该节点的最大长度. D和H越大说明该链路越长, 越容易影响调度结果, MOV越大说明该节点调度窗口越大, 越容易调度.
  在得到NodeFunction后可以借此给节点排序. 为什么要先给节点排序? 理想的调度结果希望达到两个目标: 获取最小的II同时保持寄存器生命周期最小化. 前者要求我们能顺序的对一条链路进行调度(否则如果一个节点的pred和succ优先被调度, 则该节点本身很容易失去调度窗口), 后者要求调度时尽量压缩生命周期(比如一条链路上的普通指令尽可能紧跟他的pred(选择ASAP), 而phi指令则相反, phi通常离实际的使用者较远(选择ALAP)). 因此为节点排序被分为两步, 一是给不同的链路(nodeset)设置优先级(stable_sort(), groupRemainingNodes()), 二是给同一链路内的节点排序(computeNodeOrder()). 对于第一步, 我们首先会合并相似的链路, 如果一条链路是另一条链路的子集则合并两条链路(SwingSchedulerDAG::colocateNodeSets()), 然后通过调用stable_sort()排序, NodeSet类重载了比较操作符, 按RecII, maxMOV, maxD顺序为节点排序. 第二步调用SwingSchedulerDAG::computeNodeOrder()为每条链路内节点排序, 计算方式是从most critical path开始对每条链路使用(topdown/bottomup)排序(如果已排序节点存在未排序的succ且为当前链路的子集则使用topdown, 如果已排序节点存在未排序的pred且为当前链路的子集使用bottomup, 如果已排序的节点既存在未排序的pred也存在未排序的succ则使用topdown), 依次顺序为每条链路排序.
  仍以上文例子为例, 以Rec NodeSet Num开始的打印是未排序的nodeset, 以NodeSet Num nodes开始的打印是第一步后的nodeset, 由于本例子较为简单只有两条链路且属性一致所以排序前后结果相同. 然后进行整体排序, 第一条链路默认bottomup所以先3后0, 对于第二条链路由于已排序节点中3的pred包含2, 所以2先调度且同样采用bottomup.

  4.4. 调度
  SwingSchedulerDAG::schedulePipeline()根据排序结果进行调度. 对每个节点首先计算该节点的调度窗口(earlystart / latestart), 然后尝试插入DFA, 如果插入失败则尝试扩大II重试. 注意当前插入节点的策略是ASAP(phi节点则相反, 原因上文有说明, 减少寄存器生命周期), 因此节点排序结果会影响最终的调度. 调度窗口通过SMSchedule::computeStart()计算得到当前节点最早与最晚插入时机, 其算法类似于计算ASAP / ALAP, 根据节点的pred / succ中已被调度的指令所在的cycle计算得到当前指令的ES / LS, 对于backedge则额外需要判断下跨迭代的情况. 还要注意的一点是与paper中描述的不同, loop carried dependence在代码中并未计算在ES / LS中, 而是使用另一组MS / ME来保存, 可能由于hexagon并未遇到, 然而实际上对于循环中store-load-store的情况存在问题. 如果计算的earlystart > latestart则调度失败, 一般情况是节点排序非最优. 否则调用SMSchedule::insert()尝试插入节点, 注意伪指令一般视作zerocost指令直接在计算得到最早cycle插入. 注意这里除了本cycle内是否可以插入指令外还要考虑跨stage的情况, 因为最后会将可以并行化的指令折叠起来, 因此同样需要考虑(cycle + II * stage)是否可以插入该指令. 所有节点排序完成后就得到顺序的指令调度.
  节点排序与调度策略是影响SMS结果的最重要两个模块. 除了当前LLVM框架的SMS使用的circle based node ordering外还有SCC based node ordering等其它方式. 一般当发现SMS结果不理想时就从此处入手, 通过改变node ordering及scheduling尝试优化.

  4.5. 完成调度
  在schedule完成后指令被重新排序, 但此时仍未生成pipelined loop, SMSchedule::finalizeSchedule()完成该步骤. 首先将first stage之后的指令都折叠至first stage. stage是一个容易混淆的概念, 它指的是在生成并行化循环后kernel中同时包含执行的原循环的次数(或者说是每条指令对应原循环中指令的第几次迭代). 举例对于一个6个cycle的循环, 如果II为3且first_cycle为-1且last_cycle为4, 则stage为(4 - (-1) + 1) / 3 = 2, 即并行化后循环同时执行2次原循环的指令. 由于在生成并行化循环后会产生(stage - 1)个prolog / epilog与1个kernel, 即每条指令都会产生(stage - 1)个拷贝(在prolog / epilog中). 对于循环中的每条指令在kernel中所处的第几次迭代可以由(sched_cycle - first_cycle) / II计算得到. 比如对调度在2的指令其stage为(2 - (-1)) / 3 = 1, 即kernel中该指令所处的迭代(相对于第一条调度的指令)为第一次迭代, epilog中还存在1次该指令的拷贝. 类似对于调度在0的指令, 其stage为0(和第一条调度的指令处于同一stage), epilog中不包含该指令的拷贝. 将指令折叠至first stage后ScheduledInstrs即包含了并行化循环的指令, 但此时也丢失了def-use关系(schedule结果仍是顺序的, 但折叠后会将phi节点放置到最前来保证SSA form, 此时phi use就不确定了). 最后对ScheduledInstrs中指令做重排序, 重排序的原因是为了1. 将phi节点提至最前(调度时phi节点是可以插在中间的), 2. 缩减寄存器生命周期. 比如copy / reg_seq等伪指令在之前的调度过程中(为了尽可能减少II)被放在earlystart, 然而实际use可能在之后很远, 将其调度到真实使用之前可以降低寄存器压力. SMSchedule::orderDependence()会遍历已插入的指令判断指令与当前排序指令是否存在依赖并尽可能将相互依赖的指令放在一起.

  4.6. 生成并行化循环
  生成并行化循环包含以下几步: 重新计算寄存器(对应不同迭代), 重新生成phi节点, 生成prolog与epilog. 这些任务由SwingSchedulerDAG::generatePipelinedLoop()完成. 主要接口updateInstruction()根据VRMap与stage为寄存器重命名, generateExistingPhis()替换之前的phi节点, generatePhis生成新的phi节点. 这块是诟病最多的代码, 像reduceLoopCount()这些为Hexagon实现的hack, 最近社区还有讨论重构这块代码(http://lists.llvm.org/pipermail/llvm-dev/2019-July/134002.html).

5. 优化&问题定位
  SMS是比较复杂的后端优化模块, 问题定位思路主要有:
  1. 首先打印日志, 根据日志依次排除问题. 由于SMS每个阶段强依赖前一阶段的结果, 因此怀疑某一阶段的问题就不用再排查之后的阶段.
  2. 几个主要流程点: 1. 依赖图是否正确(是否存在错误或冗余的依赖) 2. MII计算是否正确(尝试手算) 3. NodeFunction与NodeOrder是否有误(通常调度失败是由于bad ordering) 4. schedule与reorder是否有误(reorder有误常常影响最后的phi rewriting和register renaming) 5. prolog与epilog生成是否有误, phi generating是否有误.
  3. 对于性能调优, 最常见的是bad ordering, 可以采取多种ordering算法择优. 其次是依赖太强, 尝试修改依赖, 简短critical path.

posted @ 2019-07-22 00:01  Five100Miles  阅读(1751)  评论(0编辑  收藏  举报