上一篇文章指令处理机制说过现代CPU处理指令的方式大多数都是out-of-order,那么为了更好地利用out-of-order这种处理机制,我们在编写程序的时候有必要规避过长的依赖链。
循环
如下面的一个C++例子,目的是计算长度为100的数组的总和:
// Example 9.3a, Loop-carried dependency chain double list[100], sum = 0.; for (int i = 0; i < 100; i++) sum += list[i];
上述代码中有100次加法运算,并且每次加法运算都依赖于上一次加法运算的结果。这是一个循环依赖链,一旦循环依赖链非常长,就有可能长时间使得out-of-order处理机制处于无法有效运转的状态。在上述例子中,在执行浮点运算时,只有加法运算i ++ 可以并行执行。
现假设浮点加法的latency为4,throughput为1,那么最优的实现方式就是并行执行4个浮点加法,如此一来就能充分利用浮点加法器的流水线机制。我们可以把代码修改如下:
// Example 9.3b, Multiple accumulators double list[100], sum1 = 0., sum2 = 0., sum3 = 0., sum4 = 0.; for (int i = 0; i < 100; i += 4) { sum1 += list[i]; sum2 += list[i+1]; sum3 += list[i+2]; sum4 += list[i+3]; } sum1 = (sum1 + sum2) + (sum3 + sum4);
这下循环里面有了四条依赖链,并且每条依赖链的长度为原来的四分之一,由于四条依赖链相互独立,因此可以并行执行,优化后的代码理论上的处理速度为原来的四倍。不过依赖链越多,CPU进行指令调度的难度也就越大,因此不一定能达到理论值。
现在的微处理器运行速度越来越快,可能在一个时钟周期可以同时处理4到5条指令,支持macro-op fusion的处理器甚至能处理更多,对于并行度越高的处理器,就更应该避免过长的依赖链。
普通线性运算
普通线性运算可以打破依赖链:
y = a + b + c + d;
上述式子可以写成以下形式:
y = (a + b) + (c + d);
如此一来a+b与c+d就能并行执行。不过可能有些编译器已经对这种形式的运算做了优化。
寄存器清零
前一篇文章我们讨论过:对整个寄存器进行写入时会使得寄存器重命名、打破依赖链。因此对寄存器清零是一种常用的打破依赖链的方式。
寄存器清零可以用 mov eax, 0 、 xor eax, eax 、 sub eax, eax 等方式。不过常用的是 xor eax,eax 的这种形式,原因如下面表格:
Instruction | length | side-effect |
mov eax, 0 | 5 bytes | no |
xor eax, eax | 2 bytes | no |
sub eax, eax | 2 bytes | depend on carry flag |
我们之前也说过,对partial register写入是会导致falce dependence的,因此对partial register的清零是不能打破依赖链的。另外,现代处理器对清零操作的处理不用经过处理阶段,可以直接在寄存器重命名阶段对物理寄存器清零并对接上逻辑寄存器。
SIMD的相关寄存器也是有相应的xor等指令,也常用于打破依赖链。