关于c ++:为什么我观察多重继承比单一更快?

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()

然后在调用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分钟并阅读了神秘的答案之后,得出的结论是这是一个硬件问题:在热循环中生成的代码基本上是相同的,所以编译器没有问题,这使得硬件成为唯一的怀疑。

一些随机的想法:

  • 分支预测
  • 分支(=函数)目标地址的对齐或部分别名
  • 一级缓存在读取同一地址后一直处于热运行状态
  • 宇宙射线


使用当前代码,编译器可以自由地解除对obj->f()的调用,因为obj不能有除class B之外的任何动态类型。

我建议

1
2
3
4
5
6
7
if (a>3) {
    B* objb = new B();
    objb->a = 5;
    obj = objb;
}
else
    obj = new A();


我的猜测是,就A而言,class B : public dummy, public A有不利的对齐。将dummy填充到16个字节,查看是否有差异。