避免重复的C ++虚拟表查找

问题描述 投票:6回答:2

我有一个C ++程序,该程序在执行二进制文件时会读取配置文件,基于该配置文件创建多个子类实例,然后定期遍历这些实例并调用它们各自的虚函数。

[Gprof告诉我这些函数调用占用大量时间(上述迭代非常频繁地发生,所以我想尝试避免以某种方式重复进行虚函数调用。

代码类似于以下内容。一旦程序在程序开始时填充了向量v,该向量就不会在其余程序中更改,因此,每次我想调用f()时,重复执行虚拟表查找似乎效率低下。我认为一定有某种方式可以缓存或保存函数指针,但是我不确定如何。

希望您有任何提速建议。谢谢!

编辑:抱歉,我忘了提到为Child实例的向量调用的函数f()必须按从0到v.size()-1的顺序排列,所以我无法将v的元素组合在一起具有相同的派生类型。

而且,这是使用-O3 -std = c ++ 14构建的

class Parent {
public:
    virtual void f() { } 
};

class Child1 : public Parent {
public:
    void f() { /* do stuff for child1 */ }
};

//...

class Child9 : public Parent {
public:
    void f() { /* do stuff for child9 */ }
};

int main() {
    vector<Parent*> v;

    // read config file and add Child instances to v based on the file contents

    while (true) {
        // do other stuff
        for (size_t i = 0; i != v.size(); ++i) {
             v[i]->f(); // expensive to do the same virtual table lookups every loop!
        }
    }
};
c++ optimization virtual-functions vtable virtual-table
2个回答
1
投票
基于一些问题和您在评论中的答案,有几点考虑。

1)您的问题(如果有的话,您的解决方案可能已经接近最优,具体取决于您未提及的细节)很可能在其他地方,而不是虚拟函数调用的开销。

如果您确实在一个紧密的循环中运行此函数,并且f()的实现中没有涉及太多内存的事情,那么您的vtable可能会保留在L1缓存中,并且虚函数调用的开销将绝对最少,如果有的话,在现代硬件上。

2)您说“函数f()本身非常简单,例如其中一个函数只是将两个内存地址的值相乘,然后将乘积存储在第三个地址中”-这可能不像您期望的那样无辜。作为参考,使用L1高速缓存将花费您大约3个周期,使用RAM可能会花费60-200,这取决于您的硬件。

如果您有足够的这些对象(因此不可能将它们引用的所有内存都保留在L1高速缓存中,并且它们引用的内存位置基本上是随机的(因此预取无效),和/或您触摸足够多程序其余部分的所有事情(以便使所有相关数据从向量循环之间的高速缓存中腾出),从内存中获取值和将值存储到内存中的成本/较低级别的高速缓存将超过虚拟的成本在最坏的情况下,函数调用要按数量级进行。

3)您遍历指向对象的指针矢量-而不是对象本身。

取决于您如何分配对象以及对象的大小,这可能不是问题-如果以紧密循环分配它们并且分配器很好地打包它们,预取将为您带来奇迹。但是,如果您分配/释放了很多其他东西并混合了这些对象之间的分配,则它们最终可能会稀疏地位于内存中的基本上随机的位置;然后按照创建顺序对其进行迭代将涉及从内存中进行大量随机读取,这将再次比任何虚拟函数开销都要慢得多。

4)您说“必须按顺序调用子向量的f()-是吗?

如果他们这样做,那么您在某些方面会走运。但是,如果您可以重新架构系统,以便可以按类型对它们进行调用,那么在各个方面都可以提高很多速度-您可能可以为每种类型的对象分配一个数组(很好,密集包装在内存中),按顺序对其进行迭代(友好于prefetcher),并成组调用您的f()以使用单个已知类型(内联友好,指令缓存友好)。

5)最后-如果以上都不适用,而您的问题确实在虚函数调用中(不太可能),那么可以,您可以尝试存储指向您需要以某种方式为每个对象调用的确切函数的指针-手动或使用其他人建议的类型擦除/鸭式键入方法之一。

我的主要观点是-以某种方式更改系统的体系结构将带来很多性能优势。

[请记住:访问已经存在于L1 / L2缓存中的内容是件好事,必须去L3 / RAM获取数据更糟;按顺序访问内存是好的,遍历内存是不好的;在紧密循环中调用相同的方法(可能会对其进行内联)是好的,在紧密循环中调用很多不同的方法会更糟。

如果这是程序的一部分,而其性能确实很重要,则应考虑更改系统的体系结构以允许进行一些前面提到的优化。我知道这似乎令人生畏,但这就是我们正在玩的游戏。如果您要解决的问题允许的话,有时您需要牺牲“干净的” OOP和抽象来提高性能。


1
投票
编辑:对于混合在一起的任意子类型的向量,建议进行虚拟调用。


如果根据配置,只有一个子类型的向量-或者如果您可以将不同类型的向量分隔到单独的容器中,则可能是编译时多态而不是运行时一个的情况。例如:

template<class Child, class Range> void f_for(Range& r) { for (Parent* p : r) { Child* c = static_cast<Child*>(p); c->Child::f(); // use static dispatch to avoid virtual lookup } } ... if (config) f_for<Child1>(v); else f_for<Child2>(v);

显式静态分派的替代方法是将子类或成员函数标记为final。

您甚至可以扩展程序的静态部分,以便直接使用vector<Child1>vector<Child2>,避免了额外的间接操作。在这一点上,继承甚至是没有必要的。

© www.soinside.com 2019 - 2024. All rights reserved.