如何在 GCC 和 VS 中强制使用 CMOV
how to force the use of cmov in gcc and VS
我有一个简单的二叉搜索成员函数,其中lastIndex
、nIter
和xi
是类成员:
uint32 scalar(float z) const
{
uint32 lo = 0;
uint32 hi = lastIndex;
uint32 n = nIter;
while (n--) {
int mid = (hi + lo) >> 1;
// defining this if-else assignment as below cause VS2015
// to generate two cmov instructions instead of a branch
if( z < xi[mid] )
hi = mid;
if ( !(z < xi[mid]) )
lo = mid;
}
return lo;
}
gcc 和 VS 2015 都使用代码流分支转换内部循环:
000000013F0AA778 movss xmm0,dword ptr [r9+rax*4]
000000013F0AA77E comiss xmm0,xmm1
000000013F0AA781 jbe Tester::run+28h (013F0AA788h)
000000013F0AA783 mov r8d,ecx
000000013F0AA786 jmp Tester::run+2Ah (013F0AA78Ah)
000000013F0AA788 mov edx,ecx
000000013F0AA78A mov ecx,r8d
有没有办法在不内联编写汇编程序的情况下说服他们准确地使用 1comiss
指令和 2cmov
指令?
如果没有,任何人都可以建议如何为此编写 gcc 汇编程序模板吗?
请注意,我知道二叉搜索算法存在变体,编译器很容易生成分支自由代码,但这不是问题。
谢谢
正如Matteo Italia已经指出的那样,这种避免有条件移动指令的做法是GCC版本6的一个怪癖。但是,他没有注意到的是,它仅适用于针对英特尔处理器进行优化的情况。
在GCC 6.3中,当针对AMD处理器(即-march=
k8
、k10
、opteron
、amdfam10
、btver1
、bdver1
、btver2
、btver2
、bdver3
、bdver4
、znver1
等中的任何一个)时,你会得到你想要的代码:
mov esi, DWORD PTR [rdi]
mov ecx, DWORD PTR [rdi+4]
xor eax, eax
jmp .L2
.L7:
lea edx, [rax+rsi]
mov r8, QWORD PTR [rdi+8]
shr edx
mov r9d, edx
movss xmm1, DWORD PTR [r8+r9*4]
ucomiss xmm1, xmm0
cmovbe eax, edx
cmova esi, edx
.L2:
dec ecx
cmp ecx, -1
jne .L7
rep ret
在针对任何一代英特尔处理器进行优化时,GCC 6.3 避免了条件移动,而更喜欢显式分支:
mov r9d, DWORD PTR [rdi]
mov ecx, DWORD PTR [rdi+4]
xor eax, eax
.L2:
sub ecx, 1
cmp ecx, -1
je .L6
.L8:
lea edx, [rax+r9]
mov rsi, QWORD PTR [rdi+8]
shr edx
mov r8d, edx
vmovss xmm1, DWORD PTR [rsi+r8*4]
vucomiss xmm1, xmm0
ja .L4
sub ecx, 1
mov eax, edx
cmp ecx, -1
jne .L8
.L6:
ret
.L4:
mov r9d, edx
jmp .L2
此优化决策的可能理由是,有条件的移动在英特尔处理器上效率相当低下。CMOV
在英特尔处理器上的延迟为 2 个时钟周期,而 AMD 上的延迟为 1 个周期。此外,虽然CMOV
指令在英特尔处理器上被解码为多个μop(至少两个,没有μop融合的机会),因为要求单个μop具有不超过两个输入依赖项(条件移动至少有三个:两个操作数和条件标志),AMD处理器可以通过单个宏操作实现CMOV
,因为它们的设计对单个宏操作。因此,GCC 优化器仅在AMD 处理器上用有条件的移动替换分支,这可能是性能上的胜利——在英特尔处理器上不是,在针对通用 x86 进行调优时也不是。
(或者,也许GCC开发人员只是阅读了Linus臭名昭著的咆哮。
然而,有趣的是,当你告诉 GCC 针对奔腾 4 处理器进行调整时(由于某种原因,你不能为 64 位版本执行此操作 - GCC 告诉你这个架构不支持 64 位,即使肯定有 P4 处理器实现了 EMT64),你确实得到了条件的移动:
push edi
push esi
push ebx
mov esi, DWORD PTR [esp+16]
fld DWORD PTR [esp+20]
mov ebx, DWORD PTR [esi]
mov ecx, DWORD PTR [esi+4]
xor eax, eax
jmp .L2
.L8:
lea edx, [eax+ebx]
shr edx
mov edi, DWORD PTR [esi+8]
fld DWORD PTR [edi+edx*4]
fucomip st, st(1)
cmovbe eax, edx
cmova ebx, edx
.L2:
sub ecx, 1
cmp ecx, -1
jne .L8
fstp st(0)
pop ebx
pop esi
pop edi
ret
我怀疑这是因为分支错误预测在奔腾 4 上非常昂贵,因为它的管道非常长,以至于单个错误预测分支的可能性超过了您可能从破坏循环携带的依赖项中获得的任何微小收益以及从CMOV
增加的少量延迟。换句话说:错误预测的分支在P4上变得慢得多,但CMOV
的延迟没有改变,所以这偏向于等式,有利于条件移动。
针对后来的架构进行调整,从Nocona到Haswell,GCC 6.3回到了其首选分支而不是条件移动的策略。
因此,尽管在紧密的内部循环背景下,这看起来像是一个重大的悲观情绪(对我来说也是如此),但如果没有基准来支持您的假设,请不要那么快就立即将其驳回。有时,优化器并不像看起来那么愚蠢。请记住,有条件移动的优点是它避免了分支错误预测的惩罚;条件移动的缺点是它增加了依赖关系链的长度,并且可能需要额外的开销,因为在 x86 上,只允许寄存器→寄存器或内存→寄存器条件移动(不允许常量→寄存器)。在这种情况下,所有内容都已注册,但仍需要考虑依赖项链的长度。Agner Fog在他的《汇编语言中的优化子例程》中为我们提供了以下经验法则:
[W]我们可以说,如果代码是依赖链的一部分并且预测率优于 75%,则条件跳转比条件移动更快。如果我们可以避免冗长的计算,条件跳转也是首选......选择另一个操作数时。
第二部分在这里不适用,但第一部分适用。这里肯定有一个循环携带的依赖链,除非你进入一个真正病态的案例,破坏分支预测(通常具有>90%的准确率),否则分支实际上可能会更快。事实上,Agner Fog继续说道:
循环携带的依赖链对条件移动的缺点特别敏感。例如,[此代码]
// Example 12.16a. Calculate pow(x,n) where n is a positive integer double x, xp, power; unsigned int n, i; xp=x; power=1.0; for (i = n; i != 0; i >>= 1) { if (i & 1) power *= xp; xp *= xp; }
与条件移动相比,使用循环内的分支更有效,即使分支的预测不佳。这是因为浮点条件移动添加到循环携带的依赖链中,并且因为具有条件移动的实现必须计算所有
power*xp
值,即使它们未被使用。循环携带依赖链的另一个示例是排序列表中的二叉搜索。如果要搜索的项目随机分布在整个列表中,则分支预测率将接近 50%,并且使用条件移动会更快。但是,如果项目通常彼此靠近,以便预测率会更好,那么使用条件跳转比条件移动更有效,因为每次进行正确的分支预测时,依赖链都会被破坏。
如果列表中的项目实际上是随机的或接近随机的,那么您将成为重复分支预测失败的受害者,并且条件移动会更快。否则,在可能更常见的情况下,分支预测将在>75% 的时间内成功,这样您将体验到分支带来的性能优势,而不是扩展依赖项链的条件移动。
从理论上讲,很难对此进行推理,更难正确猜测,因此您需要实际使用现实世界的数字对其进行基准测试。
如果您的基准测试确认有条件的移动确实会更快,您有以下几种选择:
- 升级到更高版本的 GCC,如 7.1,即使在面向英特尔处理器时,也能在 64 位版本中生成条件移动。
- 告诉 GCC 6.3 针对 AMD 处理器优化您的代码。(甚至可能只是让它优化一个特定的代码模块,以尽量减少全局影响。
变得非常有创意(而且丑陋且可能不可移植),用 C 编写一些有点麻烦的代码,无分支地执行比较和设置操作。这可能会让编译器发出条件移动指令,或者可能会让编译器发出一系列位抖动指令。您必须检查输出以确保,但是如果您的目标真的只是为了避免分支错误预测惩罚,那么两者都会起作用。
例如,像这样:
inline uint32 ConditionalSelect(bool condition, uint32 value1, uint32 value2) { const uint32 mask = condition ? static_cast<uint32>(-1) : 0; uint32 result = (value1 ^ value2); // get bits that differ between the two values result &= mask; // select based on condition result ^= value2; // condition ? value1 : value2 return result; }
然后,您将在内部循环中调用它,如下所示:
hi = ConditionalSelect(z < xi[mid], mid, hi); lo = ConditionalSelect(z < xi[mid], lo, mid);
GCC 6.3 在面向 x86-64 时为此生成以下代码:
mov rdx, QWORD PTR [rdi+8] mov esi, DWORD PTR [rdi] test edx, edx mov eax, edx lea r8d, [rdx-1] je .L1 mov r9, QWORD PTR [rdi+16] xor eax, eax .L3: lea edx, [rax+rsi] shr edx mov ecx, edx mov edi, edx movss xmm1, DWORD PTR [r9+rcx*4] xor ecx, ecx ucomiss xmm1, xmm0 seta cl // <-- begin our bit-twiddling code xor edi, esi xor eax, edx neg ecx sub r8d, 1 // this one's not part of our bit-twiddling code! and edi, ecx and eax, ecx xor esi, edi xor eax, edx // <-- end our bit-twiddling code cmp r8d, -1 jne .L3 .L1: rep ret
请注意,内部循环是完全无分支的,这正是您想要的。它可能不如两个
成外行。CMOV
指令有效,但它会比长期错误预测的分支更快。(不言而喻,GCC 和任何其他编译器将足够智能,可以内联ConditionalSelect
函数,这允许我们出于可读性目的将其写
但是,我绝对不建议您使用内联程序集重写循环的任何部分。所有标准原因都适用于避免内联装配,但在这种情况下,即使是对提高性能的渴望也不是使用它的令人信服的理由。如果您尝试将内联程序集扔到该循环的中间,则更有可能混淆编译器的优化器,从而导致低于标准的代码比您只是将编译器留给自己的设备时更糟糕的代码。您可能必须以内联程序集编写整个函数才能获得良好的结果,即使这样,当 GCC 的优化器尝试内联函数时,也可能会产生溢出效应。
MSVC呢?好吧,不同的编译器具有不同的优化器,因此具有不同的代码生成策略。如果您一心想哄骗所有目标编译器发出特定的汇编代码序列,事情可能会很快开始变得非常丑陋。
在 MSVC 19 (VS 2015) 上,当面向 32 位时,可以按照获取条件移动指令的方式编写代码。但是这在构建 64 位二进制文件时不起作用:您可以获得分支,就像针对英特尔的 GCC 6.3 一样。
不过,有一个很好的解决方案,效果很好:使用条件运算符。换句话说,如果你像这样编写代码:
hi = (z < xi[mid]) ? mid : hi;
lo = (z < xi[mid]) ? lo : mid;
那么VS 2013和2015将始终发出CMOV
指令,无论您是构建32位还是64位二进制文件,无论您是针对大小(/O1
)还是速度(/O2
)进行优化,无论您是针对英特尔(/favor:Intel64
)还是AMD(/favor:AMD64
进行优化。
这确实无法在VS 2010上产生CMOV
指令,但仅在构建64位二进制文件时。如果需要确保此方案也生成无分支代码,则可以使用上述ConditionalSelect
函数。
正如评论中所说,没有简单的方法来强迫你问什么,尽管似乎最近的(>4.4)版本的 gcc 已经像你说的那样优化了它。编辑:有趣的是,GCC 6系列似乎使用了一个分支,不像GCC 5和GCC 7系列都使用两个cmov
。
通常的__builtin_expect
可能无法推动 gcc 使用cmov
,因为当难以预测比较结果时,cmov
通常很方便,而__builtin_expect
告诉编译器可能的结果是什么 - 所以你只会把它推向错误的方向。
不过,如果你发现这个优化非常重要,你的编译器版本通常会出错,并且由于某种原因你不能用 PGO 帮助它,相关的 gcc 程序集模板应该是这样的:
__asm__ (
"comiss %[xi_mid],%[z]n"
"cmovb %[mid],%[hi]n"
"cmovae %[mid],%[lo]n"
: [hi] "+r"(hi), [lo] "+r"(lo)
: [mid] "rm"(mid), [xi_mid] "xm"(xi[mid]), [z] "x"(z)
: "cc"
);
使用的约束包括:
hi
和lo
都进入了"write"变量列表,+r
约束cmov
只能使用寄存器作为目标操作数,并且我们有条件地覆盖其中一个(我们不能使用=
,因为它意味着该值总是被覆盖,因此编译器可以自由地为我们提供与当前寄存器不同的目标寄存器, 并使用它来引用我们asm
块之后的变量);mid
在"读取"列表中,rm
ascmov
可以将寄存器或内存操作数作为输入值;xi[mid]
和z
都在"读取"列表中;z
具有特殊的x
约束,表示"任何 SSE 寄存器"(ucomiss
第一个操作数需要);xi[mid]
具有xm
,因为第二个ucomiss
操作数允许内存运算符; 给定在z
和xi[mid]
之间进行选择,我选择了最后一个作为直接从内存中获取的更好候选者,因为z
已经在寄存器中(由于 System V 调用约定 - 并且无论如何都会在迭代之间缓存),并且xi[mid]
仅用于此比较;
cc
(FLAGS寄存器)在"clobber"列表中 - 我们确实破坏了标志,没有别的。
- 在VS代码中交叉编译Windows与Linux上的MinGW的SDL程序
- 如何为模板化对象创建模板向量?VS正在投掷C3203
- 数据成员SFINAE的C++17测试:gcc vs clang
- 为什么在Windows上的VS 2019和Clang 9中"size_t"在没有标题的情况下工作
- 在for循环中使用auto vs decltype(vec.size())来处理字符串的向量
- 正在VS调试器中监视映射条目
- Confusion: decltype vs std::function
- 将IBM Rhapsody模型集成到VS 2019中
- VS Code "command":"make"与终端窗口中的命令行"make"不同
- 使用VS Code和CMake Tools运行自定义命令
- 修改 VS Code 中的默认C++代码段
- 如何使用c++在VS 2019上运行SQL查询
- vs 2015 constexpr变量不恒定,但与2019相比还好吗
- 完美前进使用 std::forward vs RefRefCast
- 从VS 2015更新3更新到VS2015更新3 d后浮点计算行为不同的原因
- VS 2015 链接错误 无法构建依赖于 libcurl 的项目
- consteval wrapper vs. source_location
- VS Code C++:不准确的系统包括路径错误(wchar.h,boost/lambda/lambda.hpp)
- QStringList vs list<shared_ptr<QString>> 性能比较C++
- 如何在 GCC 和 VS 中强制使用 CMOV