C++ 虚函数与成员函数指针(性能比较)

c++ virtual function vs member function pointer (performance comparison)

本文关键字:函数 性能 性能比 比较 指针 成员 C++      更新时间:2023-10-16

虚拟函数调用可能会很慢,因为虚拟调用需要对向量表进行额外的索引遵从,这可能导致数据缓存未命中以及指令缓存未命中...不利于性能关键型应用程序。

因此,我一直在想一种方法来克服虚拟函数的性能问题,同时仍然具有虚拟函数提供的一些相同功能。

我相信以前已经这样做了,但我设计了一个简单的测试,允许基类存储可由任何派生类设置的成员函数指针。当我在任何派生类上调用 Foo(( 时,它将调用相应的成员函数,而无需遍历逆向表......

我只是想知道这种方法是否是虚拟呼叫范式的可行替代品,如果是这样,为什么它不是更普遍?

提前感谢您的时间! :)

class BaseClass
{
protected:
    // member function pointer
    typedef void(BaseClass::*FooMemFuncPtr)();
    FooMemFuncPtr m_memfn_ptr_Foo;
    void FooBaseClass() 
    {
        printf("FooBaseClass() n");
    }
public:
    BaseClass()
    {
        m_memfn_ptr_Foo = &BaseClass::FooBaseClass;
    }
    void Foo()
    {
        ((*this).*m_memfn_ptr_Foo)();
    }
};
class DerivedClass : public BaseClass
{
protected:
    void FooDeriveddClass()
    {
        printf("FooDeriveddClass() n");
    }
public:
    DerivedClass() : BaseClass()
    {
        m_memfn_ptr_Foo = (FooMemFuncPtr)&DerivedClass::FooDeriveddClass;
    }
};
int main(int argc, _TCHAR* argv[])
{
    DerivedClass derived_inst;
    derived_inst.Foo(); // "FooDeriveddClass()"
    BaseClass base_inst;
    base_inst.Foo(); // "FooBaseClass()"
    BaseClass * derived_heap_inst = new DerivedClass;
    derived_heap_inst->Foo();
    return 0;
}

我做了一个测试,使用虚函数调用的版本在我的系统上通过优化更快。

$ time ./main 1
Using member pointer
real    0m3.343s
user    0m3.340s
sys     0m0.002s
$ time ./main 2
Using virtual function call
real    0m2.227s
user    0m2.219s
sys     0m0.006s

这是代码:

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <stdio.h>
struct BaseClass
{
    typedef void(BaseClass::*FooMemFuncPtr)();
    FooMemFuncPtr m_memfn_ptr_Foo;
    void FooBaseClass() { }
    BaseClass()
    {
        m_memfn_ptr_Foo = &BaseClass::FooBaseClass;
    }
    void Foo()
    {
        ((*this).*m_memfn_ptr_Foo)();
    }
};
struct DerivedClass : public BaseClass
{
    void FooDerivedClass() { }
    DerivedClass() : BaseClass()
    {
        m_memfn_ptr_Foo = (FooMemFuncPtr)&DerivedClass::FooDerivedClass;
    }
};
struct VBaseClass {
  virtual void Foo() = 0;
};
struct VDerivedClass : VBaseClass {
  virtual void Foo() { }
};
static const size_t count = 1000000000;
static void f1(BaseClass* bp)
{
  for (size_t i=0; i!=count; ++i) {
    bp->Foo();
  }
}
static void f2(VBaseClass* bp)
{
  for (size_t i=0; i!=count; ++i) {
    bp->Foo();
  }
}
int main(int argc, char** argv)
{
    int test = atoi(argv[1]);
    switch (test) {
        case 1:
        {
            std::cerr << "Using member pointern";
            DerivedClass d;
            f1(&d);
            break;
        }
        case 2:
        {
            std::cerr << "Using virtual function calln";
            VDerivedClass d;
            f2(&d);
            break;
        }
    }
    return 0;
}

编译使用:

g++ -O2    main.cpp   -o main

使用 G++ 4.7.2。

由于虚拟调用必须遍历向量表,因此虚函数调用可能会很慢,

这不太正确。vtable 应在对象构造上计算,每个虚函数指针设置为层次结构中最专业的版本。调用虚函数的过程不是迭代指针,而是调用类似 *(vtbl_address + 8)(args); 的东西,它是在常量时间内计算的。

这可能导致数据缓存未命中以及指令缓存未命中...不利于性能关键型应用程序。

您的解决方案也不适合性能关键型应用程序(通常(,因为它是通用的。

通常,性能

关键型应用程序会根据具体情况进行优化(测量,选择模块中性能问题最差的代码并进行优化(。

使用这种按大小写的方法,您可能永远不会遇到代码运行缓慢的情况,因为编译器必须遍历 vtbl。如果是这种情况,那么缓慢可能来自通过指针而不是直接调用函数(即问题将通过内联来解决,而不是通过在基类中添加额外的指针(。

无论如何,所有这些都是学术性的,直到你有一个具体的案例需要优化(并且你已经测量到你最严重的罪魁祸首是虚函数调用(。

编辑

我只是想知道这种方法是否是虚拟呼叫范式的可行替代品,如果是这样,为什么它不是更普遍?

因为它看起来像一个通用解决方案(普遍应用它会降低性能而不是提高它(,所以解决了一个不存在的问题(您的应用程序通常不会因虚函数调用而变慢(。

虚函数不会"遍历"表,只需从某个位置获取指针并调用该地址即可。这就好像您手动实现了指向函数的指针,并将其用于调用而不是直接调用。

因此,您的工作只适合混淆,并破坏编译器可以发出非虚拟直接调用的情况。

使用指向成员的指针函数可能比 PTF 更糟糕,它可能会使用相同的 VMT 结构进行类似的偏移访问,只是一个变量而不是固定的。

主要是因为它不起作用。大多数现代 CPU 在分支预测和推测执行方面比您想象的要好。但是,我还没有看到一个CPU在非静态分支之外执行推测执行。

此外,在现代 CPU 中,您更有可能出现缓存未命中,因为您在调用之前有一个上下文切换并且另一个程序接管了缓存,而不是因为对向表,即使这种情况也是一个非常遥远的可能性。

实际上,一些编译器可能会使用 thunks,它们本身可以转换为普通的函数指针,所以基本上编译器会为您执行您尝试手动执行的操作(并且可能会混淆人们(。

此外,有一个指向虚函数表的指针,虚函数的空间复杂度为 O(1((只是指针(。另一方面,如果在类中存储函数指针,则复杂度为 O(N((您的类现在包含的指针数量与"虚"函数的数量一样多(。如果有许多函数,您将为此付出代价 - 在预取对象时,您将加载缓存行中的所有指针,而不仅仅是单个指针和您可能需要的前几个成员。这听起来像是一种浪费。

另一方面,虚拟函数表位于一种类型的所有对象的一个位置,并且可能永远不会从缓存中推出,而您的代码在循环中调用一些短的虚函数(这可能是虚函数成本成为瓶颈时的问题(。

至于分支预测,在某些情况下,对象类型的简单决策树和每个特定类型的内联函数提供了良好的性能(然后您存储类型信息而不是指针(。这并不适用于所有类型的问题,并且主要是过早的优化。

根据经验,不要担心语言结构,因为它们看起来很陌生。只有在测量并确定瓶颈真正所在之后,才担心和优化。