是的,尽早计算分支条件可能会有益处,这样任何错误预测都可以尽早解决,前端管道的部分就可以尽早重新填充。在最好的情况下,如果已经有足够的工作在进行中以完全隐藏前端泡沫,则错误预测可能是自由的。
不幸的是,在乱序CPU上,“尽早”有一个相当微妙的定义,因此让分支尽早解决并不像只是在源代码中移动行那么简单 - 您可能需要更改计算条件的方式。
令人遗憾的是,“更早”并不指源文件中条件/分支的位置,也不指对应于比较或分支的汇编指令的位置。因此,在根本上,它在您的示例中大多数
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,发现它们都编译了这两个函数identically(输出在编译器之间有所不同,但对于每个编译器,这两个函数的输出是相同的)。例如,使用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如何依赖数据流,但您可以在这里找到简要概述,并在末尾找到进一步阅读的指针。
遍历链接列表
这是一个涉及链接列表遍历的实际示例。
考虑将空终止的链接列表中的所有值相加,该链接列表还将其长度1存储为列表头结构的成员。链接列表实现为一个list_head
对象和零个或多个列表节点(具有单个int值
负载),定义如下:
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个周期负载到使用延迟所预期的。
对于短列表,如果列表模式可预测(始终相同或以某种适度的周期循环,可以具有良好的预测性!),则分支错误预测可以被忽略不计。在这种情况下,由于多个列表可以同时进行(例如,如果总结列表数组),因此每个节点的时间可以小于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
< p >
BR_MIS
列显示我们每个列表几乎会出现一个分支预测错误
6,这是预期的,因为循环退出是不可预测的。
然而,哨兵算法现在需要大约44个周期,而计数算法需要大约27.5个周期。计数算法快约16.5个周期。您可以玩弄列表长度和其他因素,并改变绝对时间,但差异几乎总是在16-17个周期左右,这恰好与最近英特尔的分支预测惩罚相同!通过提前解决分支条件,我们避免了前端气泡,在那里什么都不会发生。
提前计算迭代次数
另一个例子是像一个循环一样计算浮点值的循环,比如泰勒级数逼近,其中终止条件取决于计算出的值的某个函数。这具有与上述相同的效果:终止条件取决于缓慢的循环传递依赖项,因此解决它与解决值本身一样慢。如果退出不可预测,您将遭受停顿。
如果您可以更改为提前计算迭代次数,则可以使用脱耦的整数计数器作为终止条件,避免气泡。即使提前计算需要一些时间,它仍然可以提供整体加速(而且计算可以与循环的第一次迭代并行运行,因此它可能比您预期的成本要低得多)。
1 MIPS是一个有趣的例外,没有标志寄存器 - 测试结果直接存储在通用寄存器中。
2 Clang以无分支的方式编译了这个和许多其他变体,但它仍然很有趣,因为您仍然具有测试指令和条件移动(代替分支)的相同结构。
3 类似于C++11的std::list
。
4 结果发现,在x86上,每个节点的工作实际上在两种方法之间非常相似,因为dec
隐式设置了零标志,所以我们不需要额外的test
指令,而指针追踪中使用的mov
则不需要,因此计数器方法有一个额外的dec
,而哨兵方法有一个额外的test,使其几乎相等。
5 尽管这部分只是因为gcc没有将增量for循环转换为递减循环以利用dec
设置零标志,避免了cmp
。也许新版本的gcc做得更好。另见注释4。
6 我想这更接近于0.9而不是1.0,因为也许分支预测器仍然会正确处理长度= 10的情况,因为一旦循环了9次,下一个迭代将总是退出。一个不那么合成/精确的分布不会表现出来。
7 我说“大多数情况”是因为在某些情况下,通过这种源代码或汇编级别的重排可能会节省一两个周期,因为这些事情可能对乱序处理器中的执行顺序产生轻微影响,执行顺序也受到汇编顺序的影响,但仅在数据流图的约束范围内。 请参见这个评论。