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


95

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

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

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

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


11
Напишите читаемый и поддерживаемый код. Только когда вы сталкиваетесь с проблемой переполнения стека, вы можете переосмыслить свой подход
Fabio

33
Общий ответ здесь невозможен. Слишком много разных компиляторов, реализующих слишком много разных языковых спецификаций. И еще есть JIT-скомпилированные языки, динамически интерпретируемые языки и так далее. Достаточно сказать, что если вы компилируете собственный код C или C ++ с помощью современного компилятора, вам не нужно беспокоиться о стоимости вызова функции. Оптимизатор включит их, когда это будет необходимо. Как энтузиаст микро-оптимизации, я редко вижу компиляторы, принимающие встроенные решения, с которыми я или мои тесты не согласны.
Коди Грэй,

6
Исходя из личного опыта, я пишу код на проприетарном языке, который довольно современен с точки зрения возможностей, но вызовы функций смехотворно дороги, до такой степени, что даже типичные для циклов должны быть оптимизированы для скорости: for(Integer index = 0, size = someList.size(); index < size; index++)вместо простого for(Integer index = 0; index < someList.size(); index++). То, что ваш компилятор был создан за последние несколько лет, не обязательно означает, что вы можете отказаться от профилирования.
Phyrfox

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

8
Остерегайтесь слишком маленьких функций, это может запутать код так же эффективно, как и монолитная мега-функция. Если вы мне не верите, посмотрите на некоторых победителей ioccc.org : некоторые кодируют все в один main(), другие разбивают все на 50 крошечных функций, и все они совершенно нечитаемы. Хитрость, как всегда, в том, чтобы найти хороший баланс .
Cmaster

Ответы:


148

Это зависит от вашего домена.

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

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

И наконец, есть золотое правило производительности: ВСЕГДА ПРОФИЛЬ ПЕРВЫЙ. Не пишите «оптимизированный» код, основанный на предположениях. Если вы не уверены, напишите оба случая и посмотрите, какой из них лучше.


13
И например, HotSpot компилятор performes Спекулятивная Встраивание , который в некотором смысле встраивание , даже если это не возможно.
Йорг Миттаг

49
Фактически, в веб-приложении весь код, вероятно, незначителен в отношении доступа к БД и сетевого трафика ...
AnoE

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

2
@Mehrdad Даже в этом случае я бы удивился, если бы в коде не было ничего более подходящего для оптимизации. При профилировании кода я вижу вещи более тяжелые, чем вызовы функций, и именно здесь важно искать оптимизацию. Некоторые разработчики сходят с ума из-за одного или двух неоптимизированных LOC, но когда вы профилируете SW, вы понимаете, что дизайн важнее, чем этот, по крайней мере для большей части кода. Когда вы найдете узкое место, вы можете попытаться оптимизировать его, и это окажет гораздо большее влияние, чем низкоуровневая произвольная оптимизация, такая как написание больших функций, чтобы избежать накладных расходов на вызовы.
Тим

8
Хороший ответ! Ваш последний пункт должен быть первым: всегда профиль, прежде чем решать, где оптимизировать .
CJ Деннис

56

Затраты на вызов функции полностью зависят от языка и уровня оптимизации.

На сверхнизком уровне вызовы функций и, тем более, вызовы виртуальных методов могут быть дорогостоящими, если они приводят к ошибочному прогнозированию ветвлений или кешу ЦП. Если вы написали ассемблер , вы также будете знать, что вам нужно несколько дополнительных инструкций для сохранения и восстановления регистров во время вызова. Неверно, что «достаточно умный» компилятор сможет встроить правильные функции, чтобы избежать этих издержек, потому что компиляторы ограничены семантикой языка (особенно вокруг таких функций, как диспетчеризация метода интерфейса или динамически загружаемые библиотеки).

На высоком уровне языки, такие как Perl, Python, Ruby, ведут большую бухгалтерию за вызов функции, что делает их сравнительно дорогостоящими. Это усугубляется метапрограммированием. Однажды я ускорил программное обеспечение Python 3x, просто выводя вызовы функций из очень горячей петли. В критичном к производительности коде вспомогательные функции могут иметь заметный эффект.

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

  • Если ваш код не критичен к производительности, это облегчает обслуживание. Даже в критически важном программном обеспечении большая часть кода не будет «горячей точкой».

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

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


16
Один действительно умный администратор БД однажды сказал мне: «Нормализуй, пока не болит, а затем денормализуй, пока не повредит». Мне кажется, это можно перефразировать так: «Извлекайте методы, пока это не повредит, а затем вставьте, пока это не повредит».
RubberDuck

1
В дополнение к когнитивным издержкам в информации отладчика есть символические издержки, и обычно издержки в конечных двоичных файлах неизбежны.
Фрэнк Хилман

Что касается умных компиляторов - они МОГУТ сделать это, но не всегда. Например, jvm может встроить вещи, основанные на профиле времени выполнения, с очень дешевой / бесплатной ловушкой для необычного пути или встроенной полиморфной функцией, для которой существует только одна реализация данного метода / интерфейса, и затем деоптимизировать этот вызов должным образом полиморфным, когда новый подкласс загружается динамически. во время выполнения. Но да, есть много языков, где такие вещи невозможны, и много случаев даже в jvm, когда это не выгодно или вообще невозможно.
Артур Бесадовский,

19

Почти все пословицы о настройке кода для производительности являются частными случаями закона Амдала . Краткое, юмористическое изложение закона Амдала:

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

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

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

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

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

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

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

  • Если у вас есть « код лазаньи », в котором есть много уровней абстракции, которые едва ли выполняют какую-либо работу, кроме отправки на следующий уровень, и все эти уровни реализованы с помощью вызовов виртуальных методов, то есть хороший шанс, что ЦП тратит впустую много времени на косвенно-отводных трубопроводах. К сожалению, единственное лекарство от этого - избавиться от некоторых слоев, что зачастую очень сложно.


7
Просто остерегайтесь дорогих вещей, сделанных глубоко во вложенных циклах. Я оптимизировал одну функцию и получил код, который работает в 10 раз быстрее. Это было после того, как профилировщик указал на виновника. (Он вызывался снова и снова, в циклах от O (n ^ 3) до маленького n O (n ^ 6).)
Loren Pechtel

«К сожалению, единственное лекарство от этого - избавиться от некоторых слоев, что зачастую очень сложно». - это очень сильно зависит от вашего языкового компилятора и / или технологии виртуальной машины. Если вы можете изменить код, чтобы компилятору было проще встроить его (например, используя finalклассы и методы, где это применимо в Java, или не virtualметоды в C # или C ++), тогда косвенность может быть устранена компилятором / средой выполнения, и вы ' Увидим выигрыш без масштабной реструктуризации. Как указывает @JorgWMittag выше, JVM может даже встроиться в случаях, когда невозможно доказать, что оптимизация ...
Jules

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

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

@amon Мой опыт работы с «кодом лазаньи» связан с очень большими приложениями на C ++, в которых много кода датируется 1990-ми годами, когда в моду входили глубоко вложенные иерархии объектов и COM. Компиляторы C ++ прилагают довольно героические усилия, чтобы подавить штрафы за абстракцию в подобных программах, и тем не менее вы можете увидеть, как они тратят значительную часть времени выполнения настенных часов на остановках конвейера с непрямой ветвью (и еще один значительный кусок при пропуске I-кэша) ,
zwol

17

Я буду оспаривать эту цитату:

Почти всегда есть компромисс между написанием читаемого кода и написанием кода на языке исполнения.

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

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

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

(Некоторые динамические языки, такие как Python, имеют значительные накладные расходы при вызове метода. Но если производительность становится проблемой, вам, вероятно, не стоит использовать Python в первую очередь.)

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


5

Затраты на вызов функции в большинстве случаев не важны.

Однако больший выигрыш от встраивания кода заключается в оптимизации нового кода после встраивания .

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

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


5

Предполагая, что производительность имеет значение для вашей программы, и она действительно имеет много и много вызовов, стоимость все еще может иметь значение, а может и не иметь значения, в зависимости от типа вызова.

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

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

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

Например, если вы без необходимости вызываете функцию в каждой итерации цикла:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

Компилятор может знать, что это чистая функция, и вывести ее из цикла (в таком ужасном случае, как этот пример, даже исправляет случайный алгоритм O (n ^ 2) как O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

А затем, возможно, даже переписать цикл для обработки 4/8/16 элементов за один раз, используя команды wide / SIMD.

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

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

Эта старая статья может ответить на ваш вопрос:

Гай Льюис Стил, младший. «Разоблачение мифа о« дорогостоящем вызове процедуры », или реализации вызова процедуры, считающегося вредным, или Lambda: The Ultimate GOTO». MIT AI Lab. AI Lab Memo AIM-443. Октябрь 1977 г.

Абстрактные:

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


12
Я очень сомневаюсь в том, что старая статья ответит на вопрос о том, «все еще имеют значение затраты на вызов функции в современных компиляторах».
Коди Грэй,

6
@CodyGray Я думаю, что технология компиляции должна развиваться с 1977 года. Так что если в 1977 году вызовы функций можно было сделать дешевыми, мы сможем сделать это сейчас. Так что ответ - нет. Конечно, это предполагает, что вы используете достойную языковую реализацию, которая может делать такие вещи, как встраивание функций.
Алекс Вонг

4
@AlexVong Полагаться на оптимизацию компилятора 1977 года - все равно что полагаться на тенденции цен на сырьевые товары в каменном веке. Все изменилось слишком сильно. Например, умножение раньше заменялось доступом к памяти как более дешевая операция. В настоящее время это дороже по огромному фактору. Вызовы виртуальных методов относительно намного дороже, чем раньше (доступ к памяти и неправильные прогнозы ветвлений), но часто они могут быть оптимизированы, а вызов виртуальных методов может быть даже встроенным (Java делает это все время), поэтому стоимость ровно ноль. В 1977 году ничего подобного не было.
Maaartinus

3
Как отмечали другие, не только изменения в технологии компиляции сделали недействительными старые исследования. Если бы компиляторы продолжали улучшаться, в то время как микроархитектуры оставались в значительной степени неизменными, то выводы статьи все еще были бы в силе. Но этого не произошло. Во всяком случае, микроархитектуры изменились больше, чем компиляторы. Вещи, которые раньше были быстрыми, теперь медленнее, условно говоря.
Коди Грэй,

2
@AlexVong Чтобы быть более точным об изменениях ЦП, которые делают эту бумагу устаревшей: еще в 1977 году доступ к основной памяти был одним циклом ЦП. Сегодня даже простой доступ к кэш-памяти L1 (!) Имеет задержку от 3 до 4 циклов. Теперь вызовы функций довольно тяжелы при обращении к памяти (создание стекового фрейма, сохранение адреса возврата, сохранение регистров для локальных переменных), что позволяет легко снизить затраты на один вызов функции до 20 и более циклов. Если ваша функция только переставляет свои аргументы и, возможно, добавляет еще один постоянный аргумент для передачи в сквозной вызов, тогда это почти 100% накладных расходов.
cмейстер

3
  • В C ++ остерегайтесь проектирования вызовов функций, которые копируют аргументы, по умолчанию это «передача по значению». Затраты на вызов функции из-за сохранения регистров и других вещей, связанных с кадрами стека, могут быть перегружены непреднамеренной (и потенциально очень дорогой) копией объекта.

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

  • Большую часть времени, когда мне приходилось иметь дело с медленной программой, я обнаружил, что внесение изменений в алгоритмы привело к гораздо большему ускорению, чем встроенные вызовы функций. Например: другой инженер переделал парсер, который заполнил структуру map-of-maps. В рамках этого он удалил кэшированный индекс из одной карты в логически связанный. Это был хороший шаг устойчивости кода, однако он сделал программу непригодной к использованию из-за замедления в 100 раз из-за выполнения поиска хеша для всех будущих обращений по сравнению с использованием сохраненного индекса. Профилирование показало, что большую часть времени занимала функция хеширования.


4
Первый совет немного староват. Начиная с C ++ 11, перемещение стало возможным. В частности, для функций, которым необходимо внутренне изменить свои аргументы, выбор аргумента по значению и его изменение на месте могут быть наиболее эффективным выбором.
MSalters

@MSalters: я думаю, что вы приняли «в частности» с «более того» или что-то в этом роде. Решение о передаче копий или ссылок было принято до C ++ 11 (хотя я знаю, что вы это знаете).
Френель

@phresnel: Я думаю, что понял правильно. Конкретный случай, на который я ссылаюсь, - это случай, когда вы создаете временный объект в вызывающей стороне, перемещаете его в аргумент и затем изменяете его в вызываемом объекте. Это было невозможно до C ++ 11, так как C ++ 03 не может / не будет привязывать неконстантную ссылку к временному ..
MSalters

@MSalters: Тогда я неправильно понял ваш комментарий при первом прочтении. Мне показалось, что вы подразумевали, что до C ++ 11 передача по значению не была чем-то, что можно было бы сделать, если бы вы захотели изменить переданное значение.
Френель

Появление «перемещения» наиболее существенно помогает в возвращении объектов, которые более удобно сконструированы в функции, чем снаружи и передаются по ссылке. До этого возвращение объекта из функции вызывало копию, часто дорогостоящее действие. Это не касается аргументов функций. Я осторожно вставил в комментарий слово «проектирование», поскольку необходимо явно дать разрешение компилятору «переходить» в аргументы функции (&& синтаксис). Я привык «удалять» конструкторы копий, чтобы определить места, где это полезно.
user2543191

2

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

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

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

В ужасе от этих практик средний программист на С мог бы предсказать, что Java должна быть как минимум на порядок медленнее, чем С. И 20 лет назад он был бы прав. Современные тесты, однако, помещают идиоматический Java-код в несколько процентов от эквивалентного C-кода. Как это возможно?

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

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

То есть код:

int x = point.getX();

переписывается

if (point.class != Point) GOTO interpreter;
x = point.x;

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

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


4
«Нет никакой внутренней причины, по которой компилятор не может поддерживать автоматическое встраивание», - так и есть. Вы говорили о JIT-компиляции, которая сводится к самоизменяющемуся коду (который ОС может предотвратить из-за безопасности) и возможности выполнять автоматическую полную оптимизацию под управлением профиля. Компилятор AOT для языка, который допускает динамическое связывание, не знает достаточно, чтобы девиртуализировать и встроить любой вызов. OTOH: у компилятора AOT есть время, чтобы оптимизировать все, что он может, у компилятора JIT есть только время, чтобы сосредоточиться на дешевых оптимизациях в горячих точках. В большинстве случаев это оставляет JIT в небольшом недостатке.
Амон

2
Подскажите одну ОС, которая мешает запуску Google Chrome «из-за безопасности» (V8 компилирует JavaScript в собственный код во время выполнения). Кроме того, желание встроить AOT - не совсем внутренняя причина (это определяется не языком, а архитектурой, которую вы выбираете для своего компилятора), и хотя динамическое связывание запрещает встраивание AOT между модулями компиляции, оно не запрещает встраивание внутри компиляции. единицы, где происходит большинство звонков. Фактически, полезное встраивание возможно легче в языке, который использует динамическое связывание менее интенсивно, чем Java.
меритон - забастовка

4
Примечательно, что iOS предотвращает JIT для непривилегированных приложений. Chrome или Firefox должны использовать представленное Apple веб-представление вместо своих собственных движков. Хорошо, что AOT vs. JIT - это уровень реализации, а не выбор языка.
Амон

@meriton Windows 10 S и операционные системы для игровых приставок также блокируют сторонние движки JIT.
Дамиан Йеррик

2

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

Тем не менее, с концептуального уровня я решил прояснить несколько вещей, которые связаны в вашем вопросе. Во-первых, вы спрашиваете:

Имеют ли значение вызовы функций в современных компиляторах?

Обратите внимание на ключевые слова «функция» и «компиляторы». Ваша цитата тонко отличается:

Помните, что стоимость вызова метода может быть значительной в зависимости от языка.

Это говорит о методах в объектно-ориентированном смысле.

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

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

На языке, подобном C, мы обычно вызываем функции со статической диспетчеризацией . Например:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

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

Альтернативой является динамическая диспетчеризация , когда компилятор не знает, какая функция вызывается. В качестве примера, вот некоторый код на Haskell (поскольку эквивалент C был бы грязным!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

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

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

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

y.foo()Вызов использует динамическую отправку, так как он смотрит вверх значение fooсвойства в yобъекте, и называя все , что он находит; он не знает, что yбудет иметь класс A, или что Aкласс содержит fooметод, поэтому мы не можем просто перейти к нему.

ОК, это основная идея. Обратите внимание, что статическая отправка выполняется быстрее, чем динамическая, независимо от того, компилируем мы или интерпретируем; при прочих равных условиях Разыменование в любом случае требует дополнительных затрат.

Итак, как это влияет на современные оптимизирующие компиляторы?

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

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

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

Если наш язык позволяет нам накладывать больше ограничений, например, ограничивая yкласс Aс помощью аннотации, то мы можем использовать эту информацию для вывода целевой функции. В языках с подклассами (а это почти все языки с классами!) Этого на самом деле недостаточно, поскольку на yсамом деле может иметься другой (под) класс, поэтому нам потребуется дополнительная информация, такая как finalаннотации Java, чтобы точно знать, какая функция будет вызвана.

Haskell не является язык OO, но мы можем сделать вывод , значение fпо встраиванию bar(который статический отправляются) в mainподставляя fooдля y. Так как цель fooin mainстатически известна, вызов становится статически распределенным и, вероятно, будет полностью встроен и оптимизирован (поскольку эти функции невелики, компилятор с большей вероятностью их встроит; хотя в целом мы не можем рассчитывать на это) ).

Следовательно, стоимость сводится к:

  • Язык отправляет ваш звонок статически или динамически?
  • Если это последнее, позволяет ли язык реализации выводить цель, используя другую информацию (например, типы, классы, аннотации, встраивание и т. Д.)?
  • Насколько агрессивно можно оптимизировать статическую диспетчеризацию (логическую или иную)?

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


1
Я не согласен с тем, что вызов замыкания (или некоторого указателя на функцию ), как в вашем примере на Haskell, является динамической отправкой. Динамическая диспетчеризация включает в себя некоторые вычисления (например, с использованием некоторого vtable ), чтобы получить это закрытие, поэтому это более затратно, чем косвенные вызовы. В противном случае, хороший ответ.
Старынкевич,

2

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

К сожалению, это сильно зависит от:

  • набор инструментов компилятора, включая JIT, если таковой имеется,
  • домен.

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

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

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

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


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

Для вызова функции есть две стоимости:

  • стоимость времени выполнения,
  • стоимость времени компиляции.

Стоимость времени выполнения довольно очевидна; для выполнения вызова функции необходимо определенное количество работы. Например, при использовании C на платформе x86 вызов функции потребует (1) пролить регистры в стек, (2) передать аргументы в регистры, выполнить вызов и впоследствии (3) восстановить регистры из стека. Посмотрите это краткое изложение соглашений о вызовах, чтобы увидеть проделанную работу .

Этот разлив / восстановление регистра занимает нетривиальное количество раз (десятки циклов ЦП).

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

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

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

Типичный пример:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Если funcуказывается, то оптимизатор поймет, что ветка никогда не берется, и оптимизировать callдо void call() {}.

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


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


1

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

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

Я просто собираюсь изо всех сил сказать никогда. Я считаю, что цитата безрассудна, чтобы просто выбросить туда.

Конечно, я не говорю полной правды, но мне все равно, что я буду правдивым. Это как в этом фильме «Матрица», я забыл, был ли это 1, 2 или 3 - я думаю, что это был фильм с сексуальной итальянской актрисой с большими дынями (мне не очень понравился, кроме первого), когда оракулистка сказала Киану Ривзу: «Я только что сказала тебе то, что тебе нужно было услышать» или что-то в этом роде, вот что я хочу сделать сейчас.

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

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

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

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

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