缓存大量回调,然后在没有 v-table 成本的情况下批量调用它们

Cache a lot of callback, then call them all batch-ly without v-table cost

C1,C2,...是回调classes.
它们派生自具​​有回调 CBase::f().
的公共接口 CBase 他们都用 final 修饰符覆盖 CBase::f()

我必须注册 ~50 个 派生自 C1 的任何 class 实例,并且 ~ 50 个 派生自 C2.
的任何 class 实例 (例如,请参见下面代码中的 @@

Main objective: 当我调用 allF() 时,必须调用每个已注册实例的 C1::f() / C2::f() .

这是一个简化版本,它有效 (Full demo) :-

#include <iostream>
#include <vector>
class CBase{
    public: virtual void f(){std::cout<<"CBase"<<std::endl;}
};
class C1 : public CBase{
    public: virtual void f() final{std::cout<<"C1"<<std::endl;}
};
class C2 : public CBase{
    public: virtual void f() final{std::cout<<"C2"<<std::endl;}
};

这是回调注册:-

//-------- begin registering -----
std::vector<CBase*> cBase;   
void regis(CBase* c){
    cBase.push_back(c);
}
void allF(){ //must be super fast
    for(auto ele:cBase){
        ele->f();    //#
    }
}
int main() {
    C1 a;
    C1 b;
    C2 c;   //@@
    //or ... class C2Extend : public C2{};   C2Extend c;  
    regis(&a);
    regis(&b);
    regis(&c);
    allF();  //print C1 C1 C2
}

问题

根据配置文件结果,如果我可以在 # 时避免 v-table 成本,我将获得显着的性能提升。

如何优雅地做到?

我糟糕的解决方案

一个可能的解决方法是:创建许多数组来存储每个 CX (Full demo):-

//-------- begin registering -----
std::vector<C1*> c1s;
std::vector<C2*> c2s;

void regis(C1* c){
    c1s.push_back(c);
}
void regis(C2* c){
    c2s.push_back(c);
}
void allF(){ //must be super fast
    for(auto ele:c1s){
        ele->f();    //#
    }
    for(auto ele:c2s){
        ele->f();    //#
    }
}
int main() {
    C1 a;
    C1 b;
    C2 c;
    regis(&a);
    regis(&b);
    regis(&c);
    allF();  //print C1 C1 C2
}

速度非常快。 但是,它的扩展性不好。
经过几个发展周期,诞生了C3C4
我必须手动创建 std::vector<C3*>std::vector<C4*>、...
我的方法导致可维护性地狱。

更多信息(已编辑)

最坏的情况下,最多有20个class。 (C1C20)

在实际情况下,C1C2、...是特殊类型的数据结构。
所有这些都需要在精确的时间进行特殊初始化 (f())。

它们的实例是在不同的地方构建的 .cpp
因此,缓存所有这些的数组存储 std::vector<CBase*> cBase; 会很有用。

例如C1就是map 1:1C2就是map 1:NC3就是map N:N
与自定义分配器一起,我可以实现不可思议的数据局部性。

更多说明:我不关心回调的顺序。 (感谢火枪兵)

您可以将全局变量拉入 class 在派生类型上模板化,并在实例化时确保它是整个调用的一部分。

typedef void(*Action)(); // function pointer type, receives static call 
std::set<Action> allFs; 

template<typename T>
struct FRegistry 
{
    static std::vector<T*> ts;
    static void doF() 
    { 
        // loop over the derived type, so no need for virtual
        for (T * t : ts) { t->f(); } 
    }
    static void regis(T * item) 
    { 
        allFs.insert(&FRegistry::doF); // Ensure the global call includes this instantiation
        ts.push_back(t); // register the instance
    }
}

template<typename T>
std::vector<T*> FRegistry<T>::ts = {}; // member initialisation

template <typename T>
regis(T * t)
{ 
    FRegistry<T>::regis(t); 
}

void allF()
{ 
    for (Action a : allFs) { a(); } // call each doF
}

用法不变

int main() {
    C1 a;
    C1 b;
    C2 c;
    regis(&a);
    regis(&b);
    regis(&c);
    allF();  //print C1 C1 C2
}

虚拟调用已经是一个非常简单和快速的实现,所以如果这是一个问题,如果不改变结构,任何事情都不够快。值得注意的是,我不希望简单的 std::function 或手动使用函数指针会获得很大的收获。本质上,虚拟呼叫可能如下所示:

class CBase{
    // Compiler generated
    struct Vtable
    {
        void (CBase::*f)();
    };
    public: virtual void f(){std::cout<<"CBase"<<std::endl;}

    // Compiler addded instance field
    Vtable *vtable;
};
class C1 : public CBase{
    public: virtual void f() final{std::cout<<"C1"<<std::endl;}
    // Compiler generated static data to initialise vtable member
    static Vtable C1::type_vtable = { &C1::f };
};


CBase *ptr = vector.front();
ptr->f();
// Gets compiled as
ptr->(*ptr->vtable->f)();

所以在代码层面,它有一些额外的内存读取,然后通过函数指针调用函数。但是,这会阻止许多优化。在编译器级别,它不能再内联函数。在 CPU 级别,您需要 ptr->vtable 位于 CPU 缓存中,并冒着分支预测失败的风险,与直接函数调用相比,这两者的成本都远高于一些内存读起来可能暗示。如果您有许多基础 class 并且它们在容器中相当随机地排序(CPU 可能会一直猜错下一个),则尤其如此。

没有设计更改的最佳解决方案更像您展示的那样。完全摆脱 virtual/indirect 功能并存储在单独的容器中。这让编译器在认为值得的情况下内联函数调用,并使 CPU 变得容易。您也许可以使用重载或模板,所以最坏的情况下只有一个地方可以调用(并且使用模板,根据需要甚至更聪明)。

class Register
{
    std::vector<C1*> c1;
    std::vector<C2*> c2;


    void regis(C1 *c1);
    void regis(C2 *c2);
    //etc.
};

请注意,您更改了调用对象的顺序。您按 class 类型对它们进行了排序,但之前的顺序与调用 regis 的顺序相同。

仅按 class 类型排序(可以使用 typeid 等)也可能对 CPU 有所帮助,但您仍然松散了内联。

"Profile Guided Optimisation"(PGO,在编译器中查找它,例如 MSVC 和 GCC 可以做到这一点)也可能有帮助,需要一些额外的构建时间。它让编译器根据代码进行优化,实际上是 运行。我没有详细查看真实项目的生成代码,但我了解 MSVC 至少可以 "inline" 常见的虚拟调用,就像 typeid 上的 switch 语句一样,允许更好的优化并且可能工作得更好与现代 CPUs.

一个更主要的设计变化是避免使用小的虚函数。在更高级别创建虚函数(例如,将整个容器传递给虚函数,而不是每个元素)。

当您使用模板使其自动化时,您的 "poor solution" 开始看起来好多了。我们的目标:将 c1sc2s 等存储在单个向量中。

为此,我们需要将派生类型映射到连续的整数。一个简单的方法是使用全局计数器和一个函数模板,每次实例化时都会递增和存储它。

static std::size_t typeIndexCounter = 0;

template <class>
std::size_t indexForType() {
    static std::size_t const index = typeIndexCounter++;
    return index;
}

第一次调用 indexForType<T>() 将为 T 保留一个新索引,并且 return 在后续调用中保留一个新索引。

然后,我们需要一种方法来擦除有关回调向量的足够信息,以便我们可以存储它们并对其调用正确的 f

struct Group {
    using CbVec = std::vector<void *>;

    void (*call)(CbVec &);
    CbVec callbacks;
};

static std::vector<Group> groups;

call 将持有一个函数,该函数迭代指针,将它们向下转换并调用 f。就像您的解决方案一样,这会将对单一类型的所有调用分解为一个虚拟调用。

CbVec 可以容纳 CBase * 而不是 void *,但我稍后会解释这个选择。

现在我们需要一个函数来在请求 Group 某些类型时填充 groups

template <class T>
Group &groupFor() {

    std::size_t const index = indexForType<T>();
    if(index < groups.size())
        // Group already exists, return it
        return groups[index];

    assert(
        index == groups.size() &&
        "Something went wrong... Did someone call detail_callbacks::indexForType?"
    );

    // Register the new group, with its downcasting function
    groups.push_back({
        [](Group::CbVec &callbacks) {
            for(void *p : callbacks)
                static_cast<T*>(p)->f();
        },
        {}
    });

    // Return the new group
    return groups.back();
}

在这里你可以看到我们使用lambda表达式来生成向下转换函数。我选择存储 void * 而不是 CBase * 的原因是其中对性能敏感的向下转换变成了无操作,而可能需要从基础到派生的转换指针调整(以及虚拟继承情况下的进一步复杂化)。

最后是publicAPI。以上都在namespace detail_callbacks里面定义好了,我们只需要拼凑一下:

template <
    class T,
    class = std::enable_if_t<std::is_base_of<CBase, T>::value>
>
void regis(T *callback) {
    detail_callbacks::groupFor<T>().callbacks.push_back(static_cast<void*>(callback));
}

void allF() {
    for(auto &group : detail_callbacks::groups)
        group.call(group.callbacks);
}

好了!现在会自动注册新的派生回调。

See it live on Coliru