Я оцениваю переписывание части программного обеспечения реального времени с языка ассемблера C / на язык C ++ / ассемблер (по причинам, не относящимся к вопросу, части кода абсолютно необходимы для сборки).
Прерывание происходит с частотой 3 кГц, и для каждого прерывания должно быть выполнено около 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'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% загрузки процессора. Оптимизация ассемблера может быть доведите это до четырех инструкций, однако я боюсь, что мы можем получить дополнительную задержку из-за доступа к памяти близко друг к другу и т. д. Короче говоря, я считаю, что эти пять циклов довольно оптимальны.
Теперь на 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 поисков в секунду; это будет составлять более 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 и отправкой vtable C ++ — это одна инструкция, связанная с тем фактом, что 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
получает адрес виртуальной таблицы, а call
затем индексирует через это. Так что это одна инструкция накладных расходов.
Согласно OP, компилятор C ++ 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);
Заметки:
В версии C ++ кажется, что компилятор решил выполнить постинкремент в два этапа, предположительно потому, что он хочет получить результат в i
зарегистрироваться, а не в r4
, Это, несомненно, связано с проблемой ниже.
Компилятор решил вычислить базовый адрес реального класса объекта, используя vtable объекта. Это занимает три инструкции, и, вероятно, также требует использования i4
как временное на шаге 1. Сам поиск vtable занимает одну инструкцию.
Итак: проблема не в vtable lookup, который мог бы быть выполнен в одной дополнительной инструкции (но на самом деле требуется две). Проблема в том, что компилятор чувствует необходимость «найти» объект. Но почему gcc / i86 не должен этого делать?
Ответ: раньше, но больше нет. Во многих случаях (например, при отсутствии множественного наследования) приведение указателя к производному классу к указателю базового класса не требует изменения указателя. Следовательно, когда мы вызываем метод производного класса, мы можем просто дать ему указатель базового класса в качестве его this
параметр. Но в других случаях это не работает, и мы должны настроить указатель, когда мы выполняем приведение, и, следовательно, отрегулировать его обратно, когда мы сделаем вызов.
Есть (как минимум) два способа выполнить вторую настройку. Одним из них является способ, показанный сгенерированным кодом DSP, где корректировка сохраняется в виртуальной таблице — даже если она равна 0 — и затем применяется во время вызова. Другой способ, (называется vtable-thunks
) это создать thunk
— немного исполняемого кода — который регулирует this
Указатель, а затем переходит к точке входа метода и помещает указатель на этот блок в виртуальную таблицу. (Все это можно сделать во время компиляции.) Преимущество решения Thunk состоит в том, что в общем случае, когда не требуется выполнять настройку, мы можем оптимизировать Thunk, и код настройки не остался. (Недостатком является то, что если нам нужна корректировка, мы создали дополнительную ветку.)
Насколько я понимаю, VisualDSP ++ основан на gcc и может иметь -fvtable-thunks
а также -fno-vtable-thunks
опции. Таким образом, вы можете скомпилировать с -fvtable-thunks
, Но если вы сделаете это, вам нужно будет скомпилировать все библиотеки C ++, которые вы используете с этой опцией, потому что вы не можете смешивать два стиля вызова. Кроме того (15 лет назад) были различные ошибки в реализации vcc-thunks для gcc, поэтому, если версия 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
Я предлагаю использовать статические методы в ваших производных классах и помещать эти функции в ваш массив. Это позволило бы избежать накладных расходов при поиске по v-таблице. Это наиболее близко к вашей реализации языка Си.
Вы можете пожертвовать полиморфизмом ради скорости.
Нужно ли наследование?
То, что вы переключаетесь на C ++, не означает, что вы должны переключаться на объектно-ориентированное.
Кроме того, вы пытались развернуть свой цикл в ISR?
Например, выполните 2 или более вызовов выполнения перед возвратом к началу цикла.
Кроме того, вы можете переместить любую функциональность из ISR?
Может ли какая-либо часть функциональности выполняться «фоновым циклом» вместо ISR? Это уменьшит время вашего ISR.
Как уже упоминалось, вы можете использовать шаблоны, чтобы покончить с динамической отправкой. Вот пример, который делает это:
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, предоставляя больше возможностей для оптимизации компилятора, так как код внутри выполнения может быть встроен.
Однако обратите внимание, что вам нужно будет изменить некоторые части в зависимости от того, как вы инициализируете ваши обработчики. Базовая структура должна оставаться прежней.
Кроме того, это требует, чтобы у вас был C ++ 11 совместимый компилятор.
Вы можете скрыть void*
стирание типов и восстановление типов внутри шаблонов. Результатом (надеюсь) будет тот же массив для указателей на функции. Это поможет вам с приведением и совместимость с вашим кодом:
#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;
}
В этом случае вы можете создавать новые функции как объект функции с более типом safty. «Нормальный» подход ООП с виртуальными функциями здесь не помогает.
В случае среды C ++ 11 вы можете создать массив с помощью шаблонов переменных во время компиляции (но со сложным синтаксисом).
Это не имеет отношения к вашему вопросу, но если вы заинтересованы в производительности, вы можете использовать шаблоны, чтобы развернуть цикл для списка задач:
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();
Узнайте, куда ваш компилятор помещает виртуальную таблицу, и получите к ней прямой доступ, чтобы получить указатели на функции и сохранить их для использования. Таким образом, у вас будет почти такой же подход, как в C с массивом указателей на функции.