Я хотел бы воспользоваться этими и без того превосходными ответами и признать, что я выбрал уродливый подход, работающий в обратном направлении с анти-паттерном изменения полиморфного кода switches
или if/else
переходов с измеряемой прибылью. Но я не делал это оптом, только для самых критических путей. Это не должно быть так черно-белым.
Как заявление об отказе от ответственности, я работаю в таких областях, как трассировка лучей, где правильность не так сложна для достижения (и часто так или иначе нечетка и приближена), в то время как скорость часто является одним из наиболее конкурентных качеств, которые мы ищем. Сокращение времени рендеринга часто является одним из самых распространенных пользовательских запросов, поскольку мы постоянно ломаем голову и выясняем, как этого добиться для наиболее важных измеренных путей.
Полиморфная Рефакторинг Условных Условий
Во-первых, стоит понять, почему полиморфизм может быть предпочтительнее с точки switch
зрения if/else
удобства сопровождения, чем условное ветвление ( или куча утверждений). Основным преимуществом здесь является расширяемость .
С помощью полиморфного кода мы можем ввести новый подтип в нашу кодовую базу, добавить его экземпляры в некоторую полиморфную структуру данных и сделать так, чтобы весь существующий полиморфный код по-прежнему работал автоматически без каких-либо изменений. Если у вас есть куча кода, разбросанного по большой кодовой базе, которая напоминает форму «Если этот тип -« foo », сделайте это» , вы можете столкнуться с ужасным бременем обновления 50 разрозненных разделов кода, чтобы представить новый тип вещей, и все же в конечном итоге не хватает нескольких.
Преимущества удобства сопровождения полиморфизма здесь естественным образом уменьшаются, если у вас есть только пара или даже один раздел вашей кодовой базы, который должен выполнять такие проверки типов.
Барьер оптимизации
Я бы посоветовал не смотреть на это с точки зрения разветвления и конвейеризации, а взглянуть на это больше с точки зрения проектирования компиляторов барьеров оптимизации. Существуют способы улучшить прогнозирование ветвлений, применимые к обоим случаям, например, сортировка данных на основе подтипа (если он входит в последовательность).
Что отличается между этими двумя стратегиями, так это количество информации, которое оптимизатор имеет заранее. Известный вызов функции предоставляет намного больше информации, косвенный вызов функции, который вызывает неизвестную функцию во время компиляции, приводит к барьеру оптимизации.
Когда вызываемая функция известна, компиляторы могут стереть структуру и сжать ее до дребезга, вставляя вызовы, устраняя потенциальные накладные расходы на псевдонимы, выполняя лучшую работу при распределении команд / регистров, возможно даже переставляя циклы и другие формы ветвей, генерируя сложные миниатюрные LUT, когда это уместно (что-то, что GCC 5.3 недавно удивило меня switch
заявлением, используя жестко закодированные LUT данных для результатов, а не таблицу переходов).
Некоторые из этих преимуществ теряются, когда мы начинаем вводить в микс неизвестные во время компиляции, как в случае косвенного вызова функции, и именно здесь условное ветвление может, скорее всего, дать преимущество.
Оптимизация памяти
Возьмите пример видеоигры, которая состоит в повторной обработке последовательности существ в тесном цикле. В таком случае у нас может быть какой-нибудь полиморфный контейнер, подобный этому:
vector<Creature*> creatures;
Примечание: для простоты я избежал unique_ptr
здесь.
... где Creature
полиморфный базовый тип. В этом случае одна из трудностей с полиморфными контейнерами заключается в том, что они часто хотят выделить память для каждого подтипа отдельно / индивидуально (например: использование броска operator new
по умолчанию для каждого отдельного существа).
Это часто делает первую расстановку приоритетов для оптимизации (если она нам нужна) на основе памяти, а не ветвления. Одна стратегия здесь состоит в том, чтобы использовать фиксированный распределитель для каждого подтипа, поощряя непрерывное представление, выделяя большими порциями и объединяя память для каждого выделяемого подтипа. При такой стратегии это может определенно помочь отсортировать этот creatures
контейнер по подтипу (а также по адресу), поскольку это не только возможно улучшает прогнозирование ветвления, но также улучшает местность ссылок (позволяя получить доступ к нескольким существам одного и того же подтипа из одной строки кэша до выселения).
Частичная девиртуализация структур данных и циклов
Допустим, вы прошли через все эти движения и все еще желаете большей скорости. Стоит отметить, что каждый шаг, который мы предпринимаем здесь, ухудшает ремонтопригодность, и мы уже находимся на некоторой стадии измельчения металла с уменьшением производительности. Таким образом, если мы вступим на эту территорию, то должны быть довольно значительные требования к производительности, где мы готовы пожертвовать обслуживаемостью еще больше для все меньшего и меньшего прироста производительности.
Тем не менее, следующий шаг, который нужно попробовать (и всегда с готовностью поддержать наши изменения, если это не поможет вообще), может быть ручной девиртуализацией.
Совет по управлению версиями: если вы не намного лучше меня разбираетесь в оптимизации, может быть стоит создать новую ветку с готовностью отбросить ее, если наши усилия по оптимизации упустят то, что вполне может произойти. Для меня это все методом проб и ошибок после такого рода точек, даже с профилировщиком в руке.
Тем не менее, мы не должны применять это мышление оптом. Продолжая наш пример, допустим, что эта видеоигра в основном состоит из людей. В таком случае мы можем девиртуализировать только человеческие существа, подняв их и создав отдельную структуру данных только для них.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Это подразумевает, что все области в нашей кодовой базе, которые должны обрабатывать существ, нуждаются в отдельном цикле особого случая для человеческих существ. Тем не менее, это устраняет динамические накладные расходы (или, возможно, более уместно, барьер оптимизации) для людей, которые, безусловно, являются наиболее распространенным типом существ. Если этих областей много, и мы можем себе это позволить, мы можем сделать это:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... если мы можем себе это позволить, менее критические пути могут остаться такими, как есть, и просто абстрактно обрабатывать все типы существ. Критические пути могут обрабатываться humans
в одном цикле и other_creatures
во втором цикле.
Мы можем расширить эту стратегию по мере необходимости и потенциально выжать некоторые выгоды таким образом, однако стоит отметить, насколько мы ухудшаем ремонтопригодность в процессе. Использование здесь шаблонов функций может помочь сгенерировать код для людей и существ без дублирования логики вручную.
Частичная девиртуализация классов
То, что я делал много лет назад, и которое было действительно грубым, и я даже не уверен, что это больше полезно (это было в эпоху C ++ 03), было частичной девиртуализацией класса. В этом случае мы уже хранили идентификатор класса с каждым экземпляром для других целей (доступ к которому осуществлялся через базовый класс, который не был виртуальным). Там мы сделали что-то похожее на это (моя память немного туманна):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... где virtual_do_something
был реализован вызов не виртуальных версий в подклассе. Я знаю, что это грубо - делать явное статическое снижение, чтобы девиртуализировать вызов функции. Я понятия не имею, насколько это выгодно сейчас, так как я не пробовал подобные вещи годами. Благодаря использованию ориентированного на данные дизайна я обнаружил, что вышеупомянутая стратегия разделения структур данных и циклов в горячем / холодном режиме гораздо более полезна, открывая больше дверей для стратегий оптимизации (и гораздо менее уродливых).
Оптовая Девиртуализация
Я должен признать, что я никогда не заходил так далеко, применяя мышление оптимизации, поэтому я понятия не имею о преимуществах. Я избегал косвенных функций в предвидении в тех случаях, когда я знал, что будет только один центральный набор условий (например, обработка событий только с одним событием обработки в центральном месте), но никогда не начинал с полиморфного мышления и оптимизировал полностью до здесь.
Теоретически, непосредственными преимуществами здесь может быть потенциально меньший способ идентификации типа, чем виртуального указателя (например, один байт, если вы можете согласиться с идеей, что существует 256 уникальных типов или меньше) в дополнение к полному устранению этих барьеров оптимизации ,
В некоторых случаях может также помочь написать более простой в обслуживании код (по сравнению с приведенными выше примерами оптимизированной ручной девиртуализации), если вы просто используете один центральный switch
оператор, не разбивая структуры данных и циклы на основе подтипа, или если есть порядок -зависимость в тех случаях, когда вещи должны быть обработаны в точном порядке (даже если это заставляет нас разветвляться повсюду). Это может быть в тех случаях, когда у вас не так много мест, которые нужно сделать switch
.
Я бы вообще не рекомендовал это даже с очень критичным к производительности мышлением, если это не достаточно просто для поддержания. «Простота обслуживания» будет зависеть от двух доминирующих факторов:
- Отсутствие реальной потребности в расширяемости (например: точно знать, что вам нужно обрабатывать ровно 8 типов вещей, и никогда больше).
- В вашем коде не так много мест, где нужно проверять эти типы (например, одно центральное место).
... все же я рекомендую описанный выше сценарий в большинстве случаев и, при необходимости, перебираю более эффективные решения путем частичной девиртуализации. Это дает вам гораздо больше передышки, чтобы сбалансировать потребности в расширяемости и обслуживании с производительностью.
Виртуальные функции и указатели на функции
Чтобы завершить это, я заметил здесь, что было некоторое обсуждение виртуальных функций и указателей на функции. Это правда, что для вызова виртуальных функций требуется немного больше работы, но это не значит, что они работают медленнее. Против интуитивно, это может даже сделать их быстрее.
Здесь это противоречит интуиции, потому что мы привыкли измерять затраты с точки зрения инструкций, не обращая внимания на динамику иерархии памяти, которая, как правило, оказывает гораздо более существенное влияние.
Если мы сравниваем a class
с 20 виртуальными функциями против a, в struct
котором хранится 20 указателей на функции, и оба экземпляра создаются несколько раз, то объем памяти каждого class
экземпляра в этом случае составляет 8 байт для виртуального указателя на 64-разрядных машинах, тогда как объем памяти накладные расходы struct
составляют 160 байтов.
Практические затраты могут быть намного больше обязательных и необязательных пропусков кэша с таблицей указателей функций по сравнению с классом, использующим виртуальные функции (и, возможно, сбои страниц при достаточно большом масштабе ввода). Эта стоимость имеет тенденцию затмевать немного дополнительную работу по индексированию виртуальной таблицы.
Я также имел дело с унаследованными кодовыми базами C (более старыми, чем я), в которых превращение таких, structs
заполненных указателями функций, и создание их много раз фактически дало значительный прирост производительности (более 100% улучшений), превращая их в классы с виртуальными функциями, и из-за значительного сокращения использования памяти, увеличения кеш-памяти и т. д.
С другой стороны, когда сравнения между яблоками и яблоками становятся больше, я также обнаружил, что противоположный способ перевода мышления с виртуальной функции C ++ на мышление с указателем на функцию в стиле C полезен в следующих типах сценариев:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... где класс хранил одну жалкую переопределяемую функцию (или две, если считать виртуальный деструктор). В этих случаях это может определенно помочь в критических путях превратить это в это:
void (*func_ptr)(void* instance_data);
... идеально за типобезопасным интерфейсом, чтобы скрывать опасные броски в / из void*
.
В тех случаях, когда у нас возникает искушение использовать класс с одной виртуальной функцией, это может быстро помочь вместо использования указателей на функции. Большая причина даже не обязательно заключается в снижении затрат на вызов указателя функции. Это потому, что мы больше не сталкиваемся с искушением распределить каждый отдельный функционоид по разрозненным областям кучи, если мы объединяем их в постоянную структуру. Такой подход может упростить предотвращение связанных с кучей и фрагментацию памяти, если данные экземпляра однородны, например, и меняется только поведение.
Так что определенно есть случаи, когда использование указателей функций может помочь, но часто я нахожу это наоборот, если мы сравниваем несколько таблиц указателей функций с одной виртуальной таблицей, для которой требуется только один указатель для каждого экземпляра класса. , Эта таблица часто будет находиться в одной или нескольких строках кэша L1, а также в узких циклах.
Вывод
Так или иначе, это мое маленькое вращение на эту тему. Я рекомендую рисковать в этих областях с осторожностью. Измерения доверия, а не инстинкт, и с учетом того, как эти оптимизации часто ухудшают удобство обслуживания, заходят настолько далеко, насколько вы можете себе позволить (и разумным путем было бы ошибиться на стороне обслуживания).