В C ++ почему и как виртуальные функции работают медленнее?


38

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

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


5
Поиск правильного вызова метода из vtable , очевидно, займет больше времени, чем прямой вызов метода, так как есть еще что сделать. Сколько еще времени или значительна ли это дополнительное время в контексте вашей собственной программы - это другой вопрос. en.wikipedia.org/wiki/Virtual_method_table
Роберт Харви,

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

7
Часто виртуальные вызовы не являются медленными, но компилятор не может их встроить.
Кевин Сюй

4
@ Кевин Хсу: да, это так. Почти каждый раз, когда кто-то говорит вам, что он ускорился от устранения некоторых «накладных расходов на вызовы виртуальных функций», если вы посмотрите на то, откуда фактически произошло все ускорение, будет происходить от оптимизаций, которые теперь возможны, потому что компилятор не может оптимизировать через неопределенный звонок ранее.
Тимдай

7
Даже человек, который может прочитать ассемблерный код, не может точно предсказать его издержки при фактическом выполнении процессора. Производители процессоров на базе настольных компьютеров вложили в десятилетия исследования не только прогнозирования ветвлений, но также прогнозирования ценностей и умозрительного выполнения по основной причине маскировки задержки виртуальных функций. Зачем? Потому что настольные ОС и программное обеспечение часто их используют. (Я бы не сказал то же самое о мобильных процессорах.)
rwong

Ответы:


55

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

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

Большое но : эти хиты производительности, как правило, слишком малы, чтобы иметь значение. Им стоит подумать, хотите ли вы создать высокопроизводительный код и добавить виртуальную функцию, которая будет вызываться с пугающей частотой. Однако следует также помнить, что замена вызовов виртуальных функций другими средствами ветвления ( if .. else, switchуказателями функций и т. Д.) Не решит фундаментальную проблему - она ​​может быть очень медленной. Проблема (если она вообще существует) не в виртуальных функциях, а в (ненужном) косвенном обращении.

Изменить: Разница в инструкциях вызова описана в других ответах. По сути, код для статического («нормального») вызова:

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

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

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

Что касается ветвей: Ветвь - это все, что переходит к другой инструкции вместо того, чтобы просто выполнить следующую инструкцию. Это включает в себя if, switch, части различных циклов, вызовов функций и т.д. , а иногда компилятор реализует вещи , которые , кажется, не ветви таким образом , что на самом деле нуждается в отделении под капотом. См. Почему обработка отсортированного массива быстрее, чем несортированного массива? почему это может быть медленным, что процессоры делают, чтобы противостоять этому замедлению, и как это не панацея.


6
@ JörgWMittag все они являются интерпретаторами, и они все еще медленнее, чем двоичный код, сгенерированный компиляторами C ++
Сэм,

13
@ JörgWMittag Эти оптимизации в основном существуют для того, чтобы сделать косвенное / позднее связывание (почти) бесплатным, когда оно не нужно , потому что в этих языках каждый вызов технически связан с поздним. Если вы действительно вызываете множество различных виртуальных методов из одного места в течение короткого времени, эти оптимизации не помогут или не повредят активно (создайте много кода для нуля). Ребята из C ++ не очень заинтересованы в этих оптимизациях, потому что они находятся в совершенно другой ситуации ...

10
@ JörgWMittag ... Ребята из C ++ не очень заинтересованы в этих оптимизациях, потому что они находятся в совершенно другой ситуации: способ компилирования AOT с помощью vtable уже довольно быстр, очень немногие вызовы на самом деле являются виртуальными, многие случаи полиморфизма происходят на ранней стадии. привязаны (через шаблоны) и, следовательно, могут быть изменены для оптимизации AOT. Наконец, адаптивное выполнение этих оптимизаций (а не просто спекуляция во время компиляции) требует генерации кода во время выполнения, что создает массу головной боли. JIT-компиляторы уже решили эти проблемы по другим причинам, поэтому они не против, но AOT-компиляторы хотят этого избежать.

3
отличный ответ +1. Однако следует отметить, что иногда результаты ветвления известны во время компиляции, например, когда вы пишете каркасные классы, которые должны поддерживать различные виды использования, но когда код приложения взаимодействует с этими классами, конкретное использование уже известно. В этом случае альтернативой виртуальным функциям могут быть шаблоны C ++. Хорошим примером может служить CRTP, который эмулирует поведение виртуальных функций без каких-либо таблиц: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@ Джеймс У тебя есть смысл. То, что я пытался сказать, таково: у любого косвенного обращения есть те же самые проблемы, в которых нет ничего конкретного virtual.

23

Вот некоторый фактический дизассемблированный код из вызова виртуальной функции и не виртуального вызова, соответственно:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

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

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

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


1
Следует понимать, что поиск vtable и косвенный вызов почти во всех случаях будут оказывать незначительное влияние на общее время выполнения вызываемого метода.
Джон Р. Штром

12
@ JohnR.Strohm У одного человека ничтожное узкое место для другого человека
Джеймс

1
-0x8(%rbp), о мой ... этот синтаксис AT & T.
Abyx

« трех дополнительных инструкций » нет, только две: загрузка vptr и загрузка указателя функции
curiousguy

@curiousguy это на самом деле три дополнительные инструкции. Вы забыли, что виртуальный метод всегда вызывается для указателя , поэтому сначала необходимо загрузить указатель в регистр. Подводя итог, самый первый шаг должен загрузить адрес, который переменная указателя содержит в регистр% rax, затем в соответствии с адресом в регистре, загрузить vtpr по этому адресу, чтобы зарегистрировать% rax, а затем в соответствии с этим адресом в зарегистрируйтесь, загрузите адрес вызываемого метода в% rax, затем callq *% rax !.
Gab 是 好人

18

Медленнее чем что ?

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

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

Обратите внимание, что в C ++ вызовы виртуальных функций не всегда являются динамическими. Когда вызовы выполняются для объекта, точный тип которого известен (потому что объект не является указателем или ссылкой, или потому что его тип может быть статически выведен), тогда вызовы являются просто обычными вызовами функций-членов. Это не только означает, что нет накладных расходов на диспетчеризацию, но также и то, что эти вызовы могут быть встроены так же, как обычные вызовы.

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

Новое: Кроме того, мы не должны забывать общие библиотеки. Если вы используете класс, который находится в разделяемой библиотеке, вызов обычной функции-члена не будет просто хорошей последовательностью инструкций, как callq 0x4007aa. Он должен пройти через несколько циклов, например, через "таблицу ссылок на программы" или какую-то подобную структуру. Следовательно, косвенное обращение к разделяемой библиотеке может несколько (если не полностью) выровнять разницу в стоимости между (действительно косвенным) виртуальным вызовом и прямым вызовом. Таким образом, рассуждения о компромиссах виртуальных функций должны принимать во внимание то, как строится программа: монолитно ли класс целевого объекта связан с программой, выполняющей вызов.


4
"Медленнее чем?" - если вы делаете виртуальный метод, который не должен быть, у вас есть довольно хороший материал для сравнения.
tdammers

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

12

потому что виртуальный вызов эквивалентен

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

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

это также позволяет встроить функцию (со всеми вытекающими последствиями оптимизации)

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.