在 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
我们可以注意到:
- 选项 (1) 和 (2) 在统计上相似
- 选项 (3)(带有包装函数)比选项 (1) 和 (2)(没有包装函数)稍微快一些
- 选项 (4) 比其他选项快得多
如何解释这些结果,尤其是最后一个(这对我来说毫无意义)。如何使用 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% 遵守这条规则,但通常它对我有用。
考虑以下对 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
我们可以注意到:
- 选项 (1) 和 (2) 在统计上相似
- 选项 (3)(带有包装函数)比选项 (1) 和 (2)(没有包装函数)稍微快一些
- 选项 (4) 比其他选项快得多
如何解释这些结果,尤其是最后一个(这对我来说毫无意义)。如何使用 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% 遵守这条规则,但通常它对我有用。