通过尽早计算有条件来避免管道停滞



当谈到ifs的性能时,我们通常会谈论错误预测如何使管道停滞不前。我看到的推荐解决方案是:

  1. 信任分支预测器,以处理通常只有一个结果的条件;或
  2. 如果合理可能的话,避免使用一点点位魔术进行分支;或
  3. 尽可能有条件地移动。

我找不到的是,我们是否可以尽早计算病情以尽可能提供帮助。因此,而不是:

... work
if (a > b) {
... more work
}

做这样的事情:

bool aGreaterThanB = a > b;
... work
if (aGreaterThanB) {
... more work
}

像这样的事情是否可以完全避免在这个条件下停滞(取决于管道的长度以及我们可以在布尔值和 if 之间投入的工作量)?它不必像我写的那样,但是有没有办法尽早评估条件,这样 CPU 就不必尝试预测分支

另外,如果这有帮助,编译器可能会这样做吗?

是的,允许尽早计算分支条件可能是有益的,这样任何错误预测都可以尽早解决,并且管道的前端部分可以尽早开始重新填充。在最好的情况下,如果已经有足够的工作在进行中,完全隐藏前端气泡,则错误预测是免费的。

不幸的是,在无序 CPU 上,early有一个有点微妙的定义,因此让分支尽早解决并不像在源代码中移动行那么简单 - 您可能必须更改条件的计算方式。

什么不起作用

不幸的是,前面不是指条件/分支在源文件中的位置,也没有引用与比较或分支对应的程序集指令的位置。因此,在基本层面上,它大多是7不像您的示例那样工作。

即使源代码级别的定位很重要,它也可能在您的示例中不起作用,因为:

您已经将条件的评估上移并将其分配给bool,但可能错误预测的不是测试(<运算符),而是随后的条件分支:毕竟,这是一个分支错误预测。在您的示例中,分支在两个位置的同一位置:它的形式只是从if (a > b)更改为if (aGreaterThanB)

除此之外,你转换代码的方式不太可能欺骗大多数编译器。优化编译器不会按照编写代码的顺序逐行发出代码,而是根据源代码级依赖项按照他们认为合适的方式调度内容。提前拉出条件可能会被忽略,因为编译器希望将检查放在自然要去的地方:大约在具有标志寄存器的架构分支之前。

例如,考虑一个简单函数的以下两个实现,它们遵循您建议的模式。第二个函数将条件向上移动到函数的顶部。

int test1(int a, int b) {
int result = a * b;
result *= result;
if (a > b) {
return result + a;
}
return result + b * 3;
}
int test2(int a, int b) {
bool aGreaterThanB = a > b;
int result = a * b;
result *= result;
if (aGreaterThanB) {
return result + a;
}
return result + b * 3;
}

我检查了 gcc、clang2和 MSVC,并且都以相同的方式编译了这两个函数(编译器之间的输出不同,但对于每个编译器,两个函数的输出是相同的)。例如,使用gcc编译test2会导致:

test2(int, int):
mov eax, edi
imul eax, esi
imul eax, eax
cmp edi, esi
jg .L4
lea edi, [rsi+rsi*2]
.L4:
add eax, edi
ret

cmp指令对应于a > b条件,gcc 已将其移回所有"工作",并将其放在条件分支jg旁边。

什么有效

因此,如果我们知道简单地操作源中的操作顺序是行不通的,那么什么有效呢?事实证明,在数据流图中"向上"移动分支条件的任何操作都可能通过允许更早地解决错误预测来提高性能。我不打算深入探讨现代 CPU 如何依赖于数据流,但您可以在此处找到简要概述,并在末尾提供进一步阅读的指针。

遍历链表

下面是一个涉及链接列表遍历的真实示例。

考虑将所有值求和为以 null 结尾的链表的任务,该链表还将其长度1存储为列表头结构的成员。链表实现为一个list_head对象和零个或多个列表节点(具有单个int value有效负载),定义如下:

struct list_node {
int value;
list_node* next;
};
struct list_head {
int size;
list_node *first;
};

规范搜索循环将使用最后一个节点中的node->next == nullptr哨来确定是否已到达列表末尾,如下所示:

long sum_sentinel(list_head list) {
int sum = 0;
for (list_node* cur = list.first; cur; cur = cur->next) {
sum += cur->value;
}
return sum;
}

这就像你得到的一样简单。

但是,这会将结束求和的分支(第一个cur == null的分支)放在节点到节点指针跟踪的末尾,这是数据流图中最长的依赖项。如果此分支错误预测,则错误预测的解决将"延迟"发生,前端气泡将直接添加到运行时中。

另一方面,您可以通过显式计算节点来进行求和,如下所示:

long sum_counter(list_head list) {
int sum = 0;
list_node* cur = list.first;
for (int i = 0; i < list.size; cur = cur->next, i++) {
sum += cur->value;
}
return sum;
}

将其与哨兵解决方案进行比较,似乎我们增加了额外的工作:我们现在需要初始化、跟踪和减少计数4。然而,关键是这个递减依赖链非常短,因此它将"跑在"指针追逐工作之前,并且错误预测将提前发生,同时仍有有效的剩余指针追逐工作要做,可能会大大改善运行时。

让我们实际尝试一下。首先,我们检查程序集中的两个解决方案,以便我们可以验证没有发生任何意外情况:

<sum_sentinel(list_head)>:
test   rsi,rsi
je     1fe <sum_sentinel(list_head)+0x1e>
xor    eax,eax
loop:
add    eax,DWORD PTR [rsi]
mov    rsi,QWORD PTR [rsi+0x8]
test   rsi,rsi
jne    loop
cdqe   
ret    

<sum_counter(list_head)>:
test   edi,edi
jle    1d0 <sum_counter(list_head)+0x20>
xor    edx,edx
xor    eax,eax
loop:
add    edx,0x1
add    eax,DWORD PTR [rsi]
mov    rsi,QWORD PTR [rsi+0x8]
cmp    edi,edx
jne    loop:
cdqe   
ret    

正如预期的那样,哨兵方法稍微简单一些:在设置过程中少一条指令,在循环5中少一条指令,但总的来说,关键指针追逐和加法步骤是相同的,我们预计这个循环将由连续节点指针的延迟主导。

事实上,当预测影响可以忽略不计时,对短列表或长列表求和时,循环的表现几乎相同。对于长列表,分支预测的影响自动很小,因为到达列表末尾时的单个错误预测会分摊到许多节点上,并且对于 L1 中包含的列表,运行时逐渐接近每个节点 4 个周期,这是我们对英特尔最佳情况下 4 个周期加载到使用延迟的预期。

对于短列表,如果列表的模式是可预测的,则分支错误预测是可以忽略不计的:要么总是相同的,要么循环一些适度的周期(如果预测良好,可以是 1000 或更多!在这种情况下,当对许多短列表求和时,每个节点的时间可以小于 4 个周期,因为多个列表可以同时运行(例如,如果汇总一个列表数组)。在任何情况下,这两种实现的性能几乎相同。例如,当列表始终有 5 个节点时,使用任一实现对一个列表求和的时间约为 12 个周期:

** Running benchmark group Tests written in C++ **
Benchmark   Cycles   BR_MIS
Linked-list w/ Sentinel    12.19     0.00
Linked-list w/ count    12.40     0.00

让我们将分支预测添加到组合中,方法是更改列表生成代码以创建平均长度为 5 但实际长度均匀分布在[0, 10]中的列表。求和代码保持不变:只有输入不同。随机列表长度的结果:

** Running benchmark group Tests written in C++ **
Benchmark   Cycles   BR_MIS
Linked-list w/ Sentinel    43.87     0.88
Linked-list w/ count    27.48     0.89

BR_MIS列显示,正如预期的那样,我们在每个列表6中几乎得到了一个分支错误预测,因为循环退出是不可预测的。

但是,哨兵算法现在需要 ~44 个周期,而计数算法需要 ~27.5 个周期。计数算法快约 16.5 个周期。你可以玩列表长度和其他因素,并改变绝对时间,但增量几乎总是在 16-17 个周期左右,这并非巧合,这与最近英特尔的分支错误预测惩罚大致相同!通过尽早解决分支条件,我们避免了前端泡沫,在那里什么都不会发生。

提前计算迭代计数

另一个例子是计算浮点值的循环,比如泰勒级数近似,其中终止条件取决于计算值的某个函数。这具有与上述相同的效果:终止条件取决于慢速循环携带的依赖关系,因此它的解析速度与值本身的计算一样慢。如果出口不可预测,您将在出口时停滞不前。

如果可以更改它以预先计算迭代计数,则可以使用解耦整数计数器作为终止条件,从而避免气泡。即使前期计算增加了一些时间,它仍然可以提供整体加速(并且无论如何,计算可以与循环的第一次迭代并行运行,因此通过查看其延迟,它的成本可能比您期望的成本低得多)。


1MIPS是一个有趣的例外,这里没有标志寄存器 - 测试结果直接存储到通用寄存器中。

2Clang以无分支的方式编译了这个和许多其他变体,但它仍然很有趣,因为你仍然具有相同的测试指令结构和一个条件移动(取代分支)。

3喜欢 C++11std::list.

4事实证明,在 x86 上,由于dec隐式设置零标志的方式,两种方法之间的每节点工作实际上非常相似,所以我们不需要额外的test指令,而指针追逐中使用的mov则不需要,因此计数器方法具有额外的dec,而哨兵方法具有额外的测试, 让它关于洗涤。

5虽然这部分只是因为 gcc 没有设法将递增的 for 循环转换为递减的 for 循环,以利用dec设置零标志,避免cmp。也许较新的 gcc 版本做得更好。另见脚注4。

6我想这更接近 0.9 而不是 1.0,因为分支预测器可能仍然得到长度 = 10 大小写正确,因为一旦你循环了 9 次,下一次迭代将始终退出。合成/精确的分布不会表现出来。

7我之所以这么说,主要是因为在某些情况下,您可以通过这种源或程序集级别的重新排序来节省一两个周期,因为这样的事情对无序处理器中的执行顺序的影响很小,执行顺序也受到程序集顺序的影响,但仅限于数据流图的约束。另请参阅此评论。

乱序执行绝对是一回事(不仅是编译器,甚至处理器芯片本身也可以对指令进行重新排序),但它对数据依赖性引起的管道停滞比错误预测引起的管道停滞更有帮助。

控制流方案中的好处受到以下事实的限制:在大多数体系结构上,条件分支指令仅基于标志寄存器而不是基于通用寄存器做出决策。除非干预"工作"非常不寻常,否则很难提前设置标志寄存器,因为大多数指令都会更改标志寄存器(在大多数架构上)。

也许确定组合

TST (reg)
J(condition)

可以设计为在提前设置足够远时(reg)最小化失速。 这当然需要处理器的很大程度的帮助,而不仅仅是编译器。 处理器设计人员可能会针对更一般的情况进行优化,即早期(无序)执行为分支设置标志的指令,结果标志通过管道转发,提前结束停滞。

分支错误预测的主要问题不是它在刷新较年轻的操作时作为惩罚产生的几个周期(相对较快),而是如果存在分支条件必须首先解决的数据依赖关系,它可能会在管道上发生得很晚。

对于基于先前计算的分支,依赖项的工作方式与其他操作一样。此外,分支会很早就沿着管道进行预测,以便机器可以继续获取和分配进一步的操作。如果预测不正确(与通常表现出更可预测模式的循环控件不同,数据依赖分支更常见),则只有在解决依赖项并证明预测是错误的时才会发生刷新。越晚发生,处罚就越大。

由于无序执行会在解决依赖关系后立即调度操作(假设没有端口压力),因此将操作向前移动可能无济于事,因为它不会更改依赖链,也不会对调度时间产生太大影响。唯一的潜在好处是,如果你把它向上移动得足够远,以便OOO窗口可以更早地看到它,但现代CPU通常会提前运行数百条指令,并且在不破坏程序的情况下提升指令是很困难的。但是,如果您正在运行某个循环,如果可能的话,计算未来迭代的条件可能很简单。

所有这些都不会改变完全正交的预测过程,但是一旦分支到达机器的OOO部分,它将立即得到解决,如果需要,则清除,并产生最小的惩罚。

最新更新