【问题标题】:Where is the virtual function call overhead?虚函数调用开销在哪里?
【发布时间】:2010-05-17 06:03:11
【问题描述】:

我正在尝试对函数指针调用和虚函数调用之间的差异进行基准测试。为此,我编写了两段代码,它们对数组执行相同的数学计算。一种变体使用一组指向函数的指针并在循环中调用它们。另一种变体使用指向基类的指针数组并调用其虚函数,该虚函数在派生类中被重载以执行与第一个变体中的函数完全相同的事情。然后我打印经过的时间,并使用一个简单的 shell 脚本多次运行基准测试并计算平均运行时间。

代码如下:

#include <iostream>
#include <cstdlib>
#include <ctime>
#include <cmath>

using namespace std;

long long timespecDiff(struct timespec *timeA_p, struct timespec *timeB_p)
{
return ((timeA_p->tv_sec * 1000000000) + timeA_p->tv_nsec) -
    ((timeB_p->tv_sec * 1000000000) + timeB_p->tv_nsec);
}

void function_not( double *d ) {
*d = sin(*d);
}

void function_and( double *d ) {
*d = cos(*d);
}

void function_or( double *d ) {
*d = tan(*d);
}

void function_xor( double *d ) {
*d = sqrt(*d);
}

void ( * const function_table[4] )( double* ) = { &function_not, &function_and, &function_or, &function_xor };

int main(void)
{
srand(time(0));
void ( * index_array[100000] )( double * );
double array[100000];
for ( long int i = 0; i < 100000; ++i ) {
    index_array[i] = function_table[ rand() % 4 ];
    array[i] = ( double )( rand() / 1000 );
}

struct timespec start, end;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &start);
for ( long int i = 0; i < 100000; ++i ) {
    index_array[i]( &array[i] );
}
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &end);

unsigned long long time_elapsed = timespecDiff(&end, &start);
cout << time_elapsed / 1000000000.0 << endl;
}

这里是虚函数变体:

#include <iostream>
#include <cstdlib>
#include <ctime>
#include <cmath>

using namespace std;

long long timespecDiff(struct timespec *timeA_p, struct timespec *timeB_p)
{
return ((timeA_p->tv_sec * 1000000000) + timeA_p->tv_nsec) -
    ((timeB_p->tv_sec * 1000000000) + timeB_p->tv_nsec);
}

class A {
public:
    virtual void calculate( double *i ) = 0;
};

class A1 : public A {
public:
    void calculate( double *i ) {
    *i = sin(*i);
    }
};

class A2 : public A {
public:
    void calculate( double *i ) {
        *i = cos(*i);
    }
};

class A3 : public A {
public:
    void calculate( double *i ) {
        *i = tan(*i);
    }
};

class A4 : public A {
public:
    void calculate( double *i ) {
        *i = sqrt(*i);
    }
};

int main(void)
{
srand(time(0));
A *base[100000];
double array[100000];
for ( long int i = 0; i < 100000; ++i ) {
    array[i] = ( double )( rand() / 1000 );
    switch ( rand() % 4 ) {
    case 0:
    base[i] = new A1();
    break;
    case 1:
    base[i] = new A2();
    break;
    case 2:
    base[i] = new A3();
    break;
    case 3:
    base[i] = new A4();
    break;
    }
}

struct timespec start, end;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &start);
for ( int i = 0; i < 100000; ++i ) {
    base[i]->calculate( &array[i] );
}
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &end);

unsigned long long time_elapsed = timespecDiff(&end, &start);
cout << time_elapsed / 1000000000.0 << endl;
}

我的系统是 LInux,Fedora 13,gcc 4.4.2。代码是用 g++ -O3 编译的。第一个是test1,第二个是test2。

现在我在控制台中看到了这个:

[Ignat@localhost circuit_testing]$ ./test2 && ./test2 
0.0153142
0.0153166

嗯,或多或少,我想。然后,这个:

[Ignat@localhost circuit_testing]$ ./test2 && ./test2 
0.01531
0.0152476

应该可见的 25% 在哪里?第一个可执行文件怎么会比第二个慢?

我问这个是因为我正在做一个项目,该项目涉及像这样连续调用许多小函数以计算数组的值,并且我继承的代码进行了非常复杂的操作以避免虚函数调用开销。现在这个著名的调用开销在哪里?

【问题讨论】:

  • 你确实运行了./test1 &amp;&amp; ./test2,对吧?因为在您的帖子中,您已经运行了两次./test2。您还应该多次运行这些程序中的每一个并对结果进行平均;否则结果毫无意义。也就是说,您所说的“25% 在哪里”是什么意思?您是否预计虚函数调用会产生 25% 的开销?
  • 我不是这方面的专家,但 C++ 编译器被称为非常聪明的优化器。如果您的两个代码在优化后都编译成相同的东西,我不会感到惊讶。因此,如果您还没有这样做,请在编译基准之前禁用所有优化。
  • 是的,对不起,它是 test2 && test2,但这是来自 test1 && test2 的一个: [Ignat@localhost circuit_testing]$ ./test1 && ./test2 0.0159555 0.0153605 还是一样的。当它不慢时,它就像我预期的那样快 0.0005 秒 - 而不是 25 &,或者至少 10%。
  • 哇!同一个问题中的过早优化和微基准测试。添加一个 asm 块只是为了确保它真的快,你将完成代码邪恶三部曲...... muhahaha!
  • 嗯,我正在做的是试图找出使用哪一个:带开关的常规函数​​调用、带表的指针函数调用或虚函数调用。而已。我需要对一个大小为 10000 的数组进行数千次迭代,因此,更快的速度很重要。

标签: c++ optimization virtual


【解决方案1】:

在这两种情况下,您都是间接调用函数。在一种情况下,通过您的函数指针表,在另一种情况下,通过编译器的函数指针数组(vtable)。毫不奇怪,两个相似的操作会给你相似的计时结果。

【讨论】:

  • 嗯,据我所知,对于指针调用,您需要加载要调用的函数的地址,仅此而已,而对于 vtable,它至少是 vtable 的地址,然后是指针。毕竟,应该有一些理由避免使用虚函数类。这就是我继承的项目的文档中所说的,所以我只是想看看有什么不同。
  • @Semen Semenych:但是一旦 vtable 间接被 CPU 缓存,额外的间接层变得非常便宜。在性能方面,避免 vtable 间接的最佳理由不是“它很慢”,而是“它阻止了一些优化”。它并不慢,它只是阻止代码变快。
【解决方案2】:

虚拟函数可能比常规函数慢,但这是由于内联之类的原因。如果通过函数表调用函数,也不能内联,查找时间也差不多。通过您自己的查找表查找当然与通过编译器的查找表查找相同。
编辑:甚至更慢,因为编译器比你更了解处理器缓存等。

【讨论】:

    【解决方案3】:

    我认为您看到了不同之处,但这只是函数调用开销。分支错误预测、内存访问和触发函数在这两种情况下都是相同的。与那些相比,这没什么大不了的,虽然我尝试过的函数指针案例肯定快一点。

    如果这是您更大的程序的代表,这很好地证明了这种类型的微优化有时只是沧海一粟,最坏的情况是徒劳的。但撇开这一点不谈,为了更清晰的测试,函数应该执行一些更简单的操作,每个函数都不同:

    void function_not( double *d ) {
        *d = 1.0;
    }
    
    void function_and( double *d ) {
        *d = 2.0;
    }
    

    以此类推,虚函数也是如此。

    (每个函数都应该做一些不同的事情,这样它们就不会被忽略并且都以相同的地址结束;这会使分支预测工作得不切实际。)

    通过这些更改,结果会有所不同。每种情况下最好的 4 次运行。 (不是很科学,但是对于大量运行,这些数字大致相似。)所有时间都是循环的,在我的笔记本电脑上运行。代码是用 VC++ 编译的(只是改变了时序),但 gcc 以相同的方式实现虚函数调用,因此即使使用不同的 OS/x86 CPU/编译器,相对时序也应该大致相似。

    函数指针:2,052,770

    虚拟机:3,598,039

    这种差异似乎有点过分!果然,这两位代码在内存访问行为方面并不完全相同。第二个应该有一个 4 个 A *s 的表格,用于填写基础,而不是为每个条目新建一个新的。在获取要跳转的指针时,这两个示例将具有相似的行为(1 个缓存未命中/N 个条目)。例如:

    A *tbl[4] = { new A1, new A2, new A3, new A4 };
    for ( long int i = 0; i < 100000; ++i ) {
        array[i] = ( double )( rand() / 1000 );
        base[i] = tbl[ rand() % 4 ];
    }
    

    有了这个,仍然使用简化的功能:

    虚拟机(此处建议):2,487,699

    所以最好的情况是 20%。够近了吗?

    所以也许您的同事至少考虑到这一点是正确的,但我怀疑在任何实际程序中,调用开销都不足以成为值得跳过的瓶颈。

    【讨论】:

    • 虽然大多数情况都很清楚,但我仍然没有在这两种变体的代码的基准测试部分看到任何分支。是三角函数吗?而且我不知道以这种或那种方式分配 A 之间的差异,必须对此进行调查。还有两种变体,一种在测量代码中带有开关,另一种带有成员函数指针。最后一个是最快的,尽管我必须检查简化的功能。就 20% 而言,这正是文章所说的,但找不到,那是互联网上某处的 PDF。
    • 这里的分支是调用目标函数的地方——"index_array[i]( &array[i] );", "base[i]->calculate( &array[i] ); ”。在现代 x86 CPU 上,这些受益于预测(尽管类型略有不同),就像条件分支一样。对不起,如果我的术语有点混乱。
    • 另外两种分配 A 的方式的速度差异取决于数据缓存。请参阅agner.org/optimize, "Optimizing C++" pdf,里面有一个关于它的部分。
    【解决方案4】:

    如今,在大多数系统上,内存访问是主要瓶颈,而不是 CPU。在许多情况下,虚拟函数和非虚拟函数之间几乎没有显着差异——它们通常只占执行时间的一小部分。 (抱歉,我没有报告数据来支持这一点,只是经验数据。)

    如果您想获得最佳性能,如果您研究如何并行化计算以利用多个内核/处理单元,而不是担心虚拟与非虚拟的微观细节,那么您将获得更大的收益。虚函数。

    【讨论】:

    • 已经这样做了,感谢您的建议。所以不是CPU。我得和那个写旧代码的人谈谈。
    • 不,这通常不是真的。由于内联失败、分支预测失败等,虚函数可能会产生巨大开销。当然,在某些情况下(紧密循环),即使只是增加间接性也可能代价高昂。
    • 是的,这就是答案,必须停止使用成员函数指针和模板使我的设计过于复杂,并专注于真正的东西。
    • 如果我们在这里谈论内联,那么什么将内联保证:一个函数指针,一个成员函数指针或者还有什么我不知道的?
    • @Konrad - 我认为“一般”是真的,而“对于特定的情况”则不是。对于紧密的内部循环,如果这确实是开销所在,那么您希望避免使用虚函数。但总的来说,这种差异并不值得担心——而且我们一般都在谈论。如果您分析整个应用程序的所有虚拟功能,您会发现通常只有少数有任何重大开销。
    【解决方案5】:

    很多人养成做事的习惯只是因为他们被认为“更快”。 都是相对的。

    如果我要从家里开车 100 英里,我必须先在街区周围开车。我可以在街区向右或向左行驶。其中之一将是“更快”。但这有关系吗?当然不是。

    在这种情况下,您调用的函数依次调用数学函数。

    如果您在 IDE 或 GDB 下暂停程序,我怀疑您会发现几乎每次暂停它都会在那些数学库例程中(或者应该是!),并取消引用一个额外的指针以到达那里(假设它不会破坏缓存)应该消失在噪音中。

    添加:这是一个最喜欢的视频:Harry Porter's relay computer。当那个东西费力地加数字和步进它的程序计数器时,我发现记住所有计算机都在做的事情很有帮助,只是在不同的时间和复杂性上。在您的情况下,请考虑一种算法来执行 sincostansqrt。在里面,它在不停地做这些事情,只是偶然地跟踪地址或弄乱了非常慢的内存才能到达那里。

    【讨论】:

      【解决方案6】:

      最后,函数指针方法被证明是最快的方法。这是我从一开始就预料到的。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2010-11-29
        • 2011-03-09
        • 2011-12-02
        • 2014-10-29
        • 1970-01-01
        • 2010-12-10
        • 2020-03-21
        相关资源
        最近更新 更多