我需要对两个解决方案进行性能测试 - 一个使用多态来执行开启类型,另一个使用switch case来选择执行哪些函数。我真的需要优化此代码。我编写了以下测试用例(您可以简单地复制粘贴代码,使用g++ -std=c++14 -O3
编译它并使用echo 1 | ./a.out
运行它!)如果您阅读它,代码非常简单!
#include <iostream>
#include <chrono>
#include <functional>
#include <array>
#include <cassert>
#include <vector>
#include <memory>
using namespace std;
struct profiler
{
std::string name;
std::chrono::high_resolution_clock::time_point p;
profiler(std::string const &n) :
name(n), p(std::chrono::high_resolution_clock::now()) { }
~profiler()
{
using dura = std::chrono::duration<double>;
auto d = std::chrono::high_resolution_clock::now() - p;
std::cout << name << ": "
<< std::chrono::duration_cast<dura>(d).count()
<< std::endl;
}
};
#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)
class Base {
public:
virtual int increment(int in) {
return in + 2;
}
};
class Derived : public Base {
public:
int increment(int in) override {
return ++in;
}
};
int increment_one(int in) {
return in + 2;
}
int increment_two(int in) {
return ++in;
}
int increment_three(int in) {
return in + 4;
}
int increment_four(int in) {
return in + 2;
}
static constexpr unsigned long long NUMBER_LOOP{5000000000};
int main() {
int which_function;
cin >> which_function;
{
PROFILE_BLOCK("nothing");
}
{
PROFILE_BLOCK("switch case");
auto counter = 0;
for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
switch(which_function) {
case 0:
counter = increment_one(counter);
break;
case 1:
counter = increment_two(counter);
break;
case 2:
counter = increment_three(counter);
break;
case 3:
counter = increment_four(counter);
break;
default:
assert(false);
break;
}
}
cout << counter << endl;
}
{
PROFILE_BLOCK("polymorphism");
auto counter = 0;
std::unique_ptr<Base> ptr_base{new Derived()};
for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
counter = ptr_base->increment(counter);
}
}
return 0;
}
我使用g++ -std=c++14 -O3
构建并使用echo 1 | ./a.out
运行时获得的输出是
nothing: 1.167e-06
705032704
switch case: 4.089e-06
polymorphism: 9.299
我无法理解究竟是什么原因导致switch-case几乎和nothing
情况一样快。这是因为内联吗?是因为编译器预先计算每个输入方案的值并将它们放在查找表中?是什么原因导致开关盒如此之快?
我怎样才能为这种情况编写更公平的性能测试?一般来说,我永远无法理解代码是否快速,因为C ++代码和汇编之间的直接未经优化的转换,或者它的编译器是否预先计算了一个值并完全跳过编译并生成&#34; no-op style&#34;码。
注意 profiler
结构已被直接从另一个SO答案中复制,除了测量时间之外与问题无关
注意正如@dau_sama在下面的评论中指出的那样,在带有gcc而不是clang的linux机器上运行相同的测试会导致交换机案例花费更长时间(在这种情况下为3.34)但仍然比多态性案例要小得多。
答案 0 :(得分:6)
代码的问题是,当你做这样的基准测试时,为了得到有意义的结果,你不能简单地使用for循环和大数字。使用-O3优化进行编译时,允许编译器将计算提升出循环,执行循环展开等类似的事情,在编译时计算结果并将其硬编码到二进制文件中。因为在“as-if”规则下你无法区分。这使得很难像这样对微小的代码进行基准测试,但也是优化器工作,以使代码尽可能快。如果优化器可以看到你只是一遍又一遍地做同样的事情,它可能会将所有计算折叠在一起并击败基准机制。
要解决这个问题,你基本上需要混淆基准测试循环和基准测试框架的某些部分,这样编译器就不敢展开循环,或者试图分析跨应该是什么独立运行被测试代码。
在我修改的代码版本中,我使用了google基准测试库中的两位代码。理解这里发生的事情的最好方法是,观看Chandler Carruth在2015年CppNow上的精彩演讲。https://www.youtube.com/watch?v=nXaxk27zwlk
简而言之,添加的是两个内联汇编指令,“DoNotOptimize
”和“ClobberMemory
”。这些是空的汇编块,并且在编译的代码中没有实际的指令,但是它们被标记为asm volatile
,它通知优化器它们具有不可知的副作用并且它不应该尝试分析组件本身。 "memory"
指令意味着它们可能读/写所有内存地址。任何标记为“DoNotOptimize
”的变量都被认为是对此程序集“已知”,因此当调用这些函数中的任何一个时,该变量实际上会从优化程序的推理中“加扰” - 即使这些是空集合的指令,需要假设在调用这些函数后,值可能以不可知的方式发生了变化,因此循环展开和其他类型的优化变得不健全。
这是我修改后的代码版本和ouptut:
#include <iostream>
#include <chrono>
#include <functional>
#include <array>
#include <cassert>
#include <vector>
#include <memory>
using namespace std;
// From google benchmarks framework
// See also Chandler Carruth's talk on microoptimizations and benchmarking
// https://www.youtube.com/watch?v=nXaxk27zwlk
namespace bench {
#if defined(__GNUC__)
#define BENCHMARK_ALWAYS_INLINE __attribute__((always_inline))
#else
#define BENCHMARK_ALWAYS_INLINE
#endif
template <class Tp>
inline BENCHMARK_ALWAYS_INLINE void
DoNotOptimize(Tp const & value) {
asm volatile("" : : "g"(value) : "memory");
}
inline BENCHMARK_ALWAYS_INLINE void
ClobberMemory() {
asm volatile("" : : : "memory");
}
} // end namespace bench
struct profiler
{
std::string name;
std::chrono::high_resolution_clock::time_point p;
profiler(std::string const &n) :
name(n), p(std::chrono::high_resolution_clock::now()) { }
~profiler()
{
using dura = std::chrono::duration<double>;
auto d = std::chrono::high_resolution_clock::now() - p;
std::cout << name << ": "
<< std::chrono::duration_cast<dura>(d).count()
<< std::endl;
}
};
#define PROFILE_BLOCK(pbn) profiler _pfinstance(pbn)
class Base {
public:
virtual int increment(int in) {
return in + 2;
}
};
class Derived : public Base {
public:
int increment(int in) override {
return ++in;
}
};
int increment_one(int in) {
return in + 2;
}
int increment_two(int in) {
return ++in;
}
int increment_three(int in) {
return in + 4;
}
int increment_four(int in) {
return in + 2;
}
static constexpr unsigned long long NUMBER_LOOP{5000000000};
int main() {
int which_function;
cin >> which_function;
{
PROFILE_BLOCK("nothing");
}
{
PROFILE_BLOCK("switch case");
auto counter = 0;
bench::DoNotOptimize(counter);
for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
bench::DoNotOptimize(i);
switch(which_function) {
case 0:
counter = increment_one(counter);
break;
case 1:
counter = increment_two(counter);
break;
case 2:
counter = increment_three(counter);
break;
case 3:
counter = increment_four(counter);
break;
default:
assert(false);
break;
}
bench::ClobberMemory();
}
cout << counter << endl;
}
{
PROFILE_BLOCK("polymorphism");
auto counter = 0;
bench::DoNotOptimize(counter);
std::unique_ptr<Base> ptr_base{new Derived()};
for (unsigned long long i = 0; i < NUMBER_LOOP; ++i) {
bench::DoNotOptimize(i);
counter = ptr_base->increment(counter);
bench::ClobberMemory();
}
}
return 0;
}
这是我运行时得到的结果:
$ g++ -std=c++14 main.cpp
$ echo 1 |./a.out
nothing: 3.864e-06
705032704
switch case: 20.385
polymorphism: 91.0152
$ g++ -std=c++14 -O3 main.cpp
$ echo 1 |./a.out
nothing: 6.74e-07
705032704
switch case: 4.59485
polymorphism: 2.5395
实际上我对此感到非常惊讶,我认为开关盒应该总是更快。所以也许需要调整混淆指令,或者我可能是错的。
要尝试了解区别,您可以查看生成的程序集。你可以像Chandler那样使用perf
做到这一点,或者使用像godbolt这样的东西。
这是godbolt gcc of your code的链接。我没有读完所有内容,但有一点让我感到高兴的是,在本节中:
pushq %r13
pushq %r12
leaq 16(%rdi), %r12
pushq %rbp
pushq %rbx
subq $24, %rsp
testq %rsi, %rsi
movq %r12, (%rdi)
je .L5
movq %rdi, %rbx
movq %rsi, %rdi
movq %rsi, %r13
call strlen
cmpq $15, %rax
movq %rax, %rbp
movq %rax, 8(%rsp)
ja .L16
cmpq $1, %rax
je .L17
testq %rax, %rax
jne .L18
.L9:
movq 8(%rsp), %rax
movq (%rbx), %rdx
movq %rax, 8(%rbx)
movb $0, (%rdx,%rax)
addq $24, %rsp
popq %rbx
popq %rbp
popq %r12
popq %r13
ret
.L16:
leaq 8(%rsp), %rsi
xorl %edx, %edx
movq %rbx, %rdi
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_create(unsigned long&, unsigned long)
movq 8(%rsp), %rdx
movq %rax, (%rbx)
movq %rax, %rdi
movq %rdx, 16(%rbx)
.L7:
movq %rbp, %rdx
movq %r13, %rsi
call memcpy
jmp .L9
.L17:
movzbl 0(%r13), %eax
movb %al, 16(%rbx)
jmp .L9
.L5:
movl $.LC3, %edi
call std::__throw_logic_error(char const*)
.L18:
您有这些连续的跳转指令:ja .L16
,je .L17
,jne .L18
。所以我认为这可能是你的switch
陈述。但是当你看到这些语句跳回去的地方时,它们都跳回到.L9,它不会通过switch
语句返回。所以我怀疑优化器正在做的是在你的循环之外提升switch
,这允许它为每个可能的输入轻松计算循环的输出结果,并使基准出现在零时间运行。
另一方面,当我查看为我的版本生成的程序集时,它仍然具有相同的.L16
,.L17
和.L18
跳转,它们都会跳转到{{ 1}}。所以......我不确定它到底意味着什么。但希望这有助于你弄明白。
编辑:
根据@Holt发表的评论,我调整了你的代码,使.L9
案例更好地匹配virtual
案例,这样就有四个派生类和一个抽象基类。这让我的结果更符合我的预期。我能给出的最好的解释是,可能只有一个派生类,编译器能够执行“devirtualization”或其他东西。例如,switch
的现代版本将在传递gcc
时进行链接时优化。
结果:
-O3
调整后的代码:
$ g++ -std=c++14 -O3 main.cpp
$ echo 1|./a.out
nothing: 4.92e-07
705032704
switch case: 4.56484
polymorphism: 9.16065
$ echo 2|./a.out
nothing: 6.25e-07
-1474836480
switch case: 5.31955
polymorphism: 9.22714
$ echo 3|./a.out
nothing: 5.42e-07
1410065408
switch case: 3.91608
polymorphism: 9.17771
答案 1 :(得分:1)
我得到了不同的结果:
1)。没有优化
$ g++ -std=c++11 -O0 perf.cpp
$ ./a.out
2
nothing: 1.761e-06
18446744072234715136
switch case: 25.1785
polymorphism: 110.119
这个结果是正常的。 调用虚函数必须在虚函数表上进行搜索操作,但调用非虚函数没有此搜索步骤。
2)。使用O3优化
$g++ -std=c++11 -O3 perf.cpp
$ ./a.out
2
nothing: 1.44e-07
18446744072234715136
switch case: 8.4832
polymorphism: 3.34942
好吧,这个结果让我很吃惊,但这也很正常
类声明中定义的函数将被内联,编译器可能在编译时获取虚函数地址。
如果你真的想知道细节,请阅读汇编代码,也许你可以使用clang,阅读比汇编代码更易读的IR代码。 只需您的代码,删除不相关的代码:
class Base {
public:
virtual int increment(int in) {
return in + 2;
}
};
class Derived : public Base {
public:
int increment(int in) override {
return ++in;
}
};
int increment_two(int in) {
return ++in;
}
int main() {
int which_function = 2;
int NUMBER_LOOP = 1;
Base* ptr_base{new Derived()};
for (long i = 0; i < NUMBER_LOOP; ++i) {
switch(which_function) {
case 2:
increment_two(1);
break;
}
}
for (long i = 0; i < NUMBER_LOOP; ++i) {
ptr_base->increment(1);
}
return 0;
}
$ g ++ -std = c ++ 11 -O0 = 3 code.cpp -S
你可以阅读code.s
using clang:
$ clang -std=c++11 -O3 -S -emit-llvm code.cpp
here post the clang IR code:
; ModuleID = 'xp.cpp'
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
; Function Attrs: norecurse nounwind readnone uwtable
define i32 @_Z13increment_twoi(i32 %in) #0 {
entry:
%inc = add nsw i32 %in, 1
ret i32 %inc
}
; Function Attrs: norecurse uwtable
define i32 @main() #1 {
entry:
ret i32 0
}
attributes #0 = { norecurse nounwind readnone uwtable "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { norecurse uwtable "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2" "unsafe-fp-math"="false" "use-soft-float"="false" }
!llvm.ident = !{!0}
!0 = !{!"clang version 3.8.0 (tags/RELEASE_380/final)"}
答案 2 :(得分:0)
我对编译器优化的错综复杂的细节知之甚少,但我可以想象你的平台上的巨大差异是编译器改变开关和循环的顺序的结果,导致代码相当于:
switch(which_function) {
case 1: // execute increment_one NUMBER_LOOP times
case 2: // execute increment_two NUMBER_LOOP times
...
}
鉴于您的增量函数很简单,编译器可以内联这些函数:
switch(which_function) {
case 1: count += 2*NUMBER_LOOP; break;
case 2: count += NUMBER_LOOP; break;
...
}
这是非常简单的代码,可以解释平台上交换机案例的执行时间较短。如评论中所述,制作count
变量volatile
显然会禁用此优化。
当然,调查此问题的唯一方法是查看已编译的二进制文件。