优化屏障(Optimization barrier)第三讲

来源:互联网 发布:淘宝新用户有什么优惠 编辑:程序博客网 时间:2024/04/29 07:38

转载: http://www.goldendoc.org/2012/01/optimization-barrier3/

===================================================================================================================================

1. 大胆猜想

上一篇提到了”memory”这个关键字,为什么它能够防止-fschedule-insns优化时做代码重排呢?为什么非得给它取名为屏障呢?
其实我们可以先考究一下asm语句的用法以及Optimization barrier的原型:

1
2
3
4
5
6
7
8
// template
asm ( assembler template
      : output operands                  /* optional */
      : input operands                   /* optional */
      : list of clobbered registers      /* optional */
);
// Optimization barrier
__asm__ __volatile__ (""::: "memory");

“memory”属于”list of clobbered registers”,”clobber”译为”冲撞、冲突”的意思,前文解释RTL的时候也附带说明了一点,这里
又把它拎出来溜溜:
(clobber x)

表示一个不可预期的存储或者可能的存储,将不可描述的值存储到x,其必须为一个reg,scratch, parallel 或者 mem表达式。
可以用在字符串指令中,将标准的值存储到特定的硬件寄存器中。不需要去描述被存储的值,只用来告诉编译器寄存器被修改了,以免其尝试在字符串指令中保持数据。如果x为(mem:BLK (const_int 0))或者(mem:BLK (scratch)),则意味着所有的内存位置必须假设被破坏

“memory”就对应于RTL中的”(clobber (mem:BLK (scratch) [0 A8]))”,意思很明显,就是想告知编译器:“内存被我修改了”。
那这个究竟有什么用呢?其实不管是编译器重排指令,还是CPU乱序处理,都要遵守一个最基本的原则:“不能违反指令之间的依赖”,即有依赖的指令之间是不能够重排或乱序的。
还是从自始至终的例子来说明:

1
2
3
4
5
6
7
8
volatileint ready;
intmessage[100];
 
voidcmb (inti) {
    message[i/10] = 42;
    __asm__ __volatile__ (""::: "memory");
    ready = 1;
}

其中,message和ready都是全局变量,所以,它肯定是依赖内存的, __asm__ __volatile__ (“” ::: “memory”)告知编译器,内存被修改了,而ready是依赖内存的,那编译器自然不能将ready = 1越过Optimization barrier了,因为越过的话,ready=1就不能感知到内存的变动了;
同理,message[i/10] = 42也不能移动到Optimization barrier之后,因为原本它不需要感知内存的变动,现在却需要,这也是相违背的;
所以,Optimization barrier像立在代码中的一堵墙,前面的代码不能重排到后面,后面的代码不能重排到前面,当然,受约束的前提是这些代码都是依赖内存的。
这样一来,这个屏障一说还算精确,不过请注意:“前面的代码不能越过屏障到后面,后面的代码不能越过屏障到前面”这只是一个猜测,还需要我们去验证。

2. 小心求证

其实能理解第一部分,应该可以说服一部分人,不过求学的心态就是“刨根问底”,所以还是应该花些功夫去验证上面的猜测。
我们不妨从“内存依赖”着手,看源码中能否找到什么线索(太懒了,没有自己弄debug版的gcc,不然就可以gdb它了):
从sched-deps.c中,我们发现了“内存依赖”的影子,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Process an insn's memory dependencies.  There are four kinds of
   dependencies:
 
   (0) read dependence: read follows read
   (1) true dependence: read follows write
   (2) output dependence: write follows write
   (3) anti dependence: write follows read
 
   We are careful to build only dependencies which actually exist, and
   use transitivity to avoid building too many links.  */
 
/* Add an INSN and MEM reference pair to a pending INSN_LIST and MEM_LIST.
   The MEM is a memory reference contained within INSN, which we are saving
   so that we can do memory aliasing on it.  */
 
staticvoid
add_insn_mem_dependence (structdeps_desc *deps, boolread_p,
             rtx insn, rtx mem)

很明显,该函数用于添加内存依赖,我们可以看看,在哪些场景下有调用它:
它的调用场景只出现在相同文件sched-deps.c中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* Analyze a single SET, CLOBBER, PRE_DEC, POST_DEC, PRE_INC or POST_INC
   rtx, X, creating all dependencies generated by the write to the
   destination of X, and reads of everything mentioned.  */
staticvoid
sched_analyze_1 (structdeps_desc *deps, rtx x, rtx insn)
{
  ...
  elseif (MEM_P (dest))
  {
       ...
       if(!deps->readonly)
           add_insn_mem_dependence (deps, false, insn, dest);
   }
   sched_analyze_2 (deps, XEXP (dest, 0), insn);
   ...
}
/* Analyze the uses of memory and registers in rtx X in INSN.  */
staticvoid
sched_analyze_2 (structdeps_desc *deps, rtx x, rtx insn)
{
    ...
    caseMEM:
    {
       ...
       if(!deps->readonly)
             add_insn_mem_dependence (deps, true, insn, x);
    sched_analyze_2 (deps, XEXP (x, 0), insn);
       ...
    }
    ...
}

全部代码不需要我们看懂,至少从摘选出来的代码中可以看得明白:除非对应内存是只读的,否则任何访存指令都会添加内存依赖
所以这就应证了例子中的message和ready都是依赖内存的

先来看一下gcc是如何生成asm语句对应的RTL指令吧(代码来自stmt.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Generate RTL for an asm statement with arguments.
   STRING is the instruction template.
   OUTPUTS is a list of output arguments (lvalues); INPUTS a list of inputs.
   Each output or input has an expression in the TREE_VALUE and
   a tree list in TREE_PURPOSE which in turn contains a constraint
   name in TREE_VALUE (or NULL_TREE) and a constraint string
   in TREE_PURPOSE.
   CLOBBERS is a list of STRING_CST nodes each naming a hard register
   that is clobbered by this insn.
 
   Not all kinds of lvalue that may appear in OUTPUTS can be stored directly.
   Some elements of OUTPUTS may be replaced with trees representing temporary
   values.  The caller should copy those temporary values to the originally
   specified lvalues.
 
   VOL nonzero means the insn is volatile; don't optimize it.  */
 
staticvoid
expand_asm_operands (tree string, tree outputs, tree inputs,
             tree clobbers, tree labels, intvol, location_t locus)

先不看其它部分,就从”VOL nonzero means the insn is volatile; don’t optimize it. “中就能得到volatile的一个非常有用的信息:
为什么要用volatile来修饰Optimization barrier语句?原来因为,加上volatile修饰符可以防止对其进行优化,因为Optimization barrier是一个
空语句,如果不加上volatile,编译器可能会过激优化,将其删除。
再来看该函数中的一段:

1
2
3
4
5
6
7
8
9
10
11
12
  if(j == -3)  /* `cc', which is not a register */
continue;
 
  if(j == -4)  /* `memory', don't cache memory across asm */
{
  XVECEXP (body, 0, i++)
    = gen_rtx_CLOBBER (VOIDmode,
               gen_rtx_MEM
               (BLKmode,
            gen_rtx_SCRATCH (VOIDmode)));
  continue;
}

这段代码很容易看懂:
如果是”cc”,无对应RTL指令段生成;
如果是”memory”,生成的RTL指令段”(clobber (mem:BLK (scratch)))”;
这就是为什么Optimization barrier对应的Insn为什么是这个样子的原因了。

那这些都还只是RTL的生成过程,还不是关键点。

其实我们不妨从指令调度出发:

2.1 指令调度

先用一个例子来说明指令调度的作用:
假设有下面三种指令,Delay表示完成该指令需要消耗的CPU指令周期

下面是一个指令序列,如果严格按照当前顺序完成所有指令工作,最后一个指令就需要在第18个(Cycle)指令周期才开始执行:

而按照下面这种顺序执行,完成所有指令工作,最后一个指令在第11个(Cycle)指令周期就可以开始执行了:

可以看到,经过排序后的指令序列,需要花费的指令周期要少这么多,在实际编译优化过程中,指令调度的优化作用是不容忽视的。
优化的结果能够根据图或者实验看得到,那么,指令调度是怎么完成的呢?总不能随便乱排吧,指令和指令之间是有依赖的啊,就比如上文指令序列中
的前两条指令,第二条指令的R1就来自与第一条指令的对R1的写操作,第二条指令当且仅当第一条指令完成后,才能得到正确的结果。指令之间的依赖,
不管是编译器,还是CPU乱序执行,都不能违反的。
将该指令序列用小写字母按顺序编排:

分析指令之间的依赖关系,可以得到下图:

其中,a –> b是指b依赖a,希望这一点大家不要误解,下面说明一下反依赖是什么(虚箭头),其实反依赖并不是真正的依赖关系,我们可以这样来理解它:“指令序列倒过来看,B依赖A,则说明A反依赖B”,如果这样的解释不容易懂的话,可以结合上图来说明,就拿d和e来说吧:虽然e不直接依赖于d,如果我们将e排到d之前,就会使得,d依赖e,即如果将e排到d之前,就会对d产生影响,为表示这样一种关系,就
用反依赖(虚箭头)来标明,另外,反依赖也具有传递性的,如e反依赖d,而d依赖c,则有e反依赖c,所以按照这种依赖关系,从最后一个指令i出发向上,很容易得到上面的依赖关系图。
反依赖关系其实在优化过程中很有作用的,就拿上图来说,因为反依赖约束,导致e指令不能排到a,b,c,d之前,但编译器对反依赖做了一个很明知的响应:
将对应的指令依赖数据更名,可以非常有效的去除反依赖,如下图,即将e指令R2更名为R3,则因为f直接依赖e,所以对应的R2一同更改为R3,这个时候再看反依赖关系,就不复存在了,大家可以自己试试去体会一下,个人感觉这种做法非常聪明。

2.2 从指令调度来看优化屏障

假设优化屏障(OB)将一块代码分为A,B两个小块,如下图,它们有如下依赖关系(实线表依赖,虚线表反依赖):

这样一来,OB反依赖A且B依赖OB,自然能保持A,B的顺序了,即B的指令无法越过OB到A部分,而A部分的指令也无法越过OB到B部分。

有人也许会反问,如果将OB改名不也能去除反依赖么? 是的,也许真的会这样,那么我们能用什么方法阻止它编译器这么做呢?大家也许想到了:
没错,就是volatile,既然volatile可以防止编译优化对其有所动作,加上它就能保证编译器做这种将其重排、删除、或者更名等动作了。最终就维护的整体的A、B序。

好了,说了这么多,应该能说明为什么优化屏障有这样的作用吧,另外说明一下,其实除了编译优化指令不能越过优化屏障外,由于”memory”的语义,指内存经过变动了,自然,所有寄存器中的内容都是invalid的,所以,优化屏障还有另外一个作用:“屏障之前,所有在寄存器中的结果要写回内存;屏障之后,数据需要重新从内存中加载,其实这一点很像一个全局的volatile读写语义”。



原创粉丝点击