Performance 通过提前计算条件,避免管道停滞
在谈论ifs的性能时,我们通常会谈论预测失误如何会阻碍管道的运行。我看到的建议解决方案有:Performance 通过提前计算条件,避免管道停滞,performance,language-agnostic,compiler-optimization,cpu-architecture,branch-prediction,Performance,Language Agnostic,Compiler Optimization,Cpu Architecture,Branch Prediction,在谈论ifs的性能时,我们通常会谈论预测失误如何会阻碍管道的运行。我看到的建议解决方案有: 对于通常只有一个结果的情况,信任分支预测器;或 如果合理的话,避免使用一点魔法来分支;或 在可能的情况下有条件地移动 我找不到的是,我们是否可以尽早计算病情,以便尽可能地提供帮助。因此,不是: ... work if (a > b) { ... more work } 这样做: bool aGreaterThanB = a > b; ... work if (aGreaterThan
... work
if (a > b) {
... more work
}
这样做:
bool aGreaterThanB = a > b;
... work
if (aGreaterThanB) {
... more work
}
long sum_sentinel(list_head list) {
int sum = 0;
for (list_node* cur = list.first; cur; cur = cur->next) {
sum += cur->value;
}
return sum;
}
这样的事情是否可以完全避免这个条件上的停顿(取决于管道的长度以及我们可以在bool和if之间投入的工作量)?它不必像我写的那样,但是有没有一种方法可以早期评估条件,这样CPU就不必尝试预测分支了
此外,如果这有帮助的话,编译器可能会这样做吗?无序执行肯定是一件事(不仅仅是编译器,甚至处理器芯片本身也可以对指令进行重新排序),但与预测失误相比,它更能帮助解决数据依赖性导致的管道暂停问题 控制流场景中的好处在某种程度上受到以下事实的限制:在大多数体系结构上,条件分支指令仅基于标志寄存器而不是基于通用寄存器作出决定。除非中间的“工作”非常不寻常,否则很难提前设置标志寄存器,因为大多数指令都会更改标志寄存器(在大多数体系结构上) 也许是确定了
TST (reg)
J(condition)
当
(reg)
提前设置得足够远时,可将失速降至最低。这当然需要处理器的大量帮助,而不仅仅是编译器。处理器设计者可能会针对更一般的情况进行优化,即设置分支标志的指令提前(无序)执行,结果标志通过管道转发,提前结束暂停。Yes,允许尽早计算分支条件,以便尽早解决任何预测失误,并尽早开始重新填充管道前端部分,这可能是有益的。在最好的情况下,如果已经有足够的工作来完全隐藏前端气泡,那么错误预测是可以避免的
不幸的是,在无序的CPU上,early有一个稍微微妙的定义,因此尽早解析分支并不像在源代码中移动行那样简单——您可能需要更改条件的计算方式
什么不起作用
不幸的是,早期版本没有提到源文件中条件/分支的位置,也没有提到与比较或分支相对应的汇编指令的位置。因此,在基本层面上,它的工作原理与您的示例中的不同
即使源代码级别的定位很重要,在您的示例中也可能不起作用,因为:
您已将条件的计算向上移动并将其分配给bool
,但可能预测失误的不是测试(
b)
更改为if(AGREATERTANB)
除此之外,您转换代码的方式不太可能愚弄大多数编译器。优化编译器不会按照您编写代码的顺序逐行发出代码,而是根据源代码级别的依赖关系来安排适当的时间。更早地启动条件可能会被忽略,因为编译器希望将检查放在它自然会去的地方:大约在带有标志寄存器的体系结构上的分支之前
例如,考虑以下两个简单函数的实现,它们遵循您所建议的模式。第二个函数将条件向上移动到函数的顶部
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\u head
对象和零个或多个列表节点(具有单个int值
有效负载)的链表,定义如下:
struct list_node {
int value;
list_node* next;
};
struct list_head {
int size;
list_node *first;
};
规范搜索循环将使用最后一个节点中的节点->下一个==nullptr
哨兵来确定is已到达列表的末尾,如下所示:
bool aGreaterThanB = a > b;
... work
if (aGreaterThanB) {
... more work
}
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
)放在节点到节点指针跟踪的末尾,这是数据流图中最长的依赖关系。如果这麦麸
** 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
** 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