【问题标题】:Virtual function performance: one large class vs many smaller subclasses虚函数性能:一个大类与许多较小的子类
【发布时间】:2014-05-23 10:43:19
【问题描述】:

我正在重构我制作的 c++ OpenGL 应用程序(从技术上讲,这是一个在 Qt 的 QQuickItem 类中大量使用瘦 OpenGL 包装器的应用程序)。我的应用运行良好,但可能会更好。

我很好奇的一个问题与在对时间非常敏感(帧速率)的算法中使用 virtual 函数有关。我的 OpenGL 绘图代码在需要绘图的各种对象上调用了许多 virtual 函数。由于这种情况每秒发生多次,我想知道 virtual 调度是否会降低帧速率。

我正在考虑改为这种结构,通过将所有内容保存在一个基类中来避免继承,但是以前的 virtual 函数现在只包含 switch 语句以根据类的“类型”调用适当的例程这实际上只是一个typedef enum

以前:

struct Base{
  virtual void a()=0;
  virtual void b()=0;
}

struct One : public Base{
  void a(){...}
  void b(){...}
}

考虑:

struct Combined{

  MyEnumTypeDef t; //essentially holds what "type" of object this is

  void a(){

    switch (t){

     case One:
       ....
       break;

      case Two:
       ....
       break;
     }
   }
  }

当函数 a() 在 OpenGL 绘图例程中被频繁调用时,我很想知道 Combined 类的效率会大大提高,因为它不需要虚拟表上的动态分派。

我很感激关于这个问题的一些建议,无论这是否明智。

【问题讨论】:

  • 不要猜测。分析代码,然后根据结果做出决定。
  • “不要推测;个人资料”当然是最好的答案。次优的答案是,几乎可以肯定的是,开关编译成的代码比虚函数调用慢(或者,充其量是相同的速度)。现代 CPU 结合了许多技巧来使虚拟函数调用更快,或者至少比以前更快,因为现代 OO 语言往往会大量发出它们。
  • 顺便说一句,这个问题与OpenGL和Qt关系不大。请考虑删除这些标签。这是一个通用的图形编程问题。
  • 你有什么理由认为switch语句比虚函数调用快吗?
  • 是的,我同意实际解决方案需要更多信息。在这种情况下,人们能做的最好的事情就是为某些设计决策提供一些优劣的经验法则。

标签: c++ performance


【解决方案1】:

不使用虚函数会更快,但差异是否显着很难说。您应该通过分析器运行您的程序,以查看它在哪里花费时间。您可能会发现 CPU 周期都花在了完全不同的事情上,并且您会通过弄乱虚拟资源来浪费您的时间(并降低您的设计质量)。

Unix:How can I profile C++ code running in Linux? 窗口:What's the best free C++ profiler for Windows?

另一个需要考虑的选项可能是使用奇怪重复的模板模式:http://en.wikipedia.org/wiki/Curiously_recurring_template_pattern 在不使用虚拟的情况下获得类似的多态性。

【讨论】:

  • @JXB:实际上,您可以考虑按类型排序以避免运行时多态性。也看到这个:github.com/brandonpelfrey/SimpleOctree/blob/master/Octree.h
  • “不使用虚函数会更快”是完全错误的,特别是如果替代方案是 switch 语句。
  • @LaszloPapp 我从来没有说过任何关于重写或重新设计的事情。分析的重点是了解您的程序正在做什么,这很慢。如果您不明白为什么它很慢,您将无法加快速度。
  • @Nemo 开关可以比虚函数调用更快。当使用 Profile 引导优化时,VC++ 编译器自 2005 年以来一直在虚拟调用之前添加 switch 语句。他们称之为虚拟调用推测,它可以更快,因为 cpu 可以预测开关但不能预测虚拟调用。 blogs.msdn.com/b/vcblog/archive/2008/11/12/pogo.aspx
  • @user1937198:至少从 2003 年 (stackoverflow.com/a/7263886/768469) 开始,英特尔 CPU 就拥有“间接分支预测器”,并且每一代都变得更好。所以我坚持我的主张:将虚拟调用转换为 switch 语句,正如这里的 OP 所建议的那样,几乎肯定会导致 slower 代码;或者充其量,不会更快。
【解决方案2】:

在您的情况下,可能无关紧要。我说可能是因为,我的意思是建设性的,事实上,你没有指定性能要求,也没有指定调用相关函数的频率,这表明你可能现在没有足够的信息来做出判断 - “不要推测:配置文件”一揽子回应实际上只是为了确保您拥有所需的所有必要信息,因为过早的微优化非常普遍而我们真正的目标是从大局为您提供帮助。

Jeremy Friesner 用his comment on another answer here 真的一针见血:

如果您不明白为什么它很慢,您将无法加快速度。

因此,考虑到所有这些,假设 A) 您的性能要求已经得到满足(例如,您正在拉 4000 FPS - 远高于任何显示刷新率)或 B) 您正在努力满足性能要求并且此函数仅被调用几次(例如每帧

使用virtual 函数最多可能会在某处的某个表中进行一次额外的查找(可能还有一些缓存未命中 - 但如果在内部循环中重复访问则不会那么多),这是几个 CPU 时钟周期最坏的情况(并且很可能仍然小于 switch,尽管这在这里真的没有实际意义),这 与您的目标帧速率相比完全微不足道,渲染帧所需的工作量,以及您正在执行的任何其他算法和逻辑。如果您想向自己证明这一点,请介绍一下。

应该做的就是使用任何技术来生成最清晰、最干净、最易维护和可读的代码。诸如此类的微优化不会产生效果,代码可维护性的成本即使很小,也不值得收益,这基本上为零。

你应该做的是坐下来了解你的实际情况。您需要提高性能吗?调用此函数是否足以实际产生重大影响,或者您是否应该专注于其他技术(例如更高级别的算法、其他设计策略、将计算卸载到 GPU 或使用特定于机器的优化,例如使用 SSE 的批量操作等? )?

在没有具体信息的情况下,您可以做的一件事是尝试两种方法。虽然性能因机器而异,但您至少可以大致了解这段特定代码对整体性能的影响(例如,如果您以 60 FPS 的速度拍摄,这两个选项分别为您提供 23.2 FPS 与. 23.6 FPS,那么这不是您想要关注的地方,并且选择其中一种策略而不是另一种可能做出的牺牲可能不值得)。

还可以考虑使用调用列表、顶点索引缓冲区等。OpenGL 提供了许多工具来优化对象的绘制,其中某些方面保持不变。例如,如果您有一个巨大的曲面模型,其中包含顶点坐标经常变化的小零件,请使用调用列表将模型划分为多个部分,并且仅在自上次重绘后发生更改时更新更改部分的调用列表。离开例如如果它们经常更改,则从调用列表中着色和纹理(或使用坐标数组)。这样您就可以完全避免调用您的函数。


如果你很好奇,这里有一个测试程序(它可能不代表你的实际使用情况,同样,这不可能用给出的信息来回答——这个测试是下面 cmets 中要求的)。这确实意味着这些结果将反映在您的程序中,而且您需要有关于您的实际需求的具体信息。但是,这只是为了咯咯笑:

此测试程序比较基于开关的操作与基于虚函数的操作与指向成员的操作(从另一个类成员函数调用成员)与指向成员的操作(调用成员的位置)直接来自测试循环)。它还执行三种类型的测试:仅使用一个运算符在数据集上运行,在两个运算符之间来回交替运行,以及使用两个运算符的随机混合运行。

使用 gcc -O0 编译时的输出,迭代次数为 1,000,000,000:

$ g++ -O0 tester.cpp
$ ./a.out 
--------------------
Test: time=6.34 sec (switch add) [-358977076]
Test: time=6.44 sec (switch subtract) [358977076]
Test: time=6.96 sec (switch alternating) [-281087476]
Test: time=18.98 sec (switch mixed) [-314721196]
Test: time=6.11 sec (virtual add) [-358977076]
Test: time=6.19 sec (virtual subtract) [358977076]
Test: time=7.88 sec (virtual alternating) [-281087476]
Test: time=19.80 sec (virtual mixed) [-314721196]
Test: time=10.96 sec (ptm add) [-358977076]
Test: time=10.83 sec (ptm subtract) [358977076]
Test: time=12.53 sec (ptm alternating) [-281087476]
Test: time=24.24 sec (ptm mixed) [-314721196]
Test: time=6.94 sec (ptm add (direct)) [-358977076]
Test: time=6.89 sec (ptm subtract (direct)) [358977076]
Test: time=9.12 sec (ptm alternating (direct)) [-281087476]
Test: time=21.19 sec (ptm mixed (direct)) [-314721196]

使用gcc -O3 编译时的输出,迭代次数为 1,000,000,000:

$ g++ -O3 tester.cpp ; ./a.out
--------------------
Test: time=0.87 sec (switch add) [372023620]
Test: time=1.28 sec (switch subtract) [-372023620]
Test: time=1.29 sec (switch alternating) [101645020]
Test: time=7.71 sec (switch mixed) [855607628]
Test: time=2.95 sec (virtual add) [372023620]
Test: time=2.95 sec (virtual subtract) [-372023620]
Test: time=14.74 sec (virtual alternating) [101645020]
Test: time=9.39 sec (virtual mixed) [855607628]
Test: time=4.20 sec (ptm add) [372023620]
Test: time=4.21 sec (ptm subtract) [-372023620]
Test: time=13.11 sec (ptm alternating) [101645020]
Test: time=9.32 sec (ptm mixed) [855607628]
Test: time=3.37 sec (ptm add (direct)) [372023620]
Test: time=3.37 sec (ptm subtract (direct)) [-372023620]
Test: time=13.08 sec (ptm alternating (direct)) [101645020]
Test: time=9.74 sec (ptm mixed (direct)) [855607628]

请注意,-O3 做了很多工作,如果不查看汇编程序,我们不能将其用作手头问题的 100% 准确表示。

在未优化的情况下,我们注意到:

  • 在单个操作员的运行中,虚拟机的性能优于交换机。
  • 在使用多个运算符的情况下,交换机的性能优于虚拟。
  • 直接调用成员时指向成员的指针 (object->*ptm_) 与 virtual 类似,但比 virtual 慢。
  • 通过另一种方法(object->doit() 其中doit() 调用this->*ptm_)调用成员时指向成员的指针会花费不到两倍的时间。
  • 正如预期的那样,“混合”案例的性能会因分支预测失败而受到影响。

在优化的情况下:

  • 在所有情况下,交换机的性能都优于虚拟交换机。
  • 指向成员的指针与未优化情况的相似特征。
  • 由于我不明白的原因,所有涉及函数指针的“交替”情况都比 -O0 慢,并且比“混合”慢。这不会发生在我家中的 PC 上。

这里特别重要的是例如分支预测胜过“虚拟”与“切换”的任何选择。同样,请确保您了解您的代码并优化正确的内容。

这里的另一个重要的事情是这表示每次操作大约 1-14 纳秒的时间差异。这种差异对于大量操作可能很重要,但与您正在做的其他事情相比可能可以忽略不计(请注意,这些函数只执行一个算术运算,任何更多的操作都会很快使虚拟与切换的效果相形见绌)。

还要注意,虽然直接调用指向成员的指针显示出比通过另一个类成员调用它的“改进”,但这可能会对整体设计产生巨大影响,因为这样的实现(至少在这种情况下,外部的东西由于调用指向成员函数的语法不同(-> vs.->*),类直接调用成员)不能直接替换为另一个实现。例如,我必须创建一整套单独的测试用例来处理它。

结论

即使是几个额外的算术运算,性能差异也很容易相形见绌。还要注意,除了 -O3 的“虚拟交替”情况外,分支预测对所有情况的影响都要大得多。但是,测试也不太可能代表实际应用(OP 对此保密),并且-O3 引入了更多变量,因此结果必须持保留态度,不太可能适用于其他场景(换句话说,测试可能很有趣,但不是特别有意义)。

来源:

// === begin timing ===
#ifdef __linux__
#  include <sys/time.h>
typedef struct timeval Time;
static void tick (Time &t) {
  gettimeofday(&t, 0);
}
static double delta (const Time &a, const Time &b) {
  return
    (double)(b.tv_sec - a.tv_sec) +
    (double)(b.tv_usec - a.tv_usec) / 1000000.0;
}
#else // windows; untested, working from memory; sorry for compile errors
#  include <windows.h>
typedef LARGE_INTEGER Time;
static void tick (Time &t) {
  QueryPerformanceCounter(&t);
}
static double delta (const Time &a, const Time &b) {
  LARGE_INTEGER freq;
  QueryPerformanceFrequency(&freq);
  return (double)(b.QuadPart - a.QuadPart) / (double)freq.QuadPart;
}
#endif
// === end timing

#include <cstdio>
#include <cstdlib>
#include <ctime>

using namespace std;

// Size of dataset.
static const size_t DATASET_SIZE = 10000000;

// Repetitions per test.
static const unsigned REPETITIONS = 100;


// Class performs operations with a switch statement.
class OperatorSwitch {
public:
  enum Op { Add, Subtract };
  explicit OperatorSwitch (Op op) : op_(op) { }
  int perform (int a, int b) const {
    switch (op_) {
    case Add: return a + b;
    case Subtract: return a - b;
    }
  }
private:
  Op op_;
};


// Class performs operations with pointer-to-member.
class OperatorPTM {
public:
  enum Op { Add, Subtract };
  explicit OperatorPTM (Op op) {
    perform_ = (op == Add) ? 
      &OperatorPTM::performAdd :
      &OperatorPTM::performSubtract;
  }
  int perform (int a, int b) const { return (this->*perform_)(a, b); }
  int performAdd (int a, int b) const { return a + b; }
  int performSubtract (int a, int b) const { return a - b; }
  //private:
  int (OperatorPTM::*perform_) (int, int) const;
};


// Base class for virtual-function test operator.
class OperatorBase {
public:
  virtual ~OperatorBase () { }
  virtual int perform (int a, int b) const = 0;
};

// Addition
class OperatorAdd : public OperatorBase {
public:
  int perform (int a, int b) const { return a + b; }
};

// Subtraction
class OperatorSubtract : public OperatorBase {
public:
  int perform (int a, int b) const { return a - b; }
};


// No base

// Addition
class OperatorAddNoBase {
public:
  int perform (int a, int b) const { return a + b; }
};

// Subtraction
class OperatorSubtractNoBase {
public:
  int perform (int a, int b) const { return a - b; }
};



// Processes the dataset a number of times, using 'oper'.
template <typename T>
static void test (const int *dataset, const T *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i)
      result = oper->perform(result, dataset[i]);

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


// Processes the dataset a number of times, alternating between 'oper[0]'
// and 'oper[1]' per element.
template <typename T>
static void testalt (const int *dataset, const T * const *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i)
      result = oper[i&1]->perform(result, dataset[i]);

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


// Processes the dataset a number of times, choosing between 'oper[0]'
// and 'oper[1]' randomly (based on value in dataset).
template <typename T>
static void testmix (const int *dataset, const T * const *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i) {
      int d = dataset[i];
      result = oper[d&1]->perform(result, d);
    }

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


// Same as test() but calls perform_() pointer directly.
static void test_ptm (const int *dataset, const OperatorPTM *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i)
      result = (oper->*(oper->perform_))(result, dataset[i]);

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


// Same as testalt() but calls perform_() pointer directly.
static void testalt_ptm (const int *dataset, const OperatorPTM * const *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i) {
      const OperatorPTM *op = oper[i&1];
      result = (op->*(op->perform_))(result, dataset[i]);
    }

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


// Same as testmix() but calls perform_() pointer directly.
static void testmix_ptm (const int *dataset, const OperatorPTM * const *oper, const char *name) {

  int result = 0;
  Time start, stop;

  tick(start);

  for (unsigned n = 0; n < REPETITIONS; ++ n)
    for (size_t i = 0; i < DATASET_SIZE; ++ i) {
      int d = dataset[i];
      const OperatorPTM *op = oper[d&1];
      result = (op->*(op->perform_))(result, d);
    }

  tick(stop);

  // result is computed and printed so optimizations do not discard it.
  printf("Test: time=%.2f sec (%s) [%i]\n", delta(start, stop), name, result);
  fflush(stdout);

}


int main () {

  int *dataset = new int[DATASET_SIZE];
  srand(time(NULL));
  for (int n = 0; n < DATASET_SIZE; ++ n)
    dataset[n] = rand();

  OperatorSwitch *switchAdd = new OperatorSwitch(OperatorSwitch::Add);
  OperatorSwitch *switchSub = new OperatorSwitch(OperatorSwitch::Subtract);
  OperatorSwitch *switchAlt[2] = { switchAdd, switchSub };
  OperatorBase *virtAdd = new OperatorAdd();
  OperatorBase *virtSub = new OperatorSubtract();
  OperatorBase *virtAlt[2] = { virtAdd, virtSub };
  OperatorPTM *ptmAdd = new OperatorPTM(OperatorPTM::Add);
  OperatorPTM *ptmSub = new OperatorPTM(OperatorPTM::Subtract);
  OperatorPTM *ptmAlt[2] = { ptmAdd, ptmSub };

  while (true) {
    printf("--------------------\n");
    test(dataset, switchAdd, "switch add");
    test(dataset, switchSub, "switch subtract");
    testalt(dataset, switchAlt, "switch alternating");
    testmix(dataset, switchAlt, "switch mixed");
    test(dataset, virtAdd, "virtual add");
    test(dataset, virtSub, "virtual subtract");
    testalt(dataset, virtAlt, "virtual alternating");
    testmix(dataset, virtAlt, "virtual mixed");
    test(dataset, ptmAdd, "ptm add");
    test(dataset, ptmSub, "ptm subtract");
    testalt(dataset, ptmAlt, "ptm alternating");
    testmix(dataset, ptmAlt, "ptm mixed");
    test_ptm(dataset, ptmAdd, "ptm add (direct)");
    test_ptm(dataset, ptmSub, "ptm subtract (direct)");
    testalt_ptm(dataset, ptmAlt, "ptm alternating (direct)");
    testmix_ptm(dataset, ptmAlt, "ptm mixed (direct)");
  }

}

【讨论】:

  • 虚拟函数不是关于额外的周期,而是缓存命中。您会获得一个额外的指针来通过缓存,这基本上可以显着破坏缓存的使用。这不是微优化,而是图形代码的基本架构决策。试试我在回答中的建议。使用虚拟函数对每帧数百万像素执行任何虚拟像素操作,您会发现缓存会出现问题,从而导致显着的性能开销。
  • @LaszloPapp 1) 没有人认为嵌入式设备的性能无关紧要。但是这个问题与嵌入式设备无关! 2/3) 这里的不同选择更高级别的;高于OP所要求的,并且通常需要进行重大的重新设计。 4) 确实如此,但 OP 案例也涉及 在编译时未知的类型,因此需要切换。 5)此外,您关于可以拥有任意数量的屏幕对象的断言是正确的,但鉴于我们没有来自 OP 的关于正在发生的事情的信息,这并不是一个真正有意义的答案基础。
  • 我认为您可能正在尝试为无法一概而论的情况提出一般的经验法则。你的观点在某些情况下非常好和有效,而在其他情况下则不然。 很多 假设在您的答案中做出,如果 OP 将其视为 一般 建议,它并不总是有益的。 (这也是基准 -> 优化的有力案例。)
  • 1) 当您谈论在 PC 上进行开发时,您可以完全避免讨论嵌入式系统。我真的不确定这个断言来自哪里。您根据您定义的最低系统要求设置性能测试。您不会尝试编写在 50MHz 处理器上运行时满足性能要求的代码。技术进步是有原因的,不利用它是没有意义的。
  • 我喜欢这样的测试。我还在其他 cmets 中提出了另一个建议。这是现在公认的答案,谢谢。
【解决方案3】:

“许多物体自己画”的模型很吸引人,但偷偷摸摸地很糟糕。这不是虚函数调用开销(存在,但很小),它鼓励渲染反模式:让每个对象孤立地绘制自己。这听起来像是“软件工程最佳实践”中吹捧的那些东西之一,但事实并非如此,它非常糟糕。每个对象都会进行大量昂贵的 API 调用(例如绑定着色器和纹理)。现在,我真的不知道你的代码是什么样子的,也许它不是这样工作的,对象不一定是坏的,这就是它们的使用方式。

无论如何,这里有一些建议。

按他们想要的状态(着色器、纹理、顶点缓冲区,按此顺序)对您的对象进行排序(实际上,不要排序 - 将它们放入存储桶并对其进行迭代)。这很容易,每个人都会这样做,而且可能就足够了。

合并状态,因此无需切换。使用超着色器。使用纹理数组,或者更好的无绑定纹理(不存在所有切片必须具有相同格式/大小/等的问题)。使用一个巨大的顶点缓冲区,将所有内容都放入其中。使用统一的缓冲区。对动态缓冲区使用持久映射。

最后,glMultiDrawElementsIndirect。如果您按照前面的建议将所有内容都放入缓冲区,那么您只需很少调用glMultiDrawElementsIndirect非常很少,一个电话就可以做很多事情。否则,您可能会使用一堆 glDrawArrays,两者之间没有绑定,这也不错,但要让它变得更好并不需要太多努力。

最终结果是实际的绘图代码几乎消失了。几乎所有 API 调用都消失了,取而代之的是写入缓冲区。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2014-10-24
    • 1970-01-01
    • 1970-01-01
    • 2014-04-02
    • 2016-07-08
    • 1970-01-01
    • 2020-09-30
    • 2012-05-06
    相关资源
    最近更新 更多