【问题标题】:Branchless internal merge slower than internal merge with branch无分支内部合并比内部合并与分支慢
【发布时间】:2017-04-29 00:09:22
【问题描述】:

我最近在 Code Review 上要求 a question 审查名为 QuickMergeSort 的排序算法。我不会详细介绍,但在某些时候,该算法会执行内部合并排序:它不是使用额外的内存来存储要合并的数据,而是将要合并的元素与来自原始序列的另一部分的元素交换,即'否则不会与合并有关。这是我关心的算法部分:执行合并的函数:

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
}

该函数改编自 std::inplace_merge 的 libc++ 实现中的同名函数;这个新版本将元素与原始数组的另一部分交换,而不是从辅助数组中移动元素。

由于合并是内部,我意识到我实际上不需要有两个单独的输入类型:InputIterator1InputIterator2 总是相同的。然后我意识到,由于first1first2上的操作总是相同的,我可以将它们存储在一个二元素数组中,并使用比较的结果来索引数组以知道要交换哪个迭代器并增加。通过这个小技巧,我摆脱了分支并获得了一个几乎无分支的合并算法:

template<
    typename InputIterator,
    typename OutputIterator,
    typename Compare = std::less<>
>
auto half_inplace_merge(InputIterator first1, InputIterator last1,
                        InputIterator first2, InputIterator last2,
                        OutputIterator result, Compare compare={})
    -> void
{
    InputIterator store[] = { first1, first2 };

    for (; store[0] != last1; ++result) {
        if (store[1] == last2) {
            std::swap_ranges(store[0], last1, result);
            return;
        }

        bool cmp = compare(*store[1], *store[0]);
        std::iter_swap(result, store[cmp]);
        ++store[cmp];
    }
    // first2 through last2 are already in the right spot
}

现在,问题是:使用这个新的 half_inplace_merge 函数,整体排序算法比原来的 half_inplace_merge 慢 1.5 倍,我不知道为什么。我尝试了几个编译器优化级别,几个技巧来避免潜在的别名问题,但似乎问题来自无分支技巧本身。

那么,有没有人能解释一下为什么无分支代码比较慢?


附录: 对于那些想要运行与我相同的基准测试的人......好吧,这会有点困难:我使用了个人图书馆中的基准测试,其中包括很多东西;您需要下载the library,在某处添加this file,并在添加所需行以在突出显示部分附近调用quick_merge_sort 后运行this benchmark(您需要重定向程序的标准输出到profiles 子目录中的文件)。然后您需要运行this Python script 来查看结果,将quick_merge_sort 添加到突出显示的行。注意需要安装 NumPy 和 matplotlib。

【问题讨论】:

  • 所有编译器都会出现这种情况吗? (我猜你已经检查过了,但我只是想做一个小的健全性检查。)
  • 我想得越多,我就越怀疑访问任一数组元素所需的取消引用是问题所在。在原始代码中,编译器知道每种情况正在访问哪个迭代器,而在第二种情况下,内存访问无法优化。
  • 查看汇编输出,我在第二个版本中看到了更复杂的间接寻址模式:godbolt.org/g/yjW1Ks - 并且没有更少的分支。
  • 总结my comments here:您可能会将“预测”问题推入加载存储单元而不是分支预测器。由于地址的随机性,内存消歧器无法正确预测它们之间的依赖关系——从而得到与错误预测分支相同的惩罚。不幸的是,我没有办法检验这个理论。所以我把它作为评论。
  • 能否请您提供一个包含可运行代码版本的 pastebin 链接?我可以为您的代码获取性能计数器值。

标签: c++ performance sorting branch-prediction


【解决方案1】:

如此大的差异是两个条件的产物。

第一个条件与原始代码有关。就地合并非常有效,即使在汇编语言级别手动编码,也很难设计出任何更快的东西。泛型的应用很简单,所以编译器**不管有没有它都会产生相同的程序集。由于算法实现是高效的,因此只需添加到循环中的几条机器指令就能够产生问题中指出的显着比例变化。

** 整个答案中的编译细节使用 g++ 6.2.1 20160916,默认的 Fedora 24 dnf 包,以及 LINUX 内核 4.8.8-200.fc24.x86_64。运行时是 Intel i7-2600 8M 缓存。也适用于带有 arm-none-eabi-g++ 4.8.3-2014q1 的 Atmel SAM3X8E ARM Cortex-M3。

第二个条件与问题第3句2中描述的第二个技巧的编译有关。第一个技巧,模板中类型的减少,并没有对汇编语言产生任何重大变化。第二个技巧在两个调用的编译器输出中产生了影响触发器的汇编级别差异。

这种预编译器技巧可以简化测试。

#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 中使用这些命令执行和比较会利用预编译器 hack。

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 store[ ] 的结果,但在循环之外。

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 周期要求。此类规则具有与要在代码中优化的模式相匹配的前因(搜索模式)。

使用第二个技巧优化代码的速度将需要一个在循环内外都与非典型 score[ ] 抽象匹配的规则前件。在没有第二招的情况下检测分支冗余是一个更合理的目标。

将两个语句集成到每个分支中,我们可以看到 AST 中的两个相似模式如何简单到足以让重构规则前因匹配并执行所需的代码大小缩减。在这种情况下,速度几乎没有提升。

if (compare(*first2, *first1)) {
    std::iter_swap(result, first2 ++);
} else {
    std::iter_swap(result, first1 ++);
}

【讨论】:

  • 同意,Douglas Daseeco。空间优化往往是速度优化的大敌。
【解决方案2】:

以下只是一个简短的直观解释:

如果我们缩小所有内容并假设迭代器是普通指针,我们可以在第一个示例中将所有迭代器存储在寄存器中。

在无分支代码中,由于store[cmp]++store[cmp],我们不能轻易做到这一点,这意味着所有使用store[0]store[1] 的开销。

因此(在这种情况下)最大限度地利用寄存器比避免分支更重要。

【讨论】:

  • 是的@DouglasDaseeco ...您的回答和它末尾的评论解决了减速的根源以及之前对可能巧妙地减速的误解。
猜你喜欢
  • 2016-05-29
  • 2021-01-18
  • 1970-01-01
  • 2016-09-20
  • 1970-01-01
  • 1970-01-01
  • 2014-03-08
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多