我正在努力尝试使用枚举和大量的宏观魔法来实现vtable的替代方案,这种魔法真的开始让我的大脑陷入困境。我开始认为我没有走正确的道路,因为代码变得更加丑陋和丑陋,并且无论如何都不适合制作。
如何使用最少量的重定向/操作实现以下代码的模式?
必须在标准c ++中完成,最多17个。
class A{
virtual void Update() = 0; // A is so pure *¬*
};
class B: public A
{
override void Update() final
{
// DO B STUFF
}
}
class C: public A
{
override void Update() final
{
// DO C STUFF
}
}
// class...
int main()
{
std::vector<A*> vecA{};
// Insert instances of B, C, ..., into vecA
for(auto a: vecA) // This for will be inside a main loop
a->Update(); // Ridiculous amount of calls per unit of time
// Free memory
}
PS:如果枚举,开关和宏确实是最好的选择,我想我只是尝试刷新我的缓存并提出更好的设计。
PSS:我知道这是微优化......哎呀,我需要纳米甚至微微优化这个(比喻说),所以我会忽略任何可能出现的功利主义反应。答案 0 :(得分:2)
如果确实需要虚拟调度,一种加速在不同派生类型的对象列表上对同一虚拟方法进行调度的方法是使用我将调用的型外提
有点类似于loop unswitching,这会将调用每个对象上的方法的单个循环转换为N个循环(对于N个支持的类型),每个循环在特定类型的所有对象上调用该方法。这避免了不可预测的虚拟分派的主要成本:在vtable中间接调用未知的,不可预测的函数所暗示的分支误预测。
此技术的通用实现涉及按类型对对象进行分区的第一次传递:第二次传递使用有关此分区的信息,每个类型具有单独的循环 1 ,调用方法。如果仔细实施,这通常不会涉及任何不可预测的分支。
在两个派生类B
和C
的情况下,您只需使用位图来存储类型信息。这是一个示例实现,使用问题代码中的A
,B
,C
类型:
void virtual_call_unswitch(std::vector<A*>& vec) {
// first create a bitmap which specifies whether each element is B or C type
std::vector<uint64_t> bitmap(vec.size() / 64);
for (size_t block = 0; block < bitmap.size(); block++) {
uint64_t blockmap = 0;
for (size_t idx = block * 64; idx < block * 64 + 64; idx++) {
blockmap >>= 1;
blockmap |= (uint64_t)vec[idx + 0]->typecode_ << 63;
}
bitmap[block] = blockmap;
}
// now loop over the bitmap handling all the B elements, and then again for all the C elements
size_t blockidx;
// B loop
blockidx = 0;
for (uint64_t block : bitmap) {
block = ~block;
while (block) {
size_t idx = blockidx + __builtin_ctzl(block);
B* obj = static_cast<B*>(vec[idx]);
obj->Update();
block &= (block - 1);
}
blockidx += 64;
}
// C loop
blockidx = 0;
for (uint64_t block : bitmap) {
while (block) {
size_t idx = blockidx + __builtin_ctzl(block);
C* obj = static_cast<C*>(vec[idx]);
obj->Update();
block &= (block - 1);
}
blockidx += 64;
}
}
此处,typecode
是A
中的公共字段,用于标识0
的对象类型B
和1
的{{1}}。需要类似的东西才能使按类型分类成为可能(它不能成为虚拟呼叫,因为发出不可预测的呼叫是我们首先想要避免的。)
上面稍微优化的版本显示,对于未交换的版本,平均虚拟调度循环的速度提高了3.5倍,虚拟版本每个调度大约需要19个周期,而未切换版本大约为5.5。完整结果:
C
-----------------------------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------------------------
BenchWithFixture/VirtualDispatchTrue 30392 ns 30364 ns 23033 128.646M items/s
BenchWithFixture/VirtualDispatchFakeB 3564 ns 3560 ns 196712 1097.34M items/s
BenchWithFixture/StaticBPtr 3496 ns 3495 ns 200506 1117.6M items/s
BenchWithFixture/UnswitchTypes 8573 ns 8571 ns 80437 455.744M items/s
BenchWithFixture/StaticB 1981 ns 1981 ns 352397 1.9259G items/s
是在VirtualDispatchTrue
类型的指针上调用Update()
的简单循环:
A
在调用for (A *a : vecA) {
a->Update();
}
之前, VirtualDispatchFakeB
将指针强制转换为B*
(无论底层类型是什么)。由于Update()
是最终的,因此编译器可以完全去虚拟化并内联调用。当然,这根本不做正确的事情:它将任何B::Update()
对象视为C
,因此调用错误的方法(并且完全是UB) - 但它& #39;在这里估计如果每个对象都是相同的静态已知类型,你可以在指针向量上调用方法的速度。
B
for (A *a : vecA) {
((B *)a)->Update();
}
遍历StaticBPtr
而不是std::vector<B*>
。正如预期的那样,表现与假冒B&#34;上面的代码,因为std::vector<A*>
的目标是静态知道且完全无法使用的。这是一个健全检查。
Update()
是上面描述的类型非开关技巧。
UnswitchTypes
遍历StaticB
。也就是说,连续地将std::vector<B>
个对象分配给B对象而不是指针的向量。这将删除一个间接级别,并显示类似于此对象布局 2 的最佳情况。
full source可用。
此技术的关键限制是B
调用的顺序不重要。虽然Update()
仍在每个对象上调用一次,但订单已明显更改。只要对象没有更新任何可变的全局或共享状态,这应该很容易满足。
上面的代码只支持两种类型,基于使用位图来记录类型信息。
此限制相当容易删除。首先,可以扩展位图方法。例如,为了支持4种类型,可以创建两个类似的位图,每个位图的相应位基本上用于编码该类型的2位字段。循环是类似的,除了在外部循环中它们Update()
和&
位图一起以所有4种类型的方式。 E.g:
~
另一种方法是根本不使用位图,而只是存储每种类型的索引数组。数组中的每个索引都指向主数组中该类型的对象。基本上它是类型代码的1遍基数排序。这可能会使类型排序部分变慢,但可能加速循环迭代逻辑(// type 1 (code 11)
for (size_t i = 0; i < bitmap1.size(); i++) {
block = bitmap1[i] & bitmap2[i];
...
}
// type 2 (code 01)
for (size_t i = 0; i < bitmap1.size(); i++) {
block = ~bitmap1[i] & bitmap2[i];
...
}
...
和x & (x - 1)
内容消失,代价是另一个间接)。
上面的代码支持固定数量的编译时已知类型(即ctz
和B
)。如果引入了新类型,上面的代码将会中断,并且肯定无法在这些新类型上调用C
。
但是,添加对未知类型的支持很简单。只需将所有未知类型分组,然后仅针对这些类型,在循环内执行完整的虚拟调度(即直接在Update()
上调用Update()
)。您将支付全价,但仅适用于您未明确支持的类型!通过这种方式,该技术可以实现虚拟调度机制的通用性。
1 实际上,每组类型只需要一个循环,所有共享虚拟方法的相同实现,尽管这可能很难在通用中实现方式,因为这些信息不容易获得。例如,如果类A*
和Y
都来自Z
,但都不会覆盖X
中某些虚拟方法的实现,那么所有X
,{ {1}}和X
可以由同一个循环处理。
2 通过&#34;对象布局&#34;我的意思是Y
对象仍然有虚拟方法,因此有一个vtable。如果删除所有虚拟方法并删除vtable,事情就会快得多,因为编译器会将添加内容向量化为紧凑排列的字段。 vtable搞砸了。