缓存大量回调,然后在没有 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
}
速度非常快。
但是,它的扩展性不好。
经过几个发展周期,诞生了C3
、C4
等
我必须手动创建 std::vector<C3*>
、std::vector<C4*>
、...
我的方法导致可维护性地狱。
更多信息(已编辑)
最坏的情况下,最多有20个class。 (C1
至 C20
)
在实际情况下,C1
、C2
、...是特殊类型的数据结构。
所有这些都需要在精确的时间进行特殊初始化 (f()
)。
它们的实例是在不同的地方构建的 .cpp
。
因此,缓存所有这些的数组存储 std::vector<CBase*> cBase;
会很有用。
例如C1
就是map 1:1
,C2
就是map 1:N
,C3
就是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" 开始看起来好多了。我们的目标:将 c1s
、c2s
等存储在单个向量中。
为此,我们需要将派生类型映射到连续的整数。一个简单的方法是使用全局计数器和一个函数模板,每次实例化时都会递增和存储它。
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);
}
好了!现在会自动注册新的派生回调。
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
}
速度非常快。
但是,它的扩展性不好。
经过几个发展周期,诞生了C3
、C4
等
我必须手动创建 std::vector<C3*>
、std::vector<C4*>
、...
我的方法导致可维护性地狱。
更多信息(已编辑)
最坏的情况下,最多有20个class。 (C1
至 C20
)
在实际情况下,C1
、C2
、...是特殊类型的数据结构。
所有这些都需要在精确的时间进行特殊初始化 (f()
)。
它们的实例是在不同的地方构建的 .cpp
。
因此,缓存所有这些的数组存储 std::vector<CBase*> cBase;
会很有用。
例如C1
就是map 1:1
,C2
就是map 1:N
,C3
就是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" 开始看起来好多了。我们的目标:将 c1s
、c2s
等存储在单个向量中。
为此,我们需要将派生类型映射到连续的整数。一个简单的方法是使用全局计数器和一个函数模板,每次实例化时都会递增和存储它。
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);
}
好了!现在会自动注册新的派生回调。