Приложения AI на С++: насколько дорогими являются виртуальные функции? Каковы возможные оптимизации?

В приложении AI я пишу на С++,

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

В такой ситуации существуют ли какие-либо методы оптимизации? Хотя сейчас я не буду оптимизировать приложение, одним из аспектов выбора С++ над Java для проекта было включение большего количества рычагов для оптимизации и возможность использования неъектных ориентированных методов (шаблонов, процедур, перегрузки).

В частности, каковы методы оптимизации, связанные с виртуальными функциями? Виртуальные функции реализуются через виртуальные таблицы в памяти. Есть ли способ предварительно извлечь эти виртуальные таблицы в кеш-память L2 (стоимость извлечения из кэша памяти /L 2 увеличивается)?

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

Обновление. Также см. следующие связанные форумы: Штраф за производительность для интерфейса, Несколько уровней базовых классов

Ответ 1

Виртуальные функции очень эффективны. Предполагая, что 32-битные указатели имеют формат памяти примерно:

classptr -> [vtable:4][classdata:x]
vtable -> [first:4][second:4][third:4][fourth:4][...]
first -> [code:x]
second -> [code:x]
...

Класс указывает на память, которая обычно находится в куче, иногда в стеке, и начинается с четырехбайтового указателя на vtable для этого класса. Но важно помнить, что vtable сама по себе не выделяет память. Это статический ресурс, и все объекты одного и того же типа класса будут указывать на точно такое же место памяти для своего массива vtable. Вызов разных экземпляров не приведет к потере различных мест памяти в кэш L2.

Этот пример из msdn показывает vtable для класса A с виртуальными func1, func2 и func3. Не более 12 байт. Существует хорошая вероятность того, что vtables разных классов также будут физически смежными в скомпилированной библиотеке (вы захотите проверить, что это особенно вас касается), что может повысить эффективность кеширования в микроскопическом режиме.

CONST SEGMENT
[email protected]@[email protected]
   DD  FLAT:[email protected]@@UAEXXZ
   DD  FLAT:[email protected]@@UAEXXZ
   DD  FLAT:[email protected]@@UAEXXZ
CONST ENDS

Другая проблема с производительностью - это накладные расходы при вызове через функцию vtable. Это также очень эффективно. Почти идентично вызову не виртуальной функции. Опять из примера из msdn:

; A* pa;
; pa->func3();
mov eax, DWORD PTR _pa$[ebp]
mov edx, DWORD PTR [eax]
mov ecx, DWORD PTR _pa$[ebp]
call  DWORD PTR [edx+8]

В этом примере ebp, базовый указатель фрейма стека, имеет переменную A* pa с нулевым смещением. Регистр eax загружается значением в местоположении [ebp], поэтому он имеет A *, а edx загружается значением в местоположении [eax], поэтому он имеет класс A vtable. Затем ecx загружается с помощью [ebp], потому что ecx представляет "this", теперь он содержит A *, и, наконец, вызов выполняется в значение в местоположении [edx + 8], которое является третьим адресом функции в таблице vtable.

Если этот вызов функции не был виртуальным, mov eax и mov edx не нужны, но разница в производительности будет неизмеримо мала.

Ответ 3

Вы действительно профилировали и нашли, где и для чего нужна оптимизация?

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

Ответ 4

Единственная оптимизация, о которой я могу думать, это компилятор Java JIT. Если я правильно ее понимаю, он отслеживает вызовы по мере запуска кода, и если большинство вызовов переходят только к конкретной реализации, он вставляет условный переход к реализации, когда класс прав. Таким образом, в большинстве случаев, нет vtable-поиска. Конечно, для редкого случая, когда мы проходим другой класс, vtable все еще используется.

Я не знаю ни одного компилятора/исполняемого файла С++, использующего эту технику.

Ответ 5

Виртуальные функции, как правило, являются вызовом функции поиска и косвенности. На некоторых платформах это происходит быстро. На других, например, одной популярной архитектуре PPC, используемой в консолях, это не так быстро.

Оптимизации обычно вращаются вокруг выражения изменчивости выше в стоп-лотке, так что вам не нужно многократно вызывать виртуальную функцию в горячих точках.

Ответ 6

Вы можете реализовать полиморфизм во время выполнения с использованием виртуальных функций и во время компиляции с помощью шаблонов. Вы можете заменить виртуальные функции на шаблоны. Взгляните на эту статью для получения дополнительной информации - http://www.codeproject.com/KB/cpp/SimulationofVirtualFunc.aspx

Ответ 7

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

http://en.wikipedia.org/wiki/Curiously_recurring_template_pattern

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

Ответ 8

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

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

Class A {
   inline virtual int foo() {...}
};

И когда вы находитесь в точке кода, вы УВЕРЕНЫ относительно типа вызываемого объекта, вы можете сделать встроенный вызов, который позволит избежать полиморфной системы и включить вложение компилятором.

class B : public A {
     inline virtual int foo() 
     {
         //...do something different
     }

     void bar()
     {
      //logic...
      B::foo();
      // more  logic
     }
};

В этом примере вызов foo() будет выполняться не полиморфно и привязан к B реализации foo(). Но делайте это только тогда, когда вы точно знаете, что такое тип экземпляра, потому что функция автоматического полиморфизма исчезнет, ​​и это не очень очевидно для более поздних читателей кода.

Ответ 9

Я подкрепляю все ответы, которые говорят по сути:

  • Если вы на самом деле не знаете, что это проблема, любая проблема с ее исправлением, вероятно, неверна.

Что вы хотите знать:

  • Какая часть времени выполнения (когда она фактически выполняется) расходуется в процессе вызова методов, и, в частности, какие методы являются наиболее дорогостоящими (по этой мере).

Некоторые профилировщики могут дать вам эту информацию косвенно. Они должны суммироваться на уровне заявлений, но не считая времени, проведенного в самом методе.

Мой любимый метод - просто приостановить его несколько раз под отладчиком.

Если время, затрачиваемое на вызовы виртуальной функции, является значительным, например, 20%, то в среднем 1 из 5 выборок будет отображаться в нижней части стека вызовов в окне разборки, инструкции для следуя указателю виртуальной функции.

Если вы этого не видите, это не проблема.

В этом процессе вы, вероятно, увидите другие вещи выше стека вызовов, которые на самом деле не нужны и могут сэкономить вам много времени.

Ответ 10

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

Тем не менее, это может все еще иметь большее влияние, так как сложнее оптимизировать компилятор. Он не может встроить вызов функции, потому что он не знает во время компиляции, какая функция будет вызвана. Это также усложняет некоторые глобальные оптимизации. И насколько это стоит вам? Это зависит. Обычно не о чем беспокоиться, но бывают случаи, когда это может означать значительный удар по производительности.

И, конечно же, это также зависит от архитектуры процессора. На некоторых это может стать довольно дорогостоящим.

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

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

Но сначала убедитесь, что у вас есть проблема. Действительно ли код слишком медленный, чтобы быть приемлемым? Во-вторых, узнайте, что замедляет работу через профилировщик. И в-третьих, исправьте это.

Ответ 11

Статический полиморфизм, как некоторые пользователи ответили здесь. Например, WTL использует этот метод. Ясное объяснение реализации WTL можно найти на http://www.codeproject.com/KB/wtl/wtl4mfc1.aspx#atltemplates

Ответ 12

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

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

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

Такие вещи, как Vtables, как правило, не будут проблемой производительности/кеша/памяти; обычно там только один Vtable для каждого типа объекта, и объект содержит указатель на Vtable вместо самого Vtable. Поэтому, если у вас нет нескольких тысяч типов объектов, я не думаю, что Vtables собираются трэш-кеш.

1), кстати, почему функции, подобные memcpy, используют кеш-обход инструкций потоковой передачи, таких как movnt (dq | q) для чрезвычайно больших (много мегабайтных) входов данных.

Ответ 13

В настоящее время стоимость более или менее такая же, как и обычные функции для последнего CPUS, но они не могут быть встроены. Если вы вызовете функцию миллионы раз, влияние может быть значительным (попробуйте назвать миллионы раз одной и той же функцией, например, один раз с inline один раз без нее, и вы увидите, что она может быть в два раза медленнее, если сама функция делает что-то простое; не является теоретическим: это довольно часто для большого числа численных вычислений).

Ответ 14

При использовании современных, перспективных, многодисковых процессоров накладные расходы для виртуальной функции могут быть нулевыми. Нада. Zip.

Ответ 15

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

Существует несколько оптимизаций для виртуальных функций,

  • Люди написали компиляторы, которые прибегают к анализу кода и трансформации программы. Но это не компиляторы производственного класса.
  • Вы можете заменить все виртуальные функции эквивалентными блоками "switch... case" для вызова соответствующих функций на основе типа в иерархии. Таким образом вы избавитесь от виртуальной таблицы, управляемой компилятором, и у вас будет своя виртуальная таблица в виде блока... case block. Теперь вероятность того, что ваша собственная виртуальная таблица находится в кеше L2, высока, как в коде. Помните, что для этого вам понадобится RTTI или ваша собственная функция "typeof".