使用指向成员函数的指针而不是开关的代价是什么?

时间:2020-03-06 14:31:25  来源:igfitidea点击:

我有以下情况:

class A
{
public:
    A(int whichFoo);
    int foo1();
    int foo2();
    int foo3();
    int callFoo(); // cals one of the foo's depending on the value of whichFoo
};

在我当前的实现中,我将thatFoo的值保存在构造函数的数据成员中,并在callFoo()中使用switch来决定要调用哪个foo。另外,我可以在构造函数中使用switch来保存指向在callFoo()中调用的正确fooN()的指针。

我的问题是,如果仅一次构造A类的对象,而调用callFoo()的次数却非常多,那么哪种方法更有效。因此,在第一种情况下,我们多次执行switch语句,而在第二种情况下,仅执行一次开关,并使用指向它的指针多次调用成员函数。我知道使用指针调用成员函数比直接调用它慢。有人知道这种开销是大于还是小于交换机的成本吗?

澄清:我意识到我们永远不会真正知道哪种方法可以提供更好的性能,除非我们尝试一下并计时。但是,在这种情况下,我已经实现了方法1,并且我想确定方法2至少在原理上是否可以更有效。看起来可以,现在对我来说,去实施它并尝试它是有意义的。

哦,出于美学原因,我也更喜欢方法2. 我想我正在寻找实施它的理由。 :)

解决方案

我们如何确定通过指针调用成员函数比直接调用成员函数慢?你能衡量出差异吗?

通常,在进行绩效评估时,我们不应依赖直觉。坐下来使用编译器和计时功能,然后实际测量不同的选择。我们可能会感到惊讶!

更多信息:有一篇出色的文章《成员函数指针和最快的C ++委托》,它非常详细地介绍了成员函数指针的实现。

听起来我们应该将callFoo设为纯虚拟函数并创建A的某些子类。

除非我们真的需要速度,否则请进行大量的分析和检测,并确定对callFoo的调用确实是瓶颈。你?

要回答这个问题:在最细粒度的层次上,指向成员函数的指针将表现更好。

要解决尚未提出的问题:"更好"在这里是什么意思?在大多数情况下,我希望差异可以忽略不计。但是,根据所从事的课程的不同,差异可能会很大。担心差异之前的性能测试显然是正确的第一步。

函数指针几乎总是比链式条件更好。它们编写的代码更清晰,并且几乎总是更快(可能只有在两个函数之间进行选择并且始终能够正确预测的情况下除外)。

如果我们将继续使用开关,这非常好,那么我们可能应该将逻辑放在辅助方法中,并从构造函数中调用。另外,这是战略模式的经典案例。我们可以创建一个名为IFoo的接口(或者抽象类),该接口具有一个带有Foo签名的方法。我们将让构造函数接受一个I​​Foo实例(构造函数依赖注入,该实例实现了我们想要的foo方法。我们将拥有一个与此构造函数一起设置的私有IFoo,每次我们想调用Foo时,我们都会调用IFoo的版本。

注意:我从大学开始就没有使用过C ++,所以我的行话可能不在这里,但是大多数OO语言的通用思想都适用。

我应该认为指针会更快。

现代CPU预取指令;错误预测的分支将刷新缓存,这意味着它在重新填充缓存时会停顿。指针不会那样做。

当然,我们应该同时测量两者。

我们可以这样写:

class Foo {
public:
  Foo() {
    calls[0] = &Foo::call0;
    calls[1] = &Foo::call1;
    calls[2] = &Foo::call2;
    calls[3] = &Foo::call3;
  }
  void call(int number, int arg) {
    assert(number < 4);
    (this->*(calls[number]))(arg);
  }
  void call0(int arg) {
    cout<<"call0("<<arg<<")\n";
  }
  void call1(int arg) {
    cout<<"call1("<<arg<<")\n";
  }
  void call2(int arg) {
    cout<<"call2("<<arg<<")\n";
  }
  void call3(int arg) {
    cout<<"call3("<<arg<<")\n";
  }
private:
  FooCall calls[4];
};

实际函数指针的计算是线性且快速的:

(this->*(calls[number]))(arg);
004142E7  mov         esi,esp 
004142E9  mov         eax,dword ptr [arg] 
004142EC  push        eax  
004142ED  mov         edx,dword ptr [number] 
004142F0  mov         eax,dword ptr [this] 
004142F3  mov         ecx,dword ptr [this] 
004142F6  mov         edx,dword ptr [eax+edx*4] 
004142F9  call        edx

请注意,我们甚至不必在构造函数中固定实际的函数编号。

我已经将此代码与switch生成的asm进行了比较。 switch版本不提供任何性能提升。

如果示例是真实的代码,那么我认为我们应该重新考虑类设计。将值传递给构造函数,然后使用该值更改行为实际上等效于创建子类。考虑重构以使其更加明确。这样做的结果是,代码最终将使用函数指针(实际上,所有虚拟方法都是跳转表中的函数指针)。

但是,如果代码只是一个简单的示例,询问通常跳转表是否比switch语句快,那么我的直觉会说跳转表更快,但是我们依赖于编译器的优化步骤。但是,如果真的要考虑性能,则不要依赖直觉来启动测试程序并对其进行测试,也不要查看生成的汇编程序。

可以肯定的是,switch语句永远不会比跳转表慢。原因是编译器的优化器可以做的最好的事情就是将一系列条件测试(即开关)转换为跳转表。因此,如果我们确实希望确定,请使编译器退出决策过程,并使用跳转表。

仅在需要时进行优化

第一:大多数时候我们很可能不在乎,两者之间的差异会很小。确保首先优化此调用确实有意义。仅当测量结果显示确实有大量时间花费在呼叫开销上时,才继续进行优化(无耻的插件Cf。如何优化应用程序以使其更快?)。

间接通话费用取决于目标平台

一旦确定了值得应用低级优化,那么就该了解目标平台了。我们可以在这里避免的成本是分支预测错误的罚款。在现代的x86 / x64 CPU上,这种错误预测可能非常小(它们在大多数情况下可以很好地预测间接调用),但是在以PowerPC或者其他RISC平台为目标时,通常根本不会预测间接调用/跳转,因此避免了它们会导致明显的性能提升。另请参见虚拟通话费用取决于平台。

编译器也可以使用跳转表来实现切换

一个陷阱:切换有时也可以实现为间接调用(使用表),尤其是在许多可能的值之间切换时。这种开关表现出与虚拟功能相同的错误预测。为了使此优化可靠,最常见的情况可能是使用if而不是switch。

使用计时器查看哪个更快。尽管除非这段代码反复出现,否则我们不太可能会注意到任何区别。

如果从构造函数运行代码,请确保如果构造失败,则不会泄漏内存。

此技术在Symbian OS中大量使用:
http://www.titu.jyu.fi/modpa/Patterns/pattern-TwoPhaseConstruction.html

如果只调用一次callFoo(),则函数指针的运行速度很可能会慢很多。如果我们调用它的次数比最有可能的次数多,则函数指针将以不明显的速度更快(因为它不需要继续通过开关)。

两种方式都可以查看汇编的代码,以确保确定其在执行我们认为正在执行的操作。

切换(甚至超过排序和索引)常常被忽略的一个优点是,如果我们知道在大多数情况下都使用了特定的值。
订购开关很容易,以便最先检查最常用的开关。

ps。如果我们关心速度测量,请加强格雷格的答案。
当CPU具有预取/预测分支和流水线停顿等功能时,查看汇编程序无济于事