use*_*077 21 c c++ virtual-functions real-time 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]);
}
Run Code Online (Sandbox Code Playgroud)
速度很重要.上述200次迭代每秒完成3000次,因此实际上我们每秒进行600,000次迭代.上面的for循环每次迭代编译为五个周期,总成本为每秒3,000,000个周期,即1%的CPU负载.汇编程序优化可能会将其降低到四个指令,但是我担心由于内存访问彼此接近等原因,我们可能会得到一些额外的延迟.简而言之,我相信这五个周期非常理想.
现在进行C++重写.我们做的那200件事情彼此有关.有一个参数的子集,它们都需要和使用,并具有各自的结构.在C++实现中,它们可以被巧妙地视为从公共基类继承:
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 < 200; i++)
todolist[i]->Execute(); // vtable look-up! 20+ cycles.
}
Run Code Online (Sandbox Code Playgroud)
我的问题是vtable查找.我不能每秒做600,000次查询; 这将占浪费的CPU负载的4%以上.此外,todolist在运行期间永远不会改变,它只在启动时设置一次,因此查找调用哪些函数的努力确实被浪费了.在问自己"可能的最佳最终结果是什么"这个问题时,我看一下C解决方案给出的汇编代码,并重新引用一个函数指针数组......
在C++中执行此操作的干净且正确的方法是什么?制作一个漂亮的基类,派生类等等,最后因性能原因再次选择函数指针时感觉毫无意义.
更新(包括更正循环开始的位置):
处理器是ADSP-214xx,编译器是VisualDSP ++ 5.0.启用时#pragma optimize_for_speed,C循环为9个循环.装配 - 在我的脑海中优化它产生4个周期,但是我没有测试它所以它不能保证.C++循环是14个循环.我知道编译器可以做得更好,但是我不想将其视为一个编译器问题 - 在嵌入式上下文中,没有多态的情况下仍然是可取的,设计选择仍然让我感兴趣.作为参考,这里得到的组件:
C:
i3=0xb27ba;
i5=0xb28e6;
r15=0xc8;
Run Code Online (Sandbox Code Playgroud)
这是实际的循环:
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);
Run Code Online (Sandbox Code Playgroud)
C++:
i5=0xb279a;
r15=0xc8;
Run Code Online (Sandbox Code Playgroud)
这是实际的循环:
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);
Run Code Online (Sandbox Code Playgroud)
与此同时,我想我找到了一个答案.通过尽可能少的方式实现最低的循环量.我必须获取数据指针,获取函数指针,并以数据指针作为参数调用该函数.当获取指针时,索引寄存器会被一个常量自动修改,也可以让这个常量等于1.所以再一次找到一个函数指针数组和一个数据指针数组.
当然,限制是可以在装配中完成的,现在已经进行了探索.考虑到这一点,我现在明白,尽管引入一个基类是很自然的,但它并不适合这个法案.所以我想答案是,如果想要一个函数指针数组,那么应该让自己成为一个函数指针数组......
ric*_*ici 23
是什么让你觉得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]);
}
Run Code Online (Sandbox Code Playgroud)
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])();
}
Run Code Online (Sandbox Code Playgroud)
两者都用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
Run Code Online (Sandbox Code Playgroud)
在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);
Run Code Online (Sandbox Code Playgroud)
笔记:
在C++版本中,似乎编译器决定分两步执行后增量,大概是因为它希望将结果放在i寄存器而不是寄存器中r4.这无疑与下面的问题有关.
编译器决定使用对象的vtable计算对象真实类的基址.这占用了三条指令,并且可能还需要i4在步骤1中使用临时指令.vtable查找本身占用一条指令.
所以:问题不是vtable查找,这可能是在一个额外的指令中完成的(但实际上需要两个).问题是编译器感觉需要"找到"对象.但为什么gcc/i86不需要这样做呢?
答案是:它曾经,但它已经不复存在了.在许多情况下(例如,没有多重继承),将指向派生类的指针强制转换为基类的指针不需要修改指针.因此,当我们调用派生类的方法时,我们可以将基类指针作为其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,它们都涉及多重继承,所以它们可能会不适用于您的使用案例.)
(原始测试,更新前):
我尝试了以下简单的情况(没有多重继承,这会减慢速度):
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;
}
Run Code Online (Sandbox Code Playgroud)
并使用-O3使用gcc(4.8)编译它.正如我所预料的那样,它产生的完全相同的汇编代码就像你的C代表所做的那样.这是函数for情况下的循环unary,例如:
.L9:
movq (%rbx), %rax
movq %rbx, %rdi
addq $16, %rbx
movl %r13d, %esi
call *8(%rax)
addl %eax, %ebp
cmpq %rbx, %r12
jne .L9
Run Code Online (Sandbox Code Playgroud)
如前所述,您可以使用模板取消动态分派.这是一个执行此操作的示例:
template <typename FirstCb, typename ... RestCb>
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<RestCb...>().execute();
}
}
InterruptHandler</* Base, Derived1, and so on */> handler;
void realtime(void) {
handler.execute();
}
Run Code Online (Sandbox Code Playgroud)
这应该完全消除vtable查找,同时为编译器优化提供更多机会,因为可以内联执行内部的代码.
但请注意,您需要根据初始化处理程序的方式更改某些部分.基本框架应保持不变.此外,这要求您具有符合C++ 11的编译器.
我建议在派生类中使用静态方法并将这些函数放入数组中.这将消除v表搜索的开销.这最接近您的C语言实现.
你最终可能会因为速度而牺牲多态性.
遗产是必要的吗?
仅仅因为你切换到C++并不意味着你必须切换到面向对象.
另外,您是否尝试在ISR中展开循环?
例如,在返回循环顶部之前执行2次或更多次执行调用.
此外,您可以将任何功能移出ISR吗?功能的任何部分都可以由"后台循环"而不是ISR执行吗?这样可以减少ISR的时间.
找出编译器将 vtable 放在哪里并直接访问它以获取函数指针并存储它们以供使用。这样,您将获得与 C 语言中使用函数指针数组几乎相同的方法。