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

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)

笔记:

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

  2. 编译器决定使用对象的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)


Aji*_*ngh 6

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

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的编译器.


Tho*_*ews 5

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

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

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

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


use*_*961 1

找出编译器将 vtable 放在哪里并直接访问它以获取函数指针并存储它们以供使用。这样,您将获得与 C 语言中使用函数指针数组几乎相同的方法。

  • 亲爱的上帝,请不要让这样的事情发生在你的绿色地球上。谢谢。 (12认同)
  • “在这个垂直杆上平衡汽车是可以的,只要你不搞砸” (7认同)
  • 1. 每天不要更改编译器 15 次。2. 即使这样做,您也可以看到位置是否已更改。3. 如果您刚刚升级了当前的编译器,vtbl 很可能会位于同一位置。例如,MS几十年来都没有改变vtbl位置。如果您要编写一个编译器,您会按照您的建议在每个构建中实现 vtbl 移动吗?我不这么认为。因此,让我们从所有理论可能性转向实践。 (3认同)
  • @user1764961:仅仅因为您的可执行文件没有改变并不意味着代码路径是相同的。库和 DLL 始终更新。另外,如果编译器发生变化,或者您更改了编译器选项,或者它是在一天中的不同时间编译的,则 vtable 可能会有所不同,从而使您的所有辛苦工作崩溃。 (2认同)