c++ - C++中vtable查找的性能影响

标签 c++ c real-time vtable virtual-functions

我正在评估将一部分实时软件从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次迭代每秒完成3,000次,因此实际上我们每秒进行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.
}

我的问题是vtable查找。我无法每秒进行600,000次查询;这将占浪费的CPU负载的4%以上。而且,待办事项列表在运行时不会改变,它在启动时只设置了一次,因此真正浪费了查找要调用的函数的工作。问自己一个问题“最可能的最佳结果是什么”后,我看一下C解决方案给出的汇编代码,并重新编译一个函数指针数组。

用C++做到这一点的干净而正确的方法是什么?当最终出于性能原因再次选择函数指针时,创建一个不错的基类,派生类等感觉毫无意义。

更新(包括更正循环的起点):

处理器是ADSP-214xx,编译器是VisualDSP++ 5.0。启用#pragma optimize_for_speed时,C循环为9个周期。在我看来,对程序集进行优化可以产生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。因此,人们再次找到了带有函数指针数组和数据指针数组的自己。

自然,限制是可以在组装中完成的工作,现在已经进行了探索。考虑到这一点,我现在理解,即使引入基类是很自然的,但这并不是真正合适的方案。所以我想答案是,如果想要一个函数指针数组,就应该使自己成为一组函数指针...

最佳答案

是什么让您认为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);

注意:
  • 在C++版本中,似乎编译器已决定分两个步骤进行后递增,这大概是因为它希望将结果存储在i寄存器中,而不是在r4中。无疑这与下面的问题有关。
  • 编译器已决定使用对象的vtable计算对象的实类的基地址。这占用了3条指令,并且大概还需要在步骤1中使用i4作为临时指令。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;
    }
    

    并使用-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
    

    关于c++ - C++中vtable查找的性能影响,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/18404988/

    相关文章:

    c - 为什么 fwrite 向后打印十六进制值?

    erlang - 为什么 Erlang 适合软实时应用,而不适合硬实时?

    c++ - 声明一个数组而不分配内存

    c++ - 当我运行此代码时,Dev C++ 不断崩溃。我不明白为什么

    c++ - 在 C++ 中快速将原始数据转换为十六进制字符串

    python - 简化在Pybind11中为C++模板类生成包装器类的步骤:模板声明不能出现在 block 作用域中

    c - C 中的可变参数函数 f() 与 f(...)

    java - 如何使用 C 读取从 JAXB 生成的 XML

    matlab - 使用 MATLAB 在每个实例之后实时处理文件,文件由单独的程序创建

    c++ - 用于实时图形编程的 C++ 的最佳替代方案是什么?