Why am I observing multiple inheritance to be faster than single?
我有以下两个文件:
单一CPP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | #include <iostream> #include <stdlib.h> using namespace std; unsigned long a=0; class A { public: virtual int f() __attribute__ ((noinline)) { return a; } }; class B : public A { public: virtual int f() __attribute__ ((noinline)) { return a; } void g() __attribute__ ((noinline)) { return; } }; int main() { cin>>a; A* obj; if (a>3) obj = new B(); else obj = new A(); unsigned long result=0; for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result+=obj->f(); } } cout<<result<<" "; } |
和
CPP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | #include <iostream> #include <stdlib.h> using namespace std; unsigned long a=0; class A { public: virtual int f() __attribute__ ((noinline)) { return a; } }; class dummy { public: virtual void g() __attribute__ ((noinline)) { return; } }; class B : public A, public dummy { public: virtual int f() __attribute__ ((noinline)) { return a; } virtual void g() __attribute__ ((noinline)) { return; } }; int main() { cin>>a; A* obj; if (a>3) obj = new B(); else obj = new A(); unsigned long result=0; for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result+=obj->f(); } } cout<<result<<" "; } |
我使用的是GCC 3.4.6版,带有标志-O2
这就是我得到的计时结果:
倍数:
1 2 3 | real 0m8.635s user 0m8.608s sys 0m0.003s |
单身:
1 2 3 | real 0m10.072s user 0m10.045s sys 0m0.001s |
另一方面,如果在multiple.cpp中我颠倒了类派生的顺序,那么:
1 | class B : public dummy, public A { |
然后,我得到以下计时(这比单继承的计时要慢一些,因为代码需要对这个指针进行"thunk"调整,人们可能会预料到这一点):。-
1 2 3 | real 0m11.516s user 0m11.479s sys 0m0.002s |
知道为什么会这样吗?就循环而言,这三种情况下生成的程序集似乎没有任何区别。还有别的地方需要我看吗?
另外,我已经将进程绑定到一个特定的CPU核心,并且使用sched_r rr以实时优先级运行它。
编辑:这是由神秘主义注意到的,并由我复制。做某事
1 | cout <<"vtable:" << *(void**)obj << endl; |
就在single.cpp中的循环导致single的速度与8.4 s中的多个时钟一样快,就像public a、public dummy一样。
注意,这个答案是非常投机的。
与我对"为什么x比y慢"这类问题的其他一些答案不同,我无法提供可靠的证据来支持这个答案。
在修改了一个小时之后,我认为这是由于三件事情的地址对齐:
obj 的地址A 虚拟方法表的地址f() 功能地址
(owagh的回答也暗示了指令对齐的可能性。)
多重继承比单一继承慢的原因并不是因为它"神奇地"快,而是因为单一继承案例遇到了编译器或硬件"问题"。
如果为单个和多个继承案例转储程序集,那么它们在嵌套循环中是相同的(寄存器名和所有内容)。
这是我编译的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | #include <iostream> #include <stdlib.h> #include <time.h> using namespace std; unsigned long a=0; #ifdef SINGLE class A { public: virtual int f() { return a; } }; class B : public A { public: virtual int f() { return a; } void g() { return; } }; #endif #ifdef MULTIPLE class A { public: virtual int f() { return a; } }; class dummy { public: virtual void g() { return; } }; class B : public A, public dummy { public: virtual int f() { return a; } virtual void g() { return; } }; #endif int main() { cin >> a; A* obj; if (a > 3) obj = new B(); else obj = new A(); unsigned long result = 0; clock_t time0 = clock(); for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result += obj->f(); } } clock_t time1 = clock(); cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl; cout << result <<" "; system("pause"); // This is useless in Linux, but I left it here for a reason. } |
嵌套循环的程序集在单继承和多继承情况下都是相同的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | .L5: call clock movl $65535, %r13d movq %rax, %r14 xorl %r12d, %r12d .p2align 4,,10 .p2align 3 .L6: movl $65535, %ebx .p2align 4,,10 .p2align 3 .L7: movq 0(%rbp), %rax movq %rbp, %rdi call *(%rax) cltq addq %rax, %r12 subl $1, %ebx jne .L7 subl $1, %r13d jne .L6 call clock |
然而,我看到的性能差异是:
- 单程:9.4秒
- 倍数:8.06秒
Xeon X5482,Ubuntu,GCC 4.6.1 X64。
这使我得出结论,即差异必须依赖于数据。
如果查看该程序集,您将注意到唯一可能具有可变延迟的指令是加载:
1 2 3 4 5 | ; %rbp = vtable movq 0(%rbp), %rax ; Dereference function pointer from vtable movq %rbp, %rdi call *(%rax) ; Call function pointer - f() |
然后在调用
恰好在单继承示例中,上述值的偏移量不利于处理器。我不知道为什么。但我不得不怀疑,这将是缓存银行冲突,类似于此问题图表中的区域2。
通过重新排列代码并添加虚拟函数,我可以更改这些偏移量——在很多情况下,这将消除这种速度减慢的现象,并使单个继承的速度与多重继承的速度一样快。
例如,删除EDOCX1[10]会反转时间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | #ifdef SINGLE class A { public: virtual int f() { return a; } }; class B : public A { public: virtual int f() { return a; } void g() { return; } }; #endif #ifdef MULTIPLE class A { public: virtual int f() { return a; } }; class dummy { public: virtual void g() { return; } }; class B : public A, public dummy { public: virtual int f() { return a; } virtual void g() { return; } }; #endif int main() { cin >> a; A* obj; if (a > 3) obj = new B(); else obj = new A(); unsigned long result = 0; clock_t time0 = clock(); for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result += obj->f(); } } clock_t time1 = clock(); cout << (double)(time1 - time0) / CLOCKS_PER_SEC << endl; cout << result <<" "; // system("pause"); } |
- 单程:8.06秒
- 倍数:9.4秒
我想我至少对这件事的原因有了进一步的了解。循环的程序集完全相同,但对象文件不同!
对于一开始有cout的循环(即
1 2 3 4 5 6 7 | cout <<"vtable:" << *(void**)obj << endl; for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result+=obj->f(); } } |
我在对象文件中得到以下信息:
1 2 3 4 5 6 7 8 9 10 11 | 40092d: bb fe ff 00 00 mov $0xfffe,%ebx 400932: 48 8b 45 00 mov 0x0(%rbp),%rax 400936: 48 89 ef mov %rbp,%rdi 400939: ff 10 callq *(%rax) 40093b: 48 98 cltq 40093d: 49 01 c4 add %rax,%r12 400940: ff cb dec %ebx 400942: 79 ee jns 400932 <main+0x42> 400944: 41 ff c5 inc %r13d 400947: 41 81 fd fe ff 00 00 cmp $0xfffe,%r13d 40094e: 7e dd jle 40092d <main+0x3d> |
但是,如果没有cout,则循环将变为:-(.cpp优先)
1 2 3 4 5 | for (int i=0; i<65535; i++) { for (int j=0; j<65535; j++) { result+=obj->f(); } } |
现在,Obj:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 400a54: bb fe ff 00 00 mov $0xfffe,%ebx 400a59: 66 data16 400a5a: 66 data16 400a5b: 66 data16 400a5c: 90 nop 400a5d: 66 data16 400a5e: 66 data16 400a5f: 90 nop 400a60: 48 8b 45 00 mov 0x0(%rbp),%rax 400a64: 48 89 ef mov %rbp,%rdi 400a67: ff 10 callq *(%rax) 400a69: 48 98 cltq 400a6b: 49 01 c4 add %rax,%r12 400a6e: ff cb dec %ebx 400a70: 79 ee jns 400a60 <main+0x70> 400a72: 41 ff c5 inc %r13d 400a75: 41 81 fd fe ff 00 00 cmp $0xfffe,%r13d 400a7c: 7e d6 jle 400a54 <main+0x64> |
所以我不得不说,这并不是因为神秘主义所指出的假别名,而是因为编译器/链接器发出的这些nop。
在这两种情况下,组件都是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | .L30: movl $65534, %ebx .p2align 4,,7 .L29: movq (%rbp), %rax movq %rbp, %rdi call *(%rax) cltq addq %rax, %r12 decl %ebx jns .L29 incl %r13d cmpl $65534, %r13d jle .L30 |
现在,.p2align 4,,7将插入数据/nops,直到下一条指令的指令计数器具有最后四位0,最多7个nops。在没有cout和padding的情况下,p2align后面的指令地址是
1 | 0x400a59 = 0b101001011001 |
因为它需要<=7nops来对齐下一条指令,所以它实际上会在对象文件中这样做。
另一方面,对于COUT的情况,在.p2Align之后的指令将在
1 | 0x400932 = 0b100100110010 |
需要7个以上的nops才能将其填充到一个可以被16个边界整除的区域。因此,它不这样做。
因此,所花费的额外时间仅仅是由于使用-o2标志编译时编译器用NOP填充代码(为了更好地对齐缓存),而不是真正由于假别名。
我认为这解决了问题。我正在使用http://sourceware.org/binutils/docs/as/p2align.html作为我的参考,p2align实际上做了什么。
这个答案更具推测性。在修改了5分钟并阅读了神秘的答案之后,得出的结论是这是一个硬件问题:在热循环中生成的代码基本上是相同的,所以编译器没有问题,这使得硬件成为唯一的怀疑。
一些随机的想法:
- 分支预测
- 分支(=函数)目标地址的对齐或部分别名
- 一级缓存在读取同一地址后一直处于热运行状态
- 宇宙射线
使用当前代码,编译器可以自由地解除对
我建议
1 2 3 4 5 6 7 | if (a>3) { B* objb = new B(); objb->a = 5; obj = objb; } else obj = new A(); |
我的猜测是,就