Подтвердить что ты не робот

Удар производительности vtable в С++

Я оцениваю переписывание части программного обеспечения реального времени с языка C/ассемблера на язык С++/ассемблера (по причинам, не относящимся к частям вопроса, абсолютно необходимо делать в сборке).

Прерывание происходит с частотой 3 & times; kHz, и для каждого прерывания около 200 различных вещей должны выполняться в последовательности. Процессор работает с частотой 300 МГц, что дает нам 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 parameters.
void *paramlist[200];

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

Скорость важна. Вышеуказанные 200 итераций выполняются 3000 раз в секунду, поэтому практически 600 000 итераций в секунду. Вышеописанное для цикла составляет пять циклов на итерацию, что дает общую стоимость 3 000 000 циклов в секунду, то есть 1% загрузки процессора. Оптимизация ассемблера может привести к четырем инструкциям, однако я опасаюсь, что мы сможем получить дополнительную задержку из-за доступа к памяти близко друг к другу и т.д. Короче говоря, я считаю, что эти пять циклов довольно оптимальны.

Теперь переписываем С++. Эти 200 вещей, которые мы делаем, связаны друг с другом. Существует подмножество параметров, которые все они нуждаются и используют, и имеют в своих соответствующих структурах. В реализации С++ их можно было бы таким образом наследовать от общего базового класса:

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 запросов в секунду; на это будет приходиться более 4% загруженной загрузки ЦП. Кроме того, todolist никогда не меняется во время выполнения, он устанавливается только один раз при запуске, поэтому усилия по поиску функции, которую можно вызвать, действительно теряются. Задав себе вопрос "какой оптимальный конечный результат возможен", я смотрю на код ассемблера, заданный решением C, и переустанавливаю массив указателей на функции...

Каков чистый и правильный способ сделать это на С++? Создание хорошего базового класса, производных классов и т.д. Кажется совершенно бессмысленным, когда, в конце концов, снова выбираются указатели на параметры производительности.

Обновление (включая исправление, где начинается цикл):

Процессор - это ADSP-214xx, а компилятор - VisualDSP ++ 5.0. При включении #pragma optimize_for_speed цикл цикла составляет 9 циклов. Сборка, оптимизирующая его в моем сознании, дает 4 цикла, однако я не тестировал его, чтобы он не гарантировался. Цикл С++ - 14 циклов. Я знаю, что компилятор мог бы сделать лучшую работу, однако я не хотел отклонять это как проблему с компилятором, потому что без полиморфизма все еще предпочтительнее во встроенном контексте, и выбор дизайна по-прежнему меня интересует. Для справки, здесь результирующая сборка:

С

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);

С++:

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. Таким образом, вы снова обнаруживаете себя с массивом указателей на функции и массив указателей данных.

Естественно, предел - это то, что можно сделать в сборке, и это теперь изучено. Имея это в виду, я теперь понимаю, что, хотя естественно, что для того, чтобы ввести базовый класс, это было не совсем то, что соответствовало законопроекту. Поэтому я предполагаю, что ответ заключается в том, что если требуется массив указателей на функции, нужно сделать себе массив указателей на функции...

4b9b3361

Ответ 1

Что заставляет вас думать, что накладные расходы на vtable составляют 20 циклов? Если это действительно так, вам нужен лучший компилятор С++.

Я пробовал это на ящике Intel, ничего не зная о процессоре, который вы используете, и, как и ожидалось, разница между кодом отправки и отправкой С++ 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]);                                         
}       

Код С++:

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

В коде С++ первый movq получает адрес vtable, а затем call индексирует это. Так что одна служебная нагрузка.

Согласно OP, компилятор DSP С++ создает следующий код. Я вставил комментарии, основываясь на моем понимании того, что происходит (что может быть неправильно). Обратите внимание, что (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);

Примечания:

  • В версии С++ кажется, что компилятор решил выполнить пост-инкремент в два шага, по-видимому, потому, что он хочет получить результат в регистре i, а не в r4. Это, несомненно, связано с проблемой ниже.

  • Компилятор решил вычислить базовый адрес реального класса объекта, используя объект vtable. Это занимает три инструкции и, по-видимому, также требует использования i4 как временного шага 1. Сам поиск vtable занимает одну инструкцию.

Итак: проблема не в vtable lookup, что могло быть сделано в одной дополнительной инструкции (но на самом деле требуется два). Проблема в том, что компилятор чувствует необходимость "находить" объект. Но почему gcc/i86 не нужно делать?

Ответ: он привык, но это уже не так. Во многих случаях (например, если нет множественного наследования), приведение указателя на производный класс к указателю базового класса не требует модификации указателя. Следовательно, когда мы вызываем метод производного класса, мы можем просто дать ему указатель базового класса как его параметр this. Но в других случаях это не работает, и мы должны отрегулировать указатель, когда делаем актерский состав, и, следовательно, отрегулируем его, когда мы выполняем вызов.

Есть (по крайней мере) два способа выполнить вторую настройку. Один из них показан способом сгенерированного кода DSP, где настройка сохраняется в таблице vtable, даже если она равна 0, а затем применяется во время вызова. Другим способом (называемым vtable-thunks) является создание thunk - немного исполняемого кода, который корректирует указатель this, а затем переходит к точке ввода метода и помещает указатель на этот кусок в таблицу vtable. (Это может быть сделано во время компиляции.) Преимущество решения thunk заключается в том, что в обычном случае, когда корректировка не требуется, мы можем оптимизировать пробой и нет кода регулировки слева. (Недостатком является то, что если нам нужна настройка, мы создали дополнительную ветвь.)

Как я понимаю, VisualDSP ++ основан на gcc, и он может иметь опции -fvtable-thunks и -fno-vtable-thunks. Таким образом, вы можете скомпилировать с помощью -fvtable-thunks. Но если вы это сделаете, вам нужно будет скомпилировать все библиотеки С++, которые вы используете с этим параметром, потому что вы не можете смешивать два стиля вызова. Кроме того, были (15 лет назад) различные ошибки в реализации gcc vtable-thunks, поэтому, если версия gcc, используемая VisualDSP ++, достаточно старая, вы можете столкнуться с этими проблемами (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;
}

И скомпилировал его с помощью gcc (4.8), используя -O3. Как я и ожидал, он подготовил точно такой же код сборки, что и ваша 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

Ответ 2

Я предлагаю использовать статические методы в ваших производных классах и помещать эти функции в ваш массив. Это позволит устранить накладные расходы на поиск в v-таблице. Это ближе всего к вашей реализации на языке C.

Вы можете в конечном итоге жертвовать полиморфизмом за скорость.
Нужно ли наследование?
Просто потому, что вы переключаетесь на С++, это не значит, что вам нужно переключиться на Object Oriented.

Кроме того, вы пытались развернуть свой цикл в ISR?
Например, выполните два или более вызовов выполнения перед возвратом в начало цикла.

Кроме того, можете ли вы перенести любую из функций из ISR? Может ли любая часть функциональности выполняться "фоновой петлей" вместо ISR? Это сократит время вашего ISR.

Ответ 3

Как уже упоминалось, вы можете использовать шаблоны, чтобы избавиться от динамической отправки. Вот пример, который делает это:

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();
}

Это должно полностью исключить поиск vtable, предоставляя больше возможностей для оптимизации компилятора, поскольку код внутри выполнения может быть встроен.

Обратите внимание, что вам нужно будет изменить некоторые части в зависимости от того, как вы инициализируете свои обработчики. Базовая структура должна оставаться неизменной. Кроме того, для этого требуется, чтобы у вас был компилятор С++ 11.

Ответ 4

Вы можете скрыть стирание типа t20 и тип восстановления внутри шаблонов. Результатом будет (надеюсь) быть тот же массив, что и указатели функций. Это поможет вам с литьем и совместим с вашим кодом:

#include <iostream>

template<class ParamType,class F>
void fun(void* param) {
  F f;
  f(*static_cast<ParamType*>(param));
}

struct my_function {
  void operator()(int& i) {
    std::cout << "got it " << i << std::endl;
  }
};


int main() {
  void (*func)(void*) = fun<int, my_function>;

  int j=4;
  func(&j);

  return 0;
}

В этом случае вы можете создавать новые функции как объект функции с большей безопасностью типа. "Обычный" OOP aproach с виртуальными функциями здесь не помогает.

В случае среды A С++ 11 вы можете создать массив с помощью вариативных шаблонов во время компиляции (но со сложным синтаксисом).

Ответ 5

Это не связано с вашим вопросом, но если вы заинтересованы в производительности, вы можете использовать шаблоны для создания цикла для 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<int N>
struct Obj {
    static void apply()
    {
        todo[N-1](param[N-1]);
        Obj<N-1>::apply();
    }
};

template<> struct Obj<0> { static void apply() {} };

todo[0] = f1;
todo[1] = f2;
todo[2] = f3;

Obj<sizeof todo / sizeof *todo>::apply();

Ответ 6

Узнайте, где ваш компилятор помещает vtable и получает доступ к нему напрямую, чтобы получить указатели на функции и сохранить их для использования. Таким образом, у вас будет почти такой же подход, как и в C, с массивом указателей на функции.