C++ 无分支的内部合并比带分支的内部合并慢
我最近在CodeReview上询问了一个名为QuickMergeSort的排序算法。我不会详细介绍,但在某个时候,该算法会执行一个内部合并排序:它不会使用额外的内存来存储要合并的数据,而是交换元素,以便与原始序列的另一部分中的元素合并,这与合并无关。以下是我关注的算法部分:执行合并的函数:C++ 无分支的内部合并比带分支的内部合并慢,c++,performance,sorting,branch-prediction,C++,Performance,Sorting,Branch Prediction,我最近在CodeReview上询问了一个名为QuickMergeSort的排序算法。我不会详细介绍,但在某个时候,该算法会执行一个内部合并排序:它不会使用额外的内存来存储要合并的数据,而是交换元素,以便与原始序列的另一部分中的元素合并,这与合并无关。以下是我关注的算法部分:执行合并的函数: template< typename InputIterator1, typename InputIterator2, typename OutputIterator,
template<
typename InputIterator1,
typename InputIterator2,
typename OutputIterator,
typename Compare = std::less<>
>
auto half_inplace_merge(InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result, Compare compare={})
-> void
{
for (; first1 != last1; ++result) {
if (first2 == last2) {
std::swap_ranges(first1, last1, result);
return;
}
if (compare(*first2, *first1)) {
std::iter_swap(result, first2);
++first2;
} else {
std::iter_swap(result, first1);
++first1;
}
}
// first2 through last2 are already in the right spot
}
现在,问题是:有了这个新的half\u-in-place\u-merge
函数,整体排序算法比原来的half\u-in-place\u-merge
慢1.5倍,我不知道为什么。我已经尝试了几个编译器优化级别,一些技巧来避免潜在的别名问题,但问题似乎来自无分支技巧本身
那么,有人能解释为什么无分支代码速度较慢吗
附录:对于那些想运行和我一样的基准测试的人。。。嗯,这将有点困难:我使用了个人图书馆的基准测试,其中包括很多东西;在突出显示的部分附近添加调用
quick\u merge\u sort
所需的行后,您需要下载、添加某处并运行(您需要将程序的标准输出重定向到profiles
子目录中的文件)。然后需要运行查看结果,将quick\u merge\u sort
添加到突出显示的行中。请注意,需要安装NumPy和matplotlib。如此大的差异是两种情况的产物
第一个条件与原始代码相关。就地合并非常有效,即使在汇编语言级别手动编码,也很难设计出更快的代码。泛型的应用非常简单,因此编译器**生成了相同的程序集,不管有没有泛型。由于算法实现是高效的,因此只有少数机器指令添加到循环中,才能产生问题中所示的显著比例变化
**整个回答中的编译细节都使用了默认的Fedora 24 dnf包g++6.2.1 20160916,以及LINUX内核4.8.8-200.fc24.x86_64。运行时为Intel i7-2600 8M缓存。同样适用于Atmel SAM3X8E ARM Cortex-M3和ARM-none-eabi-g++4.8.3-2014q1
第二个条件与问题第3段第2句中描述的第二个技巧的编写有关。第一个技巧是减少模板中的类型,但并没有对汇编语言产生任何显著的变化。第二个技巧产生了flop,影响两个调用的编译器输出中的汇编级差异
这种预编译技术可以简化测试
#ifdef ORIG
#define half_inplace_merge half_inplace_merge_orig
#else // ORIG
#define half_inplace_merge half_inplace_merge_slow
#endif // ORIG
...
half_inplace_merge(niInA.begin(), niInA.end(),
niInB.begin(), niInB.end(),
niOut.begin(), compare);
在bash shell中使用这些命令执行和比较利用了预编译程序的漏洞
g++ -DORIG -S -fverbose-asm -o /tmp/qq.orig.s /tmp/qq.cpp
g++ -DSLOW -S -fverbose-asm -o /tmp/qq.slow.s /tmp/qq.cpp
araxis.sh /tmp/qq.orig.s /tmp/qq.slow.s # to run Araxis Merge in Wine
这些指令是初始化InputIterator存储[]的结果,但这在循环之外
leaq -48(%rbp), %rax #, _4
movq -64(%rbp), %rdx # first1, tmp104
movq %rdx, (%rax) # tmp104, *_5
leaq 8(%rax), %rdx #, _9
movq -96(%rbp), %rax # first2, tmp105
movq %rax, (%rdx) # tmp105, *_9
主要的慢动作是根据比较和交换的需要取消对store[]中包含的两个项的引用,这两个项都在循环中。没有第二个技巧的版本中不存在这些说明
movb %al, -17(%rbp) # _27, cmp
movzbl -17(%rbp), %eax # cmp, _29
cltq
...
movzbl -17(%rbp), %edx # cmp, _31
leaq -48(%rbp), %rax #, tmp121
movslq %edx, %rdx # _31, tmp122
salq $3, %rdx #, tmp123
addq %rdx, %rax # tmp123, _32
尽管在没有技巧的版本中,条件的主体中存在代码重复,但这只会影响代码的紧凑性,增加两个调用、五个移动和一个比较指令。执行就地合并所需的CPU周期数在比较产生的分支之间是相同的,并且两者都缺少上面列出的指令
对于尝试的几种语法排列中的每一种,消除分支中的冗余以提高紧凑性不可避免地会导致沿执行路径需要额外的指令
到目前为止讨论的各种排列的指令序列细节将因编译器、优化选项选择、甚至调用函数的条件而异
从理论上讲,编译器可以使用AST(抽象符号树)重构规则(或等效规则)来检测并减少任何版本函数的程序内存和CPU周期需求。此类规则具有与代码中要优化的模式相匹配的先行项(搜索模式)
使用第二个技巧优化代码的速度需要一个规则先行项,该规则先行项在循环内部和外部都匹配非典型分数[]抽象。在不使用第二种技巧的情况下检测分支冗余是一个更合理的目标
将这两条语句集成到每个分支中,可以看到AST中的两个相似模式是如何简单到足以让重构规则先行匹配并执行所需的代码大小缩减的。在这种情况下,如果有的话,速度几乎没有提高
if (compare(*first2, *first1)) {
std::iter_swap(result, first2 ++);
} else {
std::iter_swap(result, first1 ++);
}
以下只是一个简短的直观解释: 如果我们缩小所有的范围并假设迭代器是普通指针,那么在第一个示例中,我们可以将所有迭代器存储在寄存器中 在无分支代码中,由于
store[cmp]
和++store[cmp]
,我们无法轻松做到这一点,这意味着store[0]
和store[1]
的所有使用都会产生开销
因此(在本例中)最大限度地使用寄存器比避免分支更重要。这是否在所有编译器上都发生过?(我想你已经检查过了,但我只是想做一个小的合理性检查。)我越想它,就越怀疑访问任一数组元素所需的解引用是问题所在。在原始代码中,编译器知道每种情况下访问哪个迭代器,第二种情况下无法优化内存访问
if (compare(*first2, *first1)) {
std::iter_swap(result, first2 ++);
} else {
std::iter_swap(result, first1 ++);
}