在 gcc 上使用 addcarry 得到奇怪的优化结果

Strange optimization result with addcarry on gcc

考虑以下对 addcarry 内在函数进行基准测试的代码:

// Preamble
#include <iostream>

// Addcarry wrapper
template <class C, class T>
C addcarry(C carry, T src0, T src1, T* dst)
{
    unsigned long long int d = 0;
    carry = __builtin_ia32_addcarryx_u64(carry, src0, src1, &d);
    *dst = d;
    return carry;
}

// Main function
int main(int argc, char* argv[])
{
    // Initialization
    unsigned long long int n = argc > 1 ? std::stoull(argv[1]) : 1ULL << 31;
    unsigned char carry = 0;
    unsigned long long int src1 = 0;
#ifdef NOSTOULL
    unsigned long long int dst = 0;
#else
    unsigned long long int dst = std::stoull("0");
#endif

    // Computation
    for (unsigned long long int src0 = 0; src0 < n; ++src0) {
        src1 = dst;
#ifdef NOWRAPPER
        carry = __builtin_ia32_addcarryx_u64(carry, src0, src1, &dst);
#else
        carry = addcarry(carry, src0, src1, &dst);
#endif
    }

    // Finalization
    return dst + carry;
}

我使用以下命令编译(其中 [] 表示选项):

[g++6.3.0/g++7.1.0] -Wall -Wextra -pedantic -O3 -g [-DNOSTOULL]
[-DNOWRAPPER] addcarry_loop.cpp -o addcarry_loop -madx

然后我执行:

time ./addcarry_loop

我获得以下 real/user 次(经过多次运行):

(1) [-DNOSTOULL] [-DNOWRAPPER] => ~0m2.64s
(2) [          ] [-DNOWRAPPER] => ~0m2.61s
(3) [-DNOSTOULL] [           ] => ~0m2.48s
(4) [          ] [           ] => ~0m1.86s

我们可以注意到:

如何解释这些结果,尤其是最后一个(这对我来说毫无意义)。如何使用 stoull 初始化变量并包装函数可以使代码更快?

注意:欢迎使用其他 compilers/other 架构进行实验(需要 adx 指令集)。

编辑: 鉴于评论,我更新了代码,从主函数中提取了循环:

// Preamble
#include <iostream>

// Addcarry wrapper
template <class C, class T>
C addcarry(C carry, T src0, T src1, T* dst)
{
    unsigned long long int d = 0;
    carry = __builtin_ia32_addcarryx_u64(carry, src0, src1, &d);
    *dst = d;
    return carry;
}

// Compute
unsigned long long int compute(unsigned long long int n)
{
    // Initialization
    unsigned char carry = 0;
    unsigned long long int src1 = 0;
#ifdef NOSTOULL
    unsigned long long int dst = 0;
#else
    unsigned long long int dst = std::stoull("0");
#endif

    // Computation
    for (unsigned long long int src0 = 0; src0 < n; ++src0) {
        src1 = dst;
#ifdef NOWRAPPER
        carry = __builtin_ia32_addcarryx_u64(carry, src0, src1, &dst);
#else
        carry = addcarry(carry, src0, src1, &dst);
#endif
    }

    // Finalization
    return dst + carry;
}

// Main function
int main(int argc, char* argv[])
{
    return compute(argc > 1 ? std::stoull(argv[1]) : 1ULL << 31);
}

循环对应的程序集好像有点不一样。在下图中,左侧对应空选项[] [],右侧对应[] [-DNOWRAPPER](左边是"fast",右边是"slow"一个)。

我无法重现您的结果,我已经尝试过 gcc 6.3.0 和 gcc 7.1.0。在这里,您的所有变体 运行 都以相同的速度。但是,出于某种原因,您的拆卸与我的不同。看着你的反汇编,它有一些奇怪的东西。例如,在左侧,在 0x400d7d 处,有一个不必要的内存移动:它可以在循环后被移出。当然,优秀的程序员可以在这里编写更好的代码(这种情况下最好的代码是完全删除循环,并为其应用数学公式)。

在我看来,编译器还不够好。他们变得越来越好(伟大的编译器开发人员!),但有时他们生成的代码远非最佳。

这是我最后的经验:我写了一个霍夫曼解码器。 Clang 生成的代码 运行 几乎比 GCC 的代码快一半。这是一个很大的不同。我检查了反汇编,并且 clang 尝试将两个 32 位变量 "merge" 放入 64 位寄存器(也许它非常努力地避免使用堆栈?)。我稍微修改了代码,然后 clang 代码突然变得比 GCC 的代码快一点。

在创建像您这样的小循环时,每个小细节都很重要。稍微修改代码会导致巨大的速度差异。也许相同的编译代码在不同的处理器上表现不同 (Intel/AMD)。

我的建议是这样:写性能敏感的循环时,尽量把循环放到一个单独的函数中。此函数不应包含任何序言或结尾代码,而应包含循环。这样你可以帮助编译器更好地优化(尽可能有效地使用寄存器)。使用这种技术,我的霍夫曼解码器速度提高了 20%。我并不是说你应该 100% 遵守这条规则,但通常它对我有用。