Visual C++ 优化选项 - 如何提高代码输出?
Visual C++ optimization options - how to improve the code output?
是否有任何选项(除了 /O2)可以改进 Visual C++ 代码输出? MSDN 文档在这方面非常糟糕。
请注意,我不是在询问项目范围的设置(link-时间优化等)。我只对这个特定的例子感兴趣。
相当简单的 C++11 代码如下所示:
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4};
int sum = 0;
for(int i = 0; i < v.size(); i++) {
sum += v[i];
}
return sum;
}
Clang 使用 libc++ 的输出非常紧凑:
main: # @main
mov eax, 10
ret
另一方面,Visual C++ 输出是多页的混乱。
我是不是遗漏了什么或者 VS 真的有这么糟糕吗?
编译器浏览器link:
https://godbolt.org/g/GJYHjE
不幸的是,在这种情况下,即使使用更积极的优化标志,也很难大大改进 Visual C++ 的输出。导致 VS 效率低下的因素有很多,包括缺乏某些编译器优化,以及 Microsoft 实现的结构 <vector>
.
检查生成的程序集,Clang 在优化此代码方面做得非常出色。具体来说,与 VS 相比,Clang 能够执行非常有效的 Constant propagation、Function Inlining(因此,Dead代码消除),以及New/delete优化.
恒定传播
示例中,向量被静态初始化:
std::vector<int> v = {1, 2, 3, 4};
正常情况下,编译器会将常量1,2,3,4存放在数据内存中,而在for循环中,会一次一个地加载一个值,从1所在的低地址开始存储,并将每个值添加到总和中。
这是执行此操作的缩写 VS 代码:
movdqa xmm0, XMMWORD PTR __xmm@00000004000000030000000200000001
...
movdqu XMMWORD PTR $T1[rsp], xmm0 ; Store integers 1, 2, 3, 4 in memory
...
$LL4@main:
add ebx, DWORD PTR [rdx] ; loop and sum the values
lea rdx, QWORD PTR [rdx+4]
inc r8d
movsxd rax, r8d
cmp rax, r9
jb SHORT $LL4@main
然而,Clang 很聪明地意识到可以提前计算总和。我最好的猜测是它取代了将常量从内存加载到常量 mov 操作到寄存器中(传播常量),然后将它们组合成 10 的结果。这具有破坏依赖性的有用副作用,并且由于地址不再从中加载,编译器可以自由地将其他所有内容作为死代码删除。
Clang 在这方面似乎是独一无二的——无论是 VS 还是 GCC 都无法提前预先计算向量累加结果。
New/Delete优化
允许符合 C++14 的编译器在特定条件下省略对 new 和 delete 的调用,特别是当分配调用的次数不是程序可观察行为的一部分时(N3664 标准论文)。
这已经引起了很多关于 SO 的讨论:
- clang vs gcc - optimization including operator new
- Is the compiler allowed to optimize out heap memory allocations?
- Optimization of raw new[]/delete[] vs std::vector
用 -std=c++14 -stdlib=libc++
调用的 Clang 确实执行了此优化并消除了对 new 和 delete 的调用,它们确实会带来副作用,但据推测不会影响程序的可观察行为。使用 -stdlib=libstdc++
,Clang 更加严格并保留对 new 和 delete 的调用 - 尽管通过查看程序集,很明显它们并不是真正需要的。
现在,当检查 VS 生成的 main
代码时,我们可以发现有两个函数调用(其余的向量构造和迭代代码内联到 main
):
call std::vector<int,std::allocator<int> >::_Range_construct_or_tidy<int const * __ptr64>
和
call void __cdecl operator delete(void * __ptr64)
第一个用于分配向量,第二个用于解除分配,实际上 VS 输出中的所有其他函数都由这些函数调用引入。这暗示 Visual C++ 不会优化对分配函数的调用(为了符合 C++14,我们应该添加 /std:c++14
标志,但结果是相同的)。
来自 Visual C++ 团队的 blog post(2017 年 5 月 10 日)确认确实未实现此优化。在页面中搜索 N3664
显示“Avoiding/fusing 分配”的状态为 N/A,链接的评论说:
[E] Avoiding/fusing allocations is permitted but not required. For the time being, we’ve chosen not to implement this.
结合 new/delete 优化和常量传播,很容易看出这两个优化的影响 Compiler Explorer Clang 与 -stdlib=libc++
的 3 向比较,Clang 与 -stdlib=libstdc++
,和海湾合作委员会。
STL 实现
VS 有自己的 STL 实现,它的结构与 libc++ 和 stdlibc++ 非常不同,这似乎对 VS 劣质代码生成有很大贡献。虽然 VS STL 具有一些非常有用的功能,例如检查迭代器和迭代器调试挂钩 (_ITERATOR_DEBUG_LEVEL
),但它给人的总体印象是比 stdlibc++ 更重且执行效率更低。
为了隔离vector STL实现的影响,一个有趣的实验是使用Clang进行编译,结合VS header文件。事实上,使用 Clang 5.0.0 和 Visual Studio 2015 headers,会导致以下代码生成 - 显然, STL 的实施产生了巨大的影响!
main: # @main
.Lfunc_begin0:
.Lcfi0:
.seh_proc main
.seh_handler __CxxFrameHandler3, @unwind, @except
# BB#0: # %.lr.ph
pushq %rbp
.Lcfi1:
.seh_pushreg 5
pushq %rsi
.Lcfi2:
.seh_pushreg 6
pushq %rdi
.Lcfi3:
.seh_pushreg 7
pushq %rbx
.Lcfi4:
.seh_pushreg 3
subq , %rsp
.Lcfi5:
.seh_stackalloc 72
leaq 64(%rsp), %rbp
.Lcfi6:
.seh_setframe 5, 64
.Lcfi7:
.seh_endprologue
movq $-2, (%rbp)
movl , %ecx
callq "??2@YAPEAX_K@Z"
movq %rax, -24(%rbp)
leaq 16(%rax), %rcx
movq %rcx, -8(%rbp)
movups .L.ref.tmp(%rip), %xmm0
movups %xmm0, (%rax)
movq %rcx, -16(%rbp)
movl 4(%rax), %ebx
movl 8(%rax), %esi
movl 12(%rax), %edi
.Ltmp0:
leaq -24(%rbp), %rcx
callq "?_Tidy@?$vector@HV?$allocator@H@std@@@std@@IEAAXXZ"
.Ltmp1:
# BB#1: # %"??1?$vector@HV?$allocator@H@std@@@std@@QEAA@XZ.exit"
addl %ebx, %esi
leal 1(%rdi,%rsi), %eax
addq , %rsp
popq %rbx
popq %rdi
popq %rsi
popq %rbp
retq
.seh_handlerdata
.long ($cppxdata$main)@IMGREL
.text
更新 - Visual Studio 2017
在 Visual Studio 2017 年,<vector>
进行了重大改革,正如 Visual C++ 团队在此 blog post 上宣布的那样。具体来说,它提到了以下优化:
Eliminated unnecessary EH logic. For example, vector’s copy assignment operator had an unnecessary try-catch block. It just has to provide the basic guarantee, which we can achieve through proper action sequencing.
Improved performance by avoiding unnecessary rotate() calls. For example, emplace(where, val) was calling emplace_back() followed by rotate(). Now, vector calls rotate() in only one scenario (range insertion with input-only iterators, as previously described).
Improved performance with stateful allocators. For example, move construction with non-equal allocators now attempts to activate our memmove() optimization. (Previously, we used make_move_iterator(), which had the side effect of inhibiting the memmove() optimization.) Note that a further improvement is coming in VS 2017 Update 1, where move assignment will attempt to reuse the buffer in the non-POCMA non-equal case.
好奇,我回去测试了一下。 Visual Studio 2017年构建示例时,结果还是多页的汇编列表,函数调用很多,所以即使代码生成有所改进,也很难注意到。
但是,当使用 clang 5.0.0 和 Visual Studio 2017 headers 构建时,我们得到以下程序集:
main: # @main
.Lcfi0:
.seh_proc main
# BB#0:
subq , %rsp
.Lcfi1:
.seh_stackalloc 40
.Lcfi2:
.seh_endprologue
movl , %ecx
callq "??2@YAPEAX_K@Z" ; void * __ptr64 __cdecl operator new(unsigned __int64)
movq %rax, %rcx
callq "??3@YAXPEAX@Z" ; void __cdecl operator delete(void * __ptr64)
movl , %eax
addq , %rsp
retq
.seh_handlerdata
.text
请注意 movl , %eax
指令 - 也就是说,使用 VS 2017 的 <vector>
,clang 能够折叠所有内容,pre计算 10 的结果,只保留对 new 和 delete 的调用。
我会说这太棒了!
函数内联
函数内联可能是这个例子中最重要的优化。通过将被调用函数的代码折叠到它们的调用位置,编译器能够对合并后的代码执行进一步的优化,此外,删除函数调用有利于减少调用开销和消除优化障碍。
检查 VS 生成的程序集并比较内联前后的代码 (Compiler Explorer) 时,我们可以看到大多数向量函数确实是内联的,除了分配和释放函数。特别是,有对 memmove
的调用,这是内联某些更高级别函数的结果,例如 _Uninitialized_copy_al_unchecked
.
memmove
是库函数,因此不能内联。然而,clang 有一个聪明的方法来解决这个问题——它将对 memmove
的调用替换为对 __builtin_memmove
的调用。 __builtin_memmove
是一个 builtin/intrinsic 函数,它具有与 memmove
相同的功能,但与普通函数调用相反,编译器会为其生成代码并将其嵌入到调用函数中。因此,代码可以在调用函数内进一步优化,并最终作为死代码删除。
总结
总而言之,在这个例子中,Clang 明显优于 VS,这要归功于高质量的优化和更高效的向量 STL 实现。当为 Visual C++ 和 clang(Visual Studio 2017 headers)使用相同的 header 文件时,Clang 轻而易举地击败了 Visual C++。
在写这个答案的时候,我不由得想,如果没有Compiler Explorer我们怎么办?感谢 Matt Godbolt 提供这个神奇的工具!
是否有任何选项(除了 /O2)可以改进 Visual C++ 代码输出? MSDN 文档在这方面非常糟糕。 请注意,我不是在询问项目范围的设置(link-时间优化等)。我只对这个特定的例子感兴趣。
相当简单的 C++11 代码如下所示:
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4};
int sum = 0;
for(int i = 0; i < v.size(); i++) {
sum += v[i];
}
return sum;
}
Clang 使用 libc++ 的输出非常紧凑:
main: # @main
mov eax, 10
ret
另一方面,Visual C++ 输出是多页的混乱。 我是不是遗漏了什么或者 VS 真的有这么糟糕吗?
编译器浏览器link: https://godbolt.org/g/GJYHjE
不幸的是,在这种情况下,即使使用更积极的优化标志,也很难大大改进 Visual C++ 的输出。导致 VS 效率低下的因素有很多,包括缺乏某些编译器优化,以及 Microsoft 实现的结构 <vector>
.
检查生成的程序集,Clang 在优化此代码方面做得非常出色。具体来说,与 VS 相比,Clang 能够执行非常有效的 Constant propagation、Function Inlining(因此,Dead代码消除),以及New/delete优化.
恒定传播
示例中,向量被静态初始化:
std::vector<int> v = {1, 2, 3, 4};
正常情况下,编译器会将常量1,2,3,4存放在数据内存中,而在for循环中,会一次一个地加载一个值,从1所在的低地址开始存储,并将每个值添加到总和中。
这是执行此操作的缩写 VS 代码:
movdqa xmm0, XMMWORD PTR __xmm@00000004000000030000000200000001
...
movdqu XMMWORD PTR $T1[rsp], xmm0 ; Store integers 1, 2, 3, 4 in memory
...
$LL4@main:
add ebx, DWORD PTR [rdx] ; loop and sum the values
lea rdx, QWORD PTR [rdx+4]
inc r8d
movsxd rax, r8d
cmp rax, r9
jb SHORT $LL4@main
然而,Clang 很聪明地意识到可以提前计算总和。我最好的猜测是它取代了将常量从内存加载到常量 mov 操作到寄存器中(传播常量),然后将它们组合成 10 的结果。这具有破坏依赖性的有用副作用,并且由于地址不再从中加载,编译器可以自由地将其他所有内容作为死代码删除。
Clang 在这方面似乎是独一无二的——无论是 VS 还是 GCC 都无法提前预先计算向量累加结果。
New/Delete优化
允许符合 C++14 的编译器在特定条件下省略对 new 和 delete 的调用,特别是当分配调用的次数不是程序可观察行为的一部分时(N3664 标准论文)。 这已经引起了很多关于 SO 的讨论:
- clang vs gcc - optimization including operator new
- Is the compiler allowed to optimize out heap memory allocations?
- Optimization of raw new[]/delete[] vs std::vector
用 -std=c++14 -stdlib=libc++
调用的 Clang 确实执行了此优化并消除了对 new 和 delete 的调用,它们确实会带来副作用,但据推测不会影响程序的可观察行为。使用 -stdlib=libstdc++
,Clang 更加严格并保留对 new 和 delete 的调用 - 尽管通过查看程序集,很明显它们并不是真正需要的。
现在,当检查 VS 生成的 main
代码时,我们可以发现有两个函数调用(其余的向量构造和迭代代码内联到 main
):
call std::vector<int,std::allocator<int> >::_Range_construct_or_tidy<int const * __ptr64>
和
call void __cdecl operator delete(void * __ptr64)
第一个用于分配向量,第二个用于解除分配,实际上 VS 输出中的所有其他函数都由这些函数调用引入。这暗示 Visual C++ 不会优化对分配函数的调用(为了符合 C++14,我们应该添加 /std:c++14
标志,但结果是相同的)。
来自 Visual C++ 团队的 blog post(2017 年 5 月 10 日)确认确实未实现此优化。在页面中搜索 N3664
显示“Avoiding/fusing 分配”的状态为 N/A,链接的评论说:
[E] Avoiding/fusing allocations is permitted but not required. For the time being, we’ve chosen not to implement this.
结合 new/delete 优化和常量传播,很容易看出这两个优化的影响 Compiler Explorer Clang 与 -stdlib=libc++
的 3 向比较,Clang 与 -stdlib=libstdc++
,和海湾合作委员会。
STL 实现
VS 有自己的 STL 实现,它的结构与 libc++ 和 stdlibc++ 非常不同,这似乎对 VS 劣质代码生成有很大贡献。虽然 VS STL 具有一些非常有用的功能,例如检查迭代器和迭代器调试挂钩 (_ITERATOR_DEBUG_LEVEL
),但它给人的总体印象是比 stdlibc++ 更重且执行效率更低。
为了隔离vector STL实现的影响,一个有趣的实验是使用Clang进行编译,结合VS header文件。事实上,使用 Clang 5.0.0 和 Visual Studio 2015 headers,会导致以下代码生成 - 显然, STL 的实施产生了巨大的影响!
main: # @main
.Lfunc_begin0:
.Lcfi0:
.seh_proc main
.seh_handler __CxxFrameHandler3, @unwind, @except
# BB#0: # %.lr.ph
pushq %rbp
.Lcfi1:
.seh_pushreg 5
pushq %rsi
.Lcfi2:
.seh_pushreg 6
pushq %rdi
.Lcfi3:
.seh_pushreg 7
pushq %rbx
.Lcfi4:
.seh_pushreg 3
subq , %rsp
.Lcfi5:
.seh_stackalloc 72
leaq 64(%rsp), %rbp
.Lcfi6:
.seh_setframe 5, 64
.Lcfi7:
.seh_endprologue
movq $-2, (%rbp)
movl , %ecx
callq "??2@YAPEAX_K@Z"
movq %rax, -24(%rbp)
leaq 16(%rax), %rcx
movq %rcx, -8(%rbp)
movups .L.ref.tmp(%rip), %xmm0
movups %xmm0, (%rax)
movq %rcx, -16(%rbp)
movl 4(%rax), %ebx
movl 8(%rax), %esi
movl 12(%rax), %edi
.Ltmp0:
leaq -24(%rbp), %rcx
callq "?_Tidy@?$vector@HV?$allocator@H@std@@@std@@IEAAXXZ"
.Ltmp1:
# BB#1: # %"??1?$vector@HV?$allocator@H@std@@@std@@QEAA@XZ.exit"
addl %ebx, %esi
leal 1(%rdi,%rsi), %eax
addq , %rsp
popq %rbx
popq %rdi
popq %rsi
popq %rbp
retq
.seh_handlerdata
.long ($cppxdata$main)@IMGREL
.text
更新 - Visual Studio 2017
在 Visual Studio 2017 年,<vector>
进行了重大改革,正如 Visual C++ 团队在此 blog post 上宣布的那样。具体来说,它提到了以下优化:
Eliminated unnecessary EH logic. For example, vector’s copy assignment operator had an unnecessary try-catch block. It just has to provide the basic guarantee, which we can achieve through proper action sequencing.
Improved performance by avoiding unnecessary rotate() calls. For example, emplace(where, val) was calling emplace_back() followed by rotate(). Now, vector calls rotate() in only one scenario (range insertion with input-only iterators, as previously described).
Improved performance with stateful allocators. For example, move construction with non-equal allocators now attempts to activate our memmove() optimization. (Previously, we used make_move_iterator(), which had the side effect of inhibiting the memmove() optimization.) Note that a further improvement is coming in VS 2017 Update 1, where move assignment will attempt to reuse the buffer in the non-POCMA non-equal case.
好奇,我回去测试了一下。 Visual Studio 2017年构建示例时,结果还是多页的汇编列表,函数调用很多,所以即使代码生成有所改进,也很难注意到。
但是,当使用 clang 5.0.0 和 Visual Studio 2017 headers 构建时,我们得到以下程序集:
main: # @main
.Lcfi0:
.seh_proc main
# BB#0:
subq , %rsp
.Lcfi1:
.seh_stackalloc 40
.Lcfi2:
.seh_endprologue
movl , %ecx
callq "??2@YAPEAX_K@Z" ; void * __ptr64 __cdecl operator new(unsigned __int64)
movq %rax, %rcx
callq "??3@YAXPEAX@Z" ; void __cdecl operator delete(void * __ptr64)
movl , %eax
addq , %rsp
retq
.seh_handlerdata
.text
请注意 movl , %eax
指令 - 也就是说,使用 VS 2017 的 <vector>
,clang 能够折叠所有内容,pre计算 10 的结果,只保留对 new 和 delete 的调用。
我会说这太棒了!
函数内联
函数内联可能是这个例子中最重要的优化。通过将被调用函数的代码折叠到它们的调用位置,编译器能够对合并后的代码执行进一步的优化,此外,删除函数调用有利于减少调用开销和消除优化障碍。
检查 VS 生成的程序集并比较内联前后的代码 (Compiler Explorer) 时,我们可以看到大多数向量函数确实是内联的,除了分配和释放函数。特别是,有对 memmove
的调用,这是内联某些更高级别函数的结果,例如 _Uninitialized_copy_al_unchecked
.
memmove
是库函数,因此不能内联。然而,clang 有一个聪明的方法来解决这个问题——它将对 memmove
的调用替换为对 __builtin_memmove
的调用。 __builtin_memmove
是一个 builtin/intrinsic 函数,它具有与 memmove
相同的功能,但与普通函数调用相反,编译器会为其生成代码并将其嵌入到调用函数中。因此,代码可以在调用函数内进一步优化,并最终作为死代码删除。
总结
总而言之,在这个例子中,Clang 明显优于 VS,这要归功于高质量的优化和更高效的向量 STL 实现。当为 Visual C++ 和 clang(Visual Studio 2017 headers)使用相同的 header 文件时,Clang 轻而易举地击败了 Visual C++。
在写这个答案的时候,我不由得想,如果没有Compiler Explorer我们怎么办?感谢 Matt Godbolt 提供这个神奇的工具!