在C ++中vtable查找的性能损失

我正在评估将一个实时软件从C /汇编语言重写为C ++ /汇编语言(由于与代码问题无关的原因,在汇编时绝对需要这样做)。

中断带有3 kHz频率,对于每个中断,大约200个不同的事情将按顺序完成。 处理器以300 MHz运行,为我们提供100,000个周期来完成这项工作。 这已在C中用函数指针数组求解:

// Each function does a different thing, all take one parameter being a pointer // to a struct, each struct also being different. void (*todolist[200])(void *parameters); // Array of pointers to structs containing each function's parameters. void *paramlist[200]; void realtime(void) { int i; for (i = 0; i < 200; i++) (*todolist[i])(paramlist[i]); } 

速度很重要。 上述200次迭代每秒完成3000次,因此实际上我们每秒进行600,000次迭代。 上面的for循环每次迭代编译为五个周期,总成本为每秒3,000,000个周期,即1%的CPU负载。 汇编程序优化可能会将其降低到四个指令,但是我担心由于内存访问彼此接近等原因,我们可能会得到一些额外的延迟。简而言之,我认为这五个周期非常理想。

现在进行C ++重写。 我们做的那200件事情彼此有关。 有一个参数的子集,它们都需要和使用,并具有各自的结构。 在C ++实现中,它们可以被巧妙地视为从公共基类inheritance:

 class Base { virtual void Execute(); int something_all_things_need; } class Derived1 : Base { void Execute() { /* Do something */ } int own_parameter; // Other own parameters } class Derived2 : Base { /* Etc. */ } Base *todolist[200]; void realtime(void) { for (int i = 0; i Execute(); // vtable look-up! 20+ cycles. } 

我的问题是vtable查找。 我不能每秒进行600,000次查找; 这将占浪费的CPU负载的4%以上。 此外,todolist在运行期间永远不会改变,它只在启动时设置一次,因此查找调用哪个函数的努力确实被浪费了。 在问自己“可能的最佳最终结果是什么”这个问题时,我看一下C解决方案给出的汇编代码,并重新定义一个函数指针数组……

在C ++中执行此操作的干净且正确的方法是什么? 制作一个不错的基类,派生类等等,最终会因性能原因再次选择函数指针。

更新(包括更正循环开始的位置):

处理器是ADSP-214xx,编译器是VisualDSP ++ 5.0。 启用#pragma optimize_for_speed ,C循环为9个循环。 assembly – 在我的脑海中优化它产生4个周期,但是我没有测试它所以它不能保证。 C ++循环是14个循环。 我知道编译器可以做得更好,但是我不想将其视为一个编译器问题 – 在嵌入式上下文中,没有多态的情况下仍然可行,并且设计选择仍然让我感兴趣。 作为参考,这里得到的组件:

C:

 i3=0xb27ba; i5=0xb28e6; r15=0xc8; 

这是实际的循环:

 r4=dm(i5,m6); i12=dm(i3,m6); r2=i6; i6=i7; jump (m13,i12) (db); dm(i7,m7)=r2; dm(i7,m7)=0x1279de; r15=r15-1; if ne jump (pc, 0xfffffff2); 

C ++:

 i5=0xb279a; r15=0xc8; 

这是实际的循环:

 i5=modify(i5,m6); i4=dm(m7,i5); r2=i4; i4=dm(m6,i4); r1=dm(0x3,i4); r4=r2+r1; i12=dm(0x5,i4); r2=i6; i6=i7; jump (m13,i12) (db); dm(i7,m7)=r2; dm(i7,m7)=0x1279e2; r15=r15-1; if ne jump (pc, 0xffffffe7); 

与此同时,我想我找到了一个答案。 通过尽可能少的方式实现最低的循环量。 我必须获取数据指针,获取函数指针,并以数据指针作为参数调用该函数。 当获取指针时,索引寄存器会被一个常量自动修改,也可以让这个常量等于1.所以再一次找到一个函数指针数组和一个数据指针数组。

当然,限制是可以在assembly中完成的,现在已经进行了探索。 考虑到这一点,我现在明白,尽管引入一个基类是很自然的,但它并不适合这个法案。 所以我想答案是,如果想要一个函数指针数组,就应该让自己成为一个函数指针数组……

是什么让你觉得vtable查找开销是20个周期? 如果这是真的,那么你需要一个更好的C ++编译器。

我在Intel盒子上试过这个,对你正在使用的处理器一无所知,正如预期的那样,C发送代码和C ++ vtable发送之间的区别是一条指令,与vtable涉及额外的事实有关。间接。

C代码(基于OP):

 void (*todolist[200])(void *parameters); void *paramlist[200]; void realtime(void) { int i; for (i = 0; i < 200; i++) (*todolist[i])(paramlist[i]); } 

C ++代码:

 class Base { public: Base(void* unsafe_pointer) : unsafe_pointer_(unsafe_pointer) {} virtual void operator()() = 0; protected: void* unsafe_pointer_; }; Base* todolist[200]; void realtime() { for (int i = 0; i < 200; ++i) (*todolist[i])(); } 

两者都用gcc 4.8编译,-O3:

 realtime: |_Z8realtimev: .LFB0: |.LFB3: .cfi_startproc | .cfi_startproc pushq %rbx | pushq %rbx .cfi_def_cfa_offset 16 | .cfi_def_cfa_offset 16 .cfi_offset 3, -16 | .cfi_offset 3, -16 xorl %ebx, %ebx | movl $todolist, %ebx .p2align 4,,10 | .p2align 4,,10 .p2align 3 | .p2align 3 .L3: |.L3: movq paramlist(%rbx), %rdi | movq (%rbx), %rdi call *todolist(%rbx) | addq $8, %rbx addq $8, %rbx | movq (%rdi), %rax | call *(%rax) cmpq $1600, %rbx | cmpq $todolist+1600, %rbx jne .L3 | jne .L3 popq %rbx | popq %rbx .cfi_def_cfa_offset 8 | .cfi_def_cfa_offset 8 ret | ret 

在C ++代码中,第一个movq获取vtable的地址,然后call索引。 这是一个指令开销。

根据OP,DSP的C ++编译器生成以下代码。 我根据我对正在发生的事情的理解插入了评论(这可能是错误的)。 注意,(IMO)循环比OP指示早一个位置开始; 否则,对我来说毫无意义。

 # Initialization. # i3=todolist; i5=paramlist | # i5=todolist holds paramlist i3=0xb27ba; | # No paramlist in C++ i5=0xb28e6; | i5=0xb279a; # r15=count r15=0xc8; | r15=0xc8; # Loop. We need to set up r4 (first parameter) and figure out the branch address. # In C++ by convention, the first parameter is 'this' # Note 1: r4=dm(i5,m6); # r4 = *paramlist++; | i5=modify(i5,m6); # i4 = *todolist++ | i4=dm(m7,i5); # .. # Note 2: | r2=i4; # r2 = obj | i4=dm(m6,i4); # vtable = *(obj + 1) | r1=dm(0x3,i4); # r1 = vtable[3] | r4=r2+r1; # param = obj + r1 i12=dm(i3,m6); # i12 = *todolist++; | i12=dm(0x5,i4); # i12 = vtable[5] # Boilerplate call. Set frame pointer, push return address and old frame pointer. # The two (push) instructions after jump are actually executed before the jump. r2=i6; | r2=i6; i6=i7; | i6=i7; jump (m13,i12) (db); | jump (m13,i12) (db); dm(i7,m7)=r2; | dm(i7,m7)=r2; dm(i7,m7)=0x1279de; | dm(i7,m7)=0x1279e2; # if (count--) loop r15=r15-1; | r15=r15-1; if ne jump (pc, 0xfffffff2); | if ne jump (pc, 0xffffffe7); 

笔记:

  1. 在C ++版本中,似乎编译器决定分两步进行后增量,大概是因为它希望结果在i寄存器而不是r4 。 这无疑与下面的问题有关。

  2. 编译器决定使用对象的vtable计算对象真实类的基址。 这占用了三条指令,并且可能还需要在步骤1中使用i4作为临时指令.vtable查找本身占用一条指令。

所以:问题不是vtable查找,这可能是在一个额外的指令中完成的(但实际上需要两个)。 问题是编译器感觉需要“找到”对象。 但为什么gcc / i86不需要这样做呢?

答案是:它曾经,但它已经不复存在了。 在许多情况下(例如,没有多重inheritance),将指向派生类的指针强制转换为基类的指针不需要修改指针。 因此,当我们调用派生类的方法时,我们可以给它基类指针作为它的this参数。 但在其他情况下,这不起作用,我们必须在进行演员时调整指针,并在我们进行调用时调整它。

有(至少)两种方式来执行第二次调整。 一种是生成的DSP代码所示的方式,其中调整存储在vtable中 - 即使它是0 - 然后在调用期间应用。 另一种方式,(称为vtable-thunks )是创建一个thunk - 一点点可执行代码 - 调整this指针然后跳转到方法的入口点,并将指向此thunk的指针放入vtable。 (这一切都可以在编译时完成。)thunk解决方案的优点在于,在不需要进行任何调整的常见情况下,我们可以优化掉thunk并且没有调整代码。 (缺点是如果我们确实需要调整,我们已经生成了一个额外的分支。)

据我所知,VisualDSP ++基于gcc,它可能有-fvtable-thunks-fno-vtable-thunks选项。 所以你可以使用-fvtable-thunks进行编译。 但是如果你这样做,你需要编译你使用该选项的所有C ++库,因为你不能混合这两种调用样式。 此外,还有(15年前)gcc的vtable-thunks实现中的各种错误,所以如果VisualDSP ++使用的gcc版本足够老,你也可能遇到这些问题(IIRC,它们都涉及多重inheritance,所以它们可能会不适用于您的使用案例。)


(原始测试,更新前):

我尝试了以下简单的情况(没有多重inheritance,这会减慢速度):

 class Base { public: Base(int val) : val_(val) {} virtual int binary(int a, int b) = 0; virtual int unary(int a) = 0; virtual int nullary() = 0; protected: int val_; }; int binary(Base* begin, Base* end, int a, int b) { int accum = 0; for (; begin != end; ++begin) { accum += begin->binary(a, b); } return accum; } int unary(Base* begin, Base* end, int a) { int accum = 0; for (; begin != end; ++begin) { accum += begin->unary(a); } return accum; } int nullary(Base* begin, Base* end) { int accum = 0; for (; begin != end; ++begin) { accum += begin->nullary(); } return accum; } 

并使用-O3使用gcc(4.8)编译它。 正如我所料,它产生的完全相同的汇编代码就像你的C代表所做的那样。 这是unary函数情况下的for循环,例如:

 .L9: movq (%rbx), %rax movq %rbx, %rdi addq $16, %rbx movl %r13d, %esi call *8(%rax) addl %eax, %ebp cmpq %rbx, %r12 jne .L9 

我建议在派生类中使用静态方法并将这些函数放入数组中。 这将消除v表搜索的开销。 这最接近您的C语言实现。

你最终可能会因为速度而牺牲多态性。
遗产是必要的吗?
仅仅因为你切换到C ++并不意味着你必须切换到面向对象。

另外,您是否尝试在ISR中展开循环?
例如,在返回循环顶部之前执行2个或更多执行调用。

此外,您可以将任何function移出ISR吗? function的任何部分都可以由“后台循环”而不是ISR执行吗? 这样可以减少ISR的时间。

如前所述,您可以使用模板取消动态分派。 这是一个执行此操作的示例:

 template  struct InterruptHandler { void execute() { // I construct temporary objects here since I could not figure out how you // construct your objects. You can change these signatures to allow for // passing arbitrary params to these handlers. FirstCb().execute(); InterruptHandler().execute(); } } InterruptHandler handler; void realtime(void) { handler.execute(); } 

这应该完全消除vtable查找,同时为编译器优化提供更多机会,因为可以内联执行内部的代码。

但请注意,您需要根据初始化处理程序的方式更改某些部分。 基本框架应保持不变。 此外,这要求您具有符合C ++ 11的编译器。

您可以隐藏void*类型擦除并在模板中键入恢复。 结果(希望)与函数指针的数组相同。 这将有助于您的代码转换和兼容:

 #include  template void fun(void* param) { F f; f(*static_cast(param)); } struct my_function { void operator()(int& i) { std::cout << "got it " << i << std::endl; } }; int main() { void (*func)(void*) = fun; int j=4; func(&j); return 0; } 

在这种情况下,您可以创建新函数作为具有更多类型安全性的函数对象。 具有虚函数的“正常”OOP方法对此没有帮助。

对于A C ++ 11环境,您可以在编译时使用可变参数模板创建数组(但语法复杂)。

这与你的问题无关,但如果你热衷于表现,你可以使用模板为todolist做一个循环展开:

 void (*todo[3])(void *); void *param[3]; void f1(void*) {std::cout<<"1" << std::endl;} void f2(void*) {std::cout<<"2" << std::endl;} void f3(void*) {std::cout<<"3" << std::endl;} template struct Obj { static void apply() { todo[N-1](param[N-1]); Obj::apply(); } }; template<> struct Obj<0> { static void apply() {} }; todo[0] = f1; todo[1] = f2; todo[2] = f3; Obj::apply(); 

找出编译器放置vtable的位置并直接访问它以获取函数指针并存储它们以供使用。 这样你将拥有几乎相同的方法,就像在C中使用函数指针数组一样。