【问题标题】:Virtual Function Implementation虚函数实现
【发布时间】:2010-03-09 01:29:46
【问题描述】:

我一直听到这种说法。 Switch..Case 对于代码维护来说是邪恶的,但它提供了更好的性能(因为编译器可以内联东西等)。虚函数非常适合代码维护,但它们会导致两个指针间接的性能损失。

假设我有一个基类,它有 2 个子类(X 和 Y)和一个虚函数,所以会有两个虚表。该对象有一个指针,它将根据该指针选择一个虚拟表。所以对于编译器来说,更像是

switch( object's function ptr )
{

   case 0x....:

       X->call();

       break;

   case 0x....:

       Y->call();
};

那么为什么虚函数的成本会更高,如果它可以通过这种方式实现,因为编译器可以在这里做同样的内联和其他事情。或者解释一下,为什么决定不这样实现虚函数执行?

谢谢, 悟空。

【问题讨论】:

    标签: c++ virtual


    【解决方案1】:

    由于单独的编译模型,编译器无法做到这一点。

    在编译虚函数调用时,编译器无法确定有多少不同的子类。

    考虑这段代码:

    // base.h
    class base
    {
    public:
        virtual void doit();
    };
    

    还有这个:

    // usebase.cpp
    #include "base.h"
    
    void foo(base &b)
    {
        b.doit();
    }
    

    当编译器在foo 中生成虚调用时,它不知道运行时会存在哪些base 子类。

    【讨论】:

    • 我已经在下面问了这个问题。但是链接器可以做这种优化吗?
    • 你可以让链接器来做,但这会带来它自己的问题。编译器必须在指令流中留出空间供链接器修补代码——但是编译器如何知道要留多少空间?您可以让编译器调用链接器生成的函数,但随后又回到分支,因此您什么也没得到。
    • 这正是链接时代码生成和配置文件引导优化可以做的事情。注意我说可以——我没有声称知道其中任何一个的血淋淋的细节。
    • 链接器不需要知道函数的地址来执行此操作(想想共享对象)吗?
    【解决方案2】:

    您的问题在于对开关和虚拟功能工作方式的误解。我不会用关于代码生成的长篇论文来填满这个框,而是给出几个要点:

    • Switch 语句不一定比虚函数调用或内联语句快。您可以了解更多关于 switch 语句转换为程序集 herehere 的方式。
    • 虚函数调用缓慢的不是指针查找,而是间接分支。对于complicated reasons having to do with the internal electronics of the CPU,对于大多数现代处理器,执行“直接分支”(目标地址在指令中编码)比“indirect branch”(在运行时计算地址)更快。虚函数调用和大型 switch 语句通常实现为间接分支。
    • 在上面的示例中,交换机是完全冗余的。一旦对象的成员函数指针被计算出来,CPU 就可以直接跳转到它。即使链接器知道可执行文件中存在的每个可能的成员对象,仍然没有必要添加该表查找。

    【讨论】:

      【解决方案3】:

      以下是具体测试的一些结果。这些特定结果来自 VC++ 9.0/x64:

      Test Description: Time to test a global using a 10-way if/else if statement
      CPU Time:        7.70  nanoseconds           plus or minus      0.385
      
      Test Description: Time to test a global using a 10-way switch statement
      CPU Time:        2.00  nanoseconds           plus or minus     0.0999
      
      Test Description: Time to test a global using a 10-way sparse switch statement
      CPU Time:        3.41  nanoseconds           plus or minus      0.171
      
      Test Description: Time to test a global using a 10-way virtual function class
      CPU Time:        2.20  nanoseconds           plus or minus      0.110
      

      在稀疏情况下,switch 语句的速度要慢得多。在密集的情况下,switch 语句可能更快,但 switch 和虚函数 dispatch 有点重叠,所以虽然 switch 可能更快,但余量是如此之小,我们甚至无法确定它速度更快,更不用说速度足够关心了。如果 switch 语句中的 case 是稀疏的,那么虚函数调用会更快是毫无疑问的。

      【讨论】:

      • 不错的统计数据。你能解释一下为什么在 boost 中推荐 boost::variant 而不是虚函数吗?
      • @Gokul:我能做的就是通读他们的邮件列表,试图找出他们的推理。它可能已经过时,或者它可能只适用于某些情况,或者......
      【解决方案4】:

      虚拟调度中没有分支。您的类中的 vptr 指向一个 vtable,第二个指针指向特定函数的常量偏移量。

      【讨论】:

      • 从技术上讲,一个分支;汇编编写器将跳转到存储在寄存器中的地址称为“间接分支”。
      • 在类似 switch 的语句中进行分支需要您知道所有分支可能是什么。使用 vtable 指针,您可以向程序添加一个新的后代类(可能通过在宿主程序的代码发布多年后加载的共享库),而无需重新编译可能通过基础使用该后代的所有内容 -类指针。
      • 但是当我编译和链接生成一个可执行文件时,我不是告诉编译器/链接器这是一个最终的可执行文件,并且没有更多的共享库可以作为后代添加吗?或者换句话说,链接器可以执行这些优化吗?
      • 不一定。例如使用动态库,如果您更新 dll,则无需重新链接。
      【解决方案5】:

      实际上,如果你有很多虚函数,类似 switch 的分支会比两个指针间接更慢。当前实现的性能不取决于您拥有多少虚拟功能。

      【讨论】:

      • 是的!如果有需要优化的案例,使用较少的 switch..case,那么编译器可以选择性地处理它吗?
      【解决方案6】:

      你关于调用虚函数时分支的说法是错误的。生成的代码中没有这样的东西。看看汇编代码会给你一个更好的主意。

      简而言之,C++ 虚函数的一个通用简化实现是:每个类都有一个虚拟表(vbtl),类的每个实例都有一个虚拟表指针(vptr)。虚拟表基本上是一个函数指针列表。

      当你调用一个虚函数时,说它是这样的:

      class Base {};
      class Derived {};
      Base* pB = new Derived();
      pB->someVirtualFunction();
      

      'someVirtualFunction()' 将在 vtbl 中有相应的索引。还有电话

      pB->someVirtualFunction(); 
      

      将被转换为:

      pB->vptr[k](); //k is the index of the 'someVirtualFunction'.
      

      这样函数实际上是间接调用的,它具有多态性。

      我建议您阅读 Stanley Lippman 的 'The C++ Object Model'

      另外,虚函数调用比switch-case慢的说法是不准确的。这取决于。正如您在上面看到的,与常规函数调用相比,虚函数调用只是多出 1 次取消引用。并且通过 switch-case 分支,您将拥有额外的比较逻辑(这会导致 CPU 丢失缓存的可能性),这也会消耗 CPU 周期。我想说在大多数情况下,如果不是全部的话,虚拟函数调用应该比 switch-case 更快。

      【讨论】:

      • 那么,如果我们知道所涉及的子类,为什么 boost 社区的人们建议使用 boost::variant 来代替虚函数?我认为 boost::variant 在内部使用 switch..case。
      • 我不确定您为什么将虚函数与 boost::variant 进行比较。他们正在解决不同的问题。
      • 如果我读到 boost::variant 的问题/动机,他们就是这么说的。你甚至可以阅读这个帖子lists.boost.org/Archives/boost/2010/02/162072.php
      • @Gokul:首先,虚函数试图解决什么问题? (动态)多态性,它允许不同的类型由统一的接口处理。直到运行时才绑定虚函数调用。这里的关键是该类型在运行之前一直保持为 UNKNOWN。在您提供的链接中,函数调用的类型实际上在编译时就已经知道,这就是为什么它可以以 switch-case 方式实现的原因。我们可以称之为编译时多态。这是一种在 WTL 等库中广泛使用的技术。这里的关键是该类型在编译时是 KNOWN。
      【解决方案7】:

      明确地说switch/case 比虚拟调用的性能或多或少是一种过度概括。事实上,这取决于很多事情,并且会因以下因素而有所不同:

      • 您使用的是什么编译器
      • 启用了哪些优化
      • 程序的整体特征以及它们如何影响这些优化

      如果您在编写代码时在脑海中优化代码,那么您很有可能做出了错误的选择。首先以人类可读和/或用户友好的方式编写代码,然后通过分析工具运行整个可执行文件。如果此代码区域显示为热点,请尝试两种方式,看看哪种方式更适合您的特定情况。

      【讨论】:

        【解决方案8】:

        这些优化只有通过重新修补链接器才能实现,该链接器应该作为 C++ 运行时的一部分运行。

        C++ 运行时更加复杂,即使是新的 DLL 加载(使用 COM)也会向 vtable 添加新的函数指针。 (想想纯虚拟 fns 吗?)

        然后编译器或链接器都无法进行此优化。 switch/case 显然比间接调用快,因为 CPU 中的预取是确定性的并且流水线是可能的。但由于对象 vtable 的运行时扩展,它不会在 C++ 中运行。

        【讨论】: