ChatGPT解决这个技术问题 Extra ChatGPT

虚函数和性能 - C++

在我的类设计中,我广泛使用抽象类和虚函数。我有一种感觉,虚拟功能会影响性能。这是真的?但我认为这种性能差异并不明显,看起来我正在做过早的优化。正确的?

根据我的回答,我建议将此作为 stackoverflow.com/questions/113830 的副本关闭
如果您正在进行高性能计算和数字运算,请不要在计算核心中使用任何虚拟性:它肯定会扼杀所有性能并阻止编译时的优化。对于程序的初始化或结束,它并不重要。使用接口时,您可以根据需要使用虚拟性。
quick-bench.com/q/hU7VjdB0IP7rxjYuH46xbocVBxY 试试这个基准。紧密循环中的 10% 差异。一次通话 20% quick-bench.com/q/Y4FvX3adXOjVp3Bh2SmbG-jVtco

C
Crashworks

你的问题让我很好奇,所以我继续在我们使用的 3GHz 有序 PowerPC CPU 上运行了一些时序。我运行的测试是用 get/set 函数制作一个简单的 4d 矢量类

class TestVec 
{
    float x,y,z,w; 
public:
    float GetX() { return x; }
    float SetX(float to) { return x=to; }  // and so on for the other three 
}

然后我设置了三个数组,每个数组包含 1024 个这些向量(小到足以放入 L1)并运行一个循环,将它们彼此相加(Ax = Bx + Cx)1000 次。我使用定义为 inlinevirtual 的函数和常规函数调用来运行它。结果如下:

内联:8ms(每次调用 0.65ns)

直接:68ms(每次调用 5.53ns)

虚拟:160ms(每次调用 13ns)

因此,在这种情况下(一切都适合缓存),虚函数调用比内联调用慢 20 倍左右。但这究竟意味着什么?循环中的每次行程恰好导致 3 * 4 * 1024 = 12,288 次函数调用(1024 个向量乘以 4 个分量乘以每次添加的 3 次调用),因此这些时间代表 1000 * 12,288 = 12,288,000 次函数调用。虚拟循环比直接循环多花 92 毫秒,因此每次调用的额外开销是每个函数 7 纳秒

由此我得出结论:是的,虚函数比直接函数慢得多,而且不,除非您计划每秒调用它们一千万次,否则没关系。

另请参阅:comparison of the generated assembly.


但是,如果它们被多次调用,它们通常会比只调用一次更便宜。请参阅我不相关的博客:phresnel.org/blog,标题为“认为无害的虚拟功能”的帖子,但这当然取决于您的代码路径的复杂性
我的测试测量了一小组重复调用的虚函数。您的博客文章假设代码的时间成本可以通过计数操作来衡量,但这并不总是正确的;现代处理器上 vfunc 的主要成本是由分支错误预测引起的管道泡沫。
这将是 gcc LTO(链接时间优化)的一个很好的基准;尝试在启用 lto 的情况下再次编译:gcc.gnu.org/wiki/LinkTimeOptimization 并查看 20x 因子会发生什么
如果一个类有一个虚函数和一个内联函数,那么非虚方法的性能也会受到影响吗?仅仅是因为类的性质是虚拟的?
@thomthom 不,虚拟/非虚拟是每个功能的属性。仅当函数被标记为虚拟或覆盖将其作为虚拟的基类时,才需要通过 vtable 定义函数。您会经常看到具有一组用于公共接口的虚函数的类,然后是大量的内联访问器等等。 (从技术上讲,这是特定于实现的,编译器甚至可以对标记为“内联”的函数使用虚拟指针,但是编写这样一个编译器的人会发疯。)
G
Greg Hewgill

一个好的经验法则是:

在您能够证明之前,这不是性能问题。

使用虚函数对性能的影响很小,但不太可能影响应用程序的整体性能。寻找性能改进的更好地方是算法和 I/O。

Member Function Pointers and the Fastest Possible C++ Delegates 是一篇讨论虚函数(及更多内容)的优秀文章。


纯虚函数呢?它们会以任何方式影响性能吗?只是想知道它们似乎只是为了强制执行。
@thomthom:正确,纯虚函数和普通虚函数之间没有性能差异。
C
Chuck

当 Objective-C(所有方法都是虚拟的)是 iPhone 的主要语言,而该死的 Java 是 Android 的主要语言时,我认为在我们的 3 GHz 双核塔上使用 C++ 虚拟函数是非常安全的。


我不确定 iPhone 是性能代码的一个很好的例子:youtube.com/watch?v=Pdk2cJpSXLg
@Crashworks:iPhone 根本不是代码示例。这是硬件的一个例子——特别是慢速硬件,这就是我在这里提出的观点。如果这些所谓的“慢”语言对于功能不足的硬件来说足够好,那么虚拟功能就不会成为一个大问题。
iPhone 在 ARM 处理器上运行。用于 iOS 的 ARM 处理器专为低 MHz 和低功耗使用而设计。 CPU 上没有用于分支预测的芯片,因此没有来自虚拟函数调用的分支预测未命中的性能开销。此外,iOS 硬件的 MHz 足够低,以至于在从 RAM 检索数据时,缓存未命中不会使处理器停顿 300 个时钟周期。高速缓存未命中在较低的 MHz 上不太重要。简而言之,在 iOS 设备上使用虚拟功能没有开销,但这是硬件问题,不适用于桌面 CPU。
作为一个长期接触 C++ 的 Java 程序员,我想补充一点,Java 的 JIT 编译器和运行时优化器能够在预定义的循环次数后在运行时编译、预测甚至内联一些函数。但是我不确定 C++ 在编译和链接时是否具有这样的功能,因为它缺少运行时调用模式。因此,在 C++ 中,我们可能需要更加小心。
@AlexSuo 我不确定你的意思吗?在编译时,C++ 当然不能根据运行时可能发生的情况进行优化,因此预测等必须由 CPU 本身完成……但是好的 C++ 编译器(如果有指示)很早就会竭尽全力优化函数和循环运行。
M
Mark James

在性能非常关键的应用程序(如视频游戏)中,虚拟函数调用可能太慢。对于现代硬件,最大的性能问题是缓存未命中。如果数据不在缓存中,则可能需要数百个周期才能使用。

当 CPU 获取新函数的第一条指令并且它不在缓存中时,正常的函数调用会产生指令缓存未命中。

虚函数调用首先需要从对象加载 vtable 指针。这可能导致数据缓存未命中。然后它从 vtable 加载函数指针,这可能导致另一个数据缓存未命中。然后它调用可能导致指令缓存未命中的函数,就像非虚拟函数一样。

在许多情况下,两个额外的缓存未命中不是问题,但在性能关键代码的紧密循环中,它会显着降低性能。


是的,但是从紧密循环中重复调用的任何代码(或 vtable)(当然)很少会遭受缓存未命中。此外,vtable 指针通常与被调用方法将访问的对象中的其他数据位于同一缓存行中,因此我们通常只讨论一次额外的缓存未命中。
@Qwertie我认为这没有必要。循环体(如果大于 L1 缓存)可能会“退出”vtable 指针、函数指针,并且后续迭代将不得不在每次迭代时等待 L2 缓存(或更多)访问
B
Boojum

来自 Agner Fog's "Optimizing Software in C++" manual 第 44 页:

调用一个虚成员函数所花费的时间比调用一个非虚成员函数所花费的时间要多几个时钟周期,前提是函数调用语句总是调用相同版本的虚函数。如果版本发生变化,那么您将获得 10 - 30 个时钟周期的误预测惩罚。虚函数调用的预测和错误预测规则与switch语句相同......


感谢您的参考。 Agner Fog 的优化手册是最佳利用硬件的黄金标准。
根据我的回忆和快速搜索 - stackoverflow.com/questions/17061967/c-switch-and-jump-tables - 我怀疑这对于 switch 是否总是正确。当然,使用完全任意的 case 值。但是如果所有的 case 都是连续的,编译器可能能够将其优化为一个跳转表(啊,这让我想起了 Z80 的美好时光),它应该是(为了更好的术语)恒定 -时间。 不是,我建议尝试用 switch 替换 vfunc,这很可笑。 ;)
@underscore_d 我认为 vtable 可以优化为跳转表是对的,但是 Agner 关于 rules for prediction and misprediction of virtual function calls is the same as for switch statements 的陈述在某种意义上也是正确的,假设 vtable 被实现为 switch-case,那么有两种可能性:1 )如果案例是连续的,它会被优化为跳转表(如你所说),2)它不能优化为跳转表,因为案例不连续,因此 will get a misprediction penalty of 10 - 30 clock cycles 如 Anger 所述。
g
gbjbaanb

绝对地。当计算机以 100Mhz 运行时,这是一个问题,因为每个方法调用都需要在调用之前查找 vtable。但是今天.. 在一个 3Ghz CPU 上,它具有比我的第一台计算机更多的内存的一级缓存?一点也不。与所有功能都是虚拟的相比,从主 RAM 分配内存将花费更多时间。

就像过去人们说结构化编程很慢,因为所有代码都被拆分成函数,每个函数都需要堆栈分配和函数调用!

唯一一次我什至会考虑考虑虚拟函数对性能的影响,是它是否被大量使用并在模板代码中实例化,最终贯穿所有内容。即便如此,我也不会在这上面花太多力气!

PS 想想其他“易于使用”的语言——它们所有的方法都是虚拟的,而且它们现在不会爬行。


好吧,即使在今天,避免函数调用对于高性能应用程序也很重要。不同之处在于,今天的编译器可靠地内联小函数,因此我们不会因编写小函数而遭受速度损失。至于虚函数,智能 CPU 可以对其进行智能分支预测。我认为,旧计算机速度较慢的事实并不是真正的问题——是的,它们要慢得多,但当时我们知道这一点,所以我们给它们的工作量要小得多。在 1992 年,如果我们播放 MP3,我们知道我们可能需要将一半以上的 CPU 用于该任务。
mp3 可以追溯到 1995 年。在 92 中,我们几乎没有 386 个,他们不可能播放 mp3,并且 50% 的 cpu 时间假设一个好的多任务操作系统、一个空闲进程和一个抢占式调度程序。这在当时的消费市场上都不存在。从电源打开的那一刻起,它就是 100%,故事结束。
C
Community

除了执行时间之外,还有另一个性能标准。 Vtable 也会占用内存空间,在某些情况下可以避免:ATL 使用编译时“simulated dynamic binding”和 templates 来获得“静态多态”的效果,这有点难以解释;您基本上将派生类作为参数传递给基类模板,因此在编译时基类“知道”每个实例中的派生类是什么。不会让您将多个不同的派生类存储在基类型的集合中(即运行时多态性),但从静态意义上讲,如果您想创建一个与预先存在的模板类 X 相同的类 Y,它具有对于这种覆盖的钩子,你只需要覆盖你关心的方法,然后你就可以得到类 X 的基方法,而不必有一个 vtable。

在具有大内存占用的类中,单个 vtable 指针的成本并不多,但 COM 中的一些 ATL 类非常小,如果永远不会发生运行时多态情况,那么节省 vtable 是值得的。

另见this other SO question

顺便说一下,这里的 a posting I found 讨论了 CPU 时间性能方面。


S
Serge

是的,您是对的,如果您对虚函数调用的成本感到好奇,您可能会发现 this post 很有趣。


链接的文章没有考虑虚拟调用的非常重要的部分,那就是可能的分支预测错误。
D
Daemin

我可以看到虚拟函数成为性能问题的唯一方法是,如果在紧密循环中调用许多虚拟函数,并且当且仅当它们导致页面错误或其他“重”内存操作发生时。

尽管就像其他人所说的那样,在现实生活中这对你来说几乎永远不会成为问题。如果您认为是这样,请运行分析器,进行一些测试,并验证这是否真的是一个问题,然后再尝试“取消设计”您的代码以获得性能优势。


在一个紧密的循环中调用任何东西可能会使所有代码和数据在缓存中保持热...
是的,但是如果正确的循环正在遍历对象列表,那么每个对象都可能通过相同的函数调用在不同的地址调用虚函数。
E
Evgueny Sedov

当类方法不是虚拟的时,编译器通常会进行内联。相反,当您使用指向具有虚函数的某个类的指针时,只有在运行时才能知道真实地址。

测试很好地说明了这一点,时间差 ~700% (!):

#include <time.h>

class Direct
{
public:
    int Perform(int &ia) { return ++ia; }
};

class AbstrBase
{
public:
    virtual int Perform(int &ia)=0;
};

class Derived: public AbstrBase
{
public:
    virtual int Perform(int &ia) { return ++ia; }
};


int main(int argc, char* argv[])
{
    Direct *pdir, dir;
    pdir = &dir;

    int ia=0;
    double start = clock();
    while( pdir->Perform(ia) );
    double end = clock();
    printf( "Direct %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    Derived drv;
    AbstrBase *ab = &drv;

    ia=0;
    start = clock();
    while( ab->Perform(ia) );
    end = clock();
    printf( "Virtual: %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    return 0;
}

虚函数调用的影响很大程度上取决于情况。如果函数内部调用很少且工作量很大 - 它可以忽略不计。

或者,当它是一个多次重复使用的虚拟调用,同时做一些简单的操作时——它可能真的很大。


++ia 相比,虚函数调用代价高昂。所以呢?
quick-bench.com/q/hU7VjdB0IP7rxjYuH46xbocVBxY 这是一个仅显示 10% 差异的基准。
I
It'sPete

在我的特定项目中,我至少来回讨论了 20 次。尽管在代码重用、清晰性、可维护性和可读性方面可以获得一些巨大的收益,但另一方面,虚拟函数仍然存在性能损失。

现代笔记本电脑/台式机/平板电脑上的性能影响是否会很明显……可能不会!但是,在嵌入式系统的某些情况下,性能下降可能是代码效率低下的驱动因素,尤其是在循环中一遍又一遍地调用虚函数时。

这是一篇有些过时的论文,它分析了嵌入式系统环境中 C/C++ 的最佳实践:http://www.open-std.org/jtc1/sc22/wg21/docs/ESC_Boston_01_304_paper.pdf

总结:由程序员来理解使用某种结构而不是另一个结构的利弊。除非你是超级性能驱动的,否则你可能不关心性能损失,应该使用 C++ 中所有整洁的 OO 东西来帮助你的代码尽可能地可用。


佚名

以我的经验,主要相关的事情是内联函数的能力。如果您有性能/优化需求,需要内联函数,那么您不能将函数设为虚拟,因为它会阻止这种情况。否则,您可能不会注意到差异。


O
OwnageIsMagic

需要注意的一点是:

boolean contains(A element) {
    for (A current : this)
        if (element.equals(current))
            return true;
    return false;
}

可能比这更快:

boolean contains(A element) {
    for (A current : this)
        if (current.equals(element))
            return true;
    return false;
}

这是因为第一种方法只调用一个函数,而第二种方法可能调用许多不同的函数。这适用于任何语言的任何虚函数。

我说“可能”,因为这取决于编译器、缓存等。


佚名

使用虚函数的性能损失永远不会超过您在设计级别获得的优势。假设调用虚函数的效率比直接调用静态函数的效率低 25%。这是因为通过 VMT 存在一定程度的间接性。但是,与实际执行函数所花费的时间相比,进行调用所花费的时间通常非常小,因此总性能成本将是微不足道的,尤其是在当前硬件性能的情况下。此外,编译器有时可以优化并看到不需要虚拟调用并将其编译为静态调用。所以不用担心尽可能多地使用虚函数和抽象类。


永远不会,无论目标计算机有多小?
如果您将其表述为 The performance penalty of using virtual functions can sometimes be so insignificant that it is completely outweighed by the advantages you get at the design level.,我可能会同意。主要区别在于说 sometimes,而不是 never
考虑到最近推动对缓存更友好的编程,因为内存速度跟不上 CPU 速度,这个答案是个坏建议。
c
christianparpart

我总是这样质疑自己,特别是因为 - 好几年前 - 我也做过这样的测试,比较标准成员方法调用和虚拟方法调用的时间,当时对结果非常生气,空的虚拟调用是比非虚拟机慢 8 倍。

今天我不得不决定是否使用虚函数在我的缓冲区类中分配更多内存,在一个性能非常关键的应用程序中,所以我用谷歌搜索(并找到了你),最后,再次进行了测试。

// g++ -std=c++0x -o perf perf.cpp -lrt
#include <typeinfo>    // typeid
#include <cstdio>      // printf
#include <cstdlib>     // atoll
#include <ctime>       // clock_gettime

struct Virtual { virtual int call() { return 42; } }; 
struct Inline { inline int call() { return 42; } }; 
struct Normal { int call(); };
int Normal::call() { return 42; }

template<typename T>
void test(unsigned long long count) {
    std::printf("Timing function calls of '%s' %llu times ...\n", typeid(T).name(), count);

    timespec t0, t1;
    clock_gettime(CLOCK_REALTIME, &t0);

    T test;
    while (count--) test.call();

    clock_gettime(CLOCK_REALTIME, &t1);
    t1.tv_sec -= t0.tv_sec;
    t1.tv_nsec = t1.tv_nsec > t0.tv_nsec
        ? t1.tv_nsec - t0.tv_nsec
        : 1000000000lu - t0.tv_nsec;

    std::printf(" -- result: %d sec %ld nsec\n", t1.tv_sec, t1.tv_nsec);
}

template<typename T, typename Ua, typename... Un>
void test(unsigned long long count) {
    test<T>(count);
    test<Ua, Un...>(count);
}

int main(int argc, const char* argv[]) {
    test<Inline, Normal, Virtual>(argc == 2 ? atoll(argv[1]) : 10000000000llu);
    return 0;
}

并且真的很惊讶它 - 事实上 - 真的不再重要了。虽然内联比非虚拟更快是有意义的,而且它们比虚拟更快,但它通常涉及到整个计算机的负载,无论你的缓存是否有必要的数据,虽然你可能能够优化在缓存级别,我认为这应该由编译器开发人员完成,而不是由应用程序开发人员完成。


我认为您的编译器很可能会告诉您代码中的虚函数调用只能调用 Virtual::call。在这种情况下,它可以内联它。即使您没有要求,也没有什么可以阻止编译器内联 Normal::call。因此,我认为这 3 个操作很可能获得相同的时间,因为编译器正在为它们生成相同的代码。