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


95

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

Пам'ятайте, що вартість виклику методу може бути значною, залежно від мови. Майже завжди існує компроміс між написанням читаного коду та написанням коду виконавця.

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

Це моє єдине питання. І справа не в тому, чи слід писати довгі чи малі функції. Я просто підкреслюю, що ваші відгуки можуть - або не - сприяти зміні мого ставлення і не дозволяють мені протистояти спокусі блюзнірів .


11
Напишіть читабельний та підтримуваний код Тільки коли у вас виникне проблема із переповненням стека, ви можете переосмислити свій виступ
Фабіо

33
Загальна відповідь тут неможлива. Занадто багато різних компіляторів, що реалізують занадто багато різних мовних специфікацій. А потім є мови, складені JIT, динамічно інтерпретовані мови тощо. Досить сказати, хоча, якщо ви збираєте нативний C або C ++ код із сучасним компілятором, вам не доведеться турбуватися про витрати на виклик функції. Оптимізатор буде вбудовувати їх, коли це доречно. Як ентузіаст мікрооптимізації, я рідко бачу компіляторів, які приймають інлінінгові рішення, з якими я або мої орієнтири не згодні.
Коді Грей

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

5
@phyrfox, що має сенс, отримуючи значення someList.size () поза циклом, а не викликати його щоразу через цикл. Це особливо вірно, якщо є ймовірність виникнення проблеми синхронізації, коли читачі та письменники можуть спробувати зіткнутися під час ітерації. У цьому випадку ви також хочете захистити список від будь-яких змін під час ітерації.
Крейг

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

Відповіді:


148

Це залежить від вашого домену.

Якщо ви пишете код для мікроконтролера малої потужності, вартість дзвінків методу може бути значною. Але якщо ви створюєте звичайний веб-сайт або додаток, то вартість виклику методу буде незначною порівняно з рештою коду. У такому випадку завжди буде більше уваги зосередитись на правильних алгоритмах та структурах даних, а не на мікрооптимізаціях, як виклики методів.

І також є питання про компілятор, який описує методи для вас. Більшість компіляторів досить розумні, щоб вбудовувати функції там, де це можливо.

І останнє, є золоте правило виконання: ВЖЕ ПРОФІЛЮЙТЕ ПЕРШИЙ. Не пишіть "оптимізований" код на основі припущень. Якщо ви незвичні, напишіть обидва випадки і подивіться, що краще.


13
І наприклад, HotSpot компілятор performes Спекулятивна Вбудовування , який в деякому сенсі вбудовування , навіть якщо це НЕ можливо.
Йорг W Міттаг

49
Насправді, у веб-додатку весь код, мабуть, незначний щодо доступу до БД та мережевого трафіку ...
AnoE

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

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

8
Хороша відповідь! Ваша остання точка повинна бути першою: Завжди профілюйте, перш ніж вирішувати, де оптимізувати .
CJ Dennis

56

Накладні витрати виклику повністю залежать від мови та рівня оптимізації.

На наднизькому рівні виклики функцій і тим більше виклики віртуальних методів можуть бути дорогими, якщо вони призведуть до неправильного прогнозування філії або помилок кешу процесора. Якщо ви написали асемблер , ви також будете знати, що вам потрібно кілька додаткових інструкцій для збереження та відновлення регістрів навколо виклику. Неправда, що "достатньо розумний" компілятор зміг би вбудувати правильні функції, щоб уникнути цього накладних витрат, оскільки компілятори обмежені семантикою мови (особливо навколо таких функцій, як диспетчерський метод інтерфейсу або динамічно завантажені бібліотеки).

На високому рівні такі мови, як Perl, Python, Ruby, роблять багато бухгалтерського обліку за виклик функції, роблячи такі порівняно дорогими. Це погіршується мета-програмуванням. Я одного разу прискорив програмне забезпечення Python 3x, просто піднявши виклики функцій із дуже гарячого циклу. У критичному для продуктивності коді вбудовані функції помічників можуть мати помітний ефект.

Але переважна більшість програмного забезпечення не настільки важлива для продуктивності, щоб ви могли помітити накладні виклики функцій. У будь-якому випадку, написання чистого, простого коду окупається:

  • Якщо ваш код не є критичним для продуктивності, це полегшує обслуговування. Навіть у критичному для продуктивного програмного забезпечення більшість коду не буде «гарячою точкою».

  • Якщо ваш код є критичним для продуктивності, простий код полегшує розуміння коду та точкові можливості для оптимізації. Найбільші виграші, як правило, відбуваються не від мікрооптимізації, як вбудовані функції, а від алгоритмічних удосконалень. Або по-різному: не робіть те ж саме швидше. Знайдіть спосіб зробити менше.

Зауважте, що "простий код" не означає "включений у тисячу крихітних функцій". Кожна функція також вводить трохи пізнавальних накладних витрат - важче міркувати про більш абстрактний код. У якийсь момент ці крихітні функції можуть зробити так мало, що їх використання не спростить ваш код.


16
По-справжньому розумний DBA одного разу сказав мені: "Нормалізуйте, поки не болить, потім денормалізуйте, поки цього не стане". Мені здається, це можна перефразовувати до "Витягувати методи, поки не болить, а потім встроюється, поки не стане".
RubberDuck

1
Окрім когнітивних накладних даних, у налагоджувальній інформації є символічна накладні витрати, і зазвичай накладні витрати в кінцевих бінарних файлах неминучі.
Френк Хілеман

Щодо розумних компіляторів - вони МОЖУТИ робити це, не завжди. Наприклад, jvm може вбудовувати речі на основі профілю виконання з дуже дешевим / вільним пасткою для нечастого шляху або вбудованої поліморфної функції, для якої існує лише одна реалізація заданого методу / інтерфейсу, а потім деоптимізувати цей виклик належним чином поліморфно, коли новий підклас динамічно завантажується на час виконання. Але так, є багато мов, де подібні речі неможливі, і багато випадків навіть у jvm, коли це не рентабельно або можливо взагалі.
Артур Бієсядовський

19

Майже всі пристосування щодо налаштування коду для виконання - це особливі випадки закону Амдаля . Короткий, жартівливий виклад закону Амдала є

Якщо один фрагмент вашої програми займає 5% часу виконання, а ви оптимізуєте цей фрагмент так, щоб він зараз займав нульовий відсоток часу виконання, програма в цілому буде лише на 5% швидшою.

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

Ось чому люди зазвичай кажуть не турбуватися про витрати на функціональні дзвінки: як би вони не були дорогими, звичайно програма загалом витрачає лише невелику частину часу її виконання на накладні дзвінки, тому пришвидшення їх не дуже допомагає .

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

І, якщо у вас є підстави вважати , що програма буде проводити більшу частину свого часу виконання просто робить виклики функцій, то ви повинні почати думати про те, чи є чи деякі з цих викликів функції не потрібні. Ось кілька основних правил для того, щоб знати, коли ви повинні це зробити:

  • Якщо час виконання функції на виклик менше мілісекунди, але ця функція викликається сотні тисяч разів, вона, ймовірно, повинна бути окреслена.

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

  • Якщо у вас є " код лазаньї ", в якому є багато шарів абстракції, які навряд чи виконують будь-яку роботу, крім відправки на наступний шар, і всі ці шари реалізовані за допомогою викликів віртуальних методів, то є хороший шанс, що процесор витратить багато часу на непрямих патрубках трубопроводів. На жаль, єдине ліки від цього - позбутися деяких шарів, що часто буває дуже важко.


7
Просто остерігайтесь дорогих речей, зроблених глибоко в вкладені петлі. Я оптимізував одну функцію і отримав код, який працює 10 разів так само швидко. Це було після того, як профайлер вказав на винуватця. (Його називали знову і знову, в петлях від O (n ^ 3) до невеликого n O (n ^ 6).)
Лорен Печтел

"На жаль, єдине ліки від цього - позбутися деяких шарів, що часто буває дуже важко". - це дуже залежить від вашого компілятора мови та / або технології віртуальної машини. Якщо ви можете змінити код, щоб полегшити компілятору вбудований (наприклад, за допомогою finalкласів та методів, де це застосовано на Java, або неметодів virtualу C # або C ++), тоді непрямий характер може бути усунений компілятором / програмою, і ви ' Ви побачите прибуток без масштабної перебудови. Як @JorgWMittag вказує вище, віртуальна машина може навіть вбудований в тих випадках , коли це не доказовою , що оптимізація ...
Жюль

... дійсно, тому може бути так, що це робиться у вашому коді, незважаючи на багатошаровість.
Жуль

@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, використовуючи вказівки широкого / SIMD.

Але якщо ви додаєте виклик до якогось непрозорого коду в циклі, навіть якщо виклик нічого не робить і сам супер дешевий, компілятор повинен вважати найгірше - що виклик отримає доступ до глобальної змінної, яка вказує на ту саму пам'ять, що і sзміна його вміст (навіть якщо він constу вашій функції, він може бути constніде більше), що робить оптимізацію неможливою:

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

3

Цей старий документ може відповісти на ваше запитання:

Гай Льюїс Стіл, молодший. "Розгортання міфу" Дорогий виклик процедури ", або" Реалізація викликів процедури "вважається шкідливою, або, Лямбда: Остаточний GOTO". Лабораторія MIT AI Пам'ятка AI-лабораторії AIM-443. Жовтень 1977 року.

Анотація:

Фольклор стверджує, що заяви GOTO є "дешевими", а виклики процедур - "дорогими". Цей міф значною мірою є результатом погано розроблених мовних реалізацій. Розглянуто історичне зростання цього міфу. Обговорюються як теоретичні ідеї, так і існуюча реалізація, що розкриває цей міф. Показано, що необмежене використання процедурних дзвінків дозволяє отримати велику стильну свободу. Зокрема, будь-яка блок-схема може бути записана як "структурована" програма без введення додаткових змінних. Складність із заявою GOTO та викликом процедури характеризується як конфлікт між абстрактними поняттями програмування та конкретними мовними конструкціями.


12
Я дуже сумніваюся в роботі, що старий відповість на питання, чи "в сучасних компіляторах все ще мають значення витрати на виклики функцій ".
Коді Грей

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

4
@ АлексВонг Покладаючись на оптимізацію компілятора 1977 року, це як покладання на тенденції цін на товари в кам'яну епоху. Все дуже змінилося. Наприклад, множення використовувалося для заміни доступу до пам'яті як більш дешева операція. В даний час це дорожче величезним фактором. Виклики віртуальних методів відносно набагато дорожчі, ніж раніше (помилки з доступом до пам’яті та гілки), але часто їх можна оптимізувати, а виклик віртуального методу можна навіть накреслити (Java робить це постійно), тому вартість становить рівно нуль. У 1977 році нічого подібного не було
maaartinus

3
Як зазначають інші, не лише зміни в технології компілятора старі дослідження визнали недійсними. Якби компілятори продовжували вдосконалюватися, хоча мікроархітектури залишалися в основному незмінними, висновки статті все-таки були б справедливими. Але цього не сталося. Якщо що, мікроархітектури змінилися більше, ніж компілятори. Речі, які раніше були швидкими, зараз повільні, відносно кажучи.
Коді Грей

2
@AlexVong Щоб бути більш точним щодо змін у процесорі, які роблять цей папір застарілим: ще в 1977 році основним доступом до пам'яті був єдиний цикл процесора. Сьогодні навіть простий доступ до кешу L1 (!) Має затримку від 3 до 4 циклів. Тепер виклики функцій є досить важкими в доступі до пам'яті (створення кадру стека, збереження зворотної адреси, збереження регістрів для локальних змінних), що легко призводить до витрат на один виклик функції на 20 і більше циклів. Якщо ваша функція лише переставляє свої аргументи і, можливо, додає ще один постійний аргумент, щоб перейти до виклику, це майже 100% накладні витрати.
cmaster

3
  • У C ++ остерігайтеся проектування функціональних викликів, які копіюють аргументи, за замовчуванням - "передавати за значенням". Накладні виклики функцій через збереження реєстрів та інших матеріалів, пов'язаних з кадрами, можуть бути переповнені ненавмисною (і, можливо, дуже дорогою) копією об'єкта.

  • Існують оптимізації, пов'язані зі стеком-кадрами, які слід вивчити, перш ніж відмовитись від висококорекційного коду.

  • Більшу частину часу, коли мені доводилося стикатися з повільною програмою, я виявив, що внесення алгоритмічних змін призводить до набагато більших швидкостей, ніж виклики функцій вкладок. Наприклад: інший інженер переробив аналізатор, який заповнив структуру карт-карт. У рамках цього він видалив кешований індекс з однієї карти до логічно пов'язаної. Це був приємний крок надійності коду, однак він зробив програму непридатною через фактор 100 сповільнення, завдяки виконанню хеш-пошуку для всіх майбутніх доступів порівняно з використанням збереженого індексу. Профілювання показало, що більшу частину часу проводили за хеш-функцією.


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

@MSalters: Я думаю, ви неправильно сприйняли "зокрема" з "далі" або щось подібне. Рішення про передачу копій чи посилань було раніше C ++ 11 (тому я знаю, ви це знаєте).
phresnel

@phresnel: Я думаю, що я зрозумів це правильно. Конкретний випадок, про який я маю на увазі, - це випадок, коли ви створюєте тимчасовий номер у виклику, переміщуєте його в аргумент та змінюєте його у виклику. Це було неможливо раніше, ніж C ++ 11, оскільки C ++ 03 не може / не пов'язує посилання без
обмежень

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

Поява "переміщення" допомагає найбільше повернути об'єкти, які зручніше побудовані у функції, ніж зовні і передаються посиланням. До цього повернення об'єкта з функції викликало копію, часто дорогу ходу. Це не стосується аргументів функції. Я ретельно вкладаю слово "проектування" в коментар, оскільки потрібно явно дати компілятору дозвіл на "переміщення" в аргументи функції (& синтаксис &&). У мене звикла «видаляти» конструктори копій, щоб визначити місця, де це важливо.
користувач2543191

3

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

І все-таки з концептуального рівня я подумав, що я проясню декілька речей, які пов'язані з вашим запитанням. По-перше, ви запитуєте:

Чи все ще важливі витрати на виклики функцій у сучасних компіляторах?

Зауважте ключові слова "функція" та "компілятори". Ваша цитата тонко відрізняється:

Пам'ятайте, що вартість виклику методу може бути значною, залежно від мови.

Мова йде про методи , в об’єктно-орієнтованому сенсі.

Хоча "функцію" та "метод" часто використовують беззмістовно, існують відмінності, коли мова йде про їхню вартість (про яку ви запитуєте) та коли мова йде про компіляцію (що таке контекст, який ви дали).

Зокрема, нам потрібно знати про статичну та динамічну . На даний момент я проігнорую оптимізації.

Мовою, такою як 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 не є мовою ОО, але ми можемо зробити висновок про значення f, включивши bar(який статично відправляється) main, замінивши fooна y. Оскільки ціль в fooin mainстатично відома, виклик стає статично відправленим і, ймовірно, буде вбудованим та оптимізованим повністю (оскільки цих функцій мало, компілятор швидше їх вбудовує; хоча ми взагалі не можемо розраховувати на це ).

Отже, вартість зводиться до:

  • Мова відправляє ваш дзвінок статично чи динамічно?
  • Якщо мова йде про останнє, чи дозволяє мова реалізації реалізувати ціль за допомогою іншої інформації (наприклад, типів, класів, анотацій, вкладок тощо)?
  • Наскільки агресивно можна оптимізувати статичну диспетчеризацію (висновок чи інше)?

Якщо ви користуєтеся "дуже динамічною" мовою, з великою кількістю динамічної відправки та кількома гарантіями, доступними для компілятора, то кожен дзвінок буде мати витрати. Якщо ви використовуєте "дуже статичну" мову, то зрілий компілятор створить дуже швидкий код. Якщо ви перебуваєте між ними, то це може залежати від вашого стилю кодування та наскільки розумною є реалізація.


Я не погоджуюся з тим, що виклик закриття (або деякого вказівника функції ) - як ваш приклад Haskell - це динамічна відправка. динамічна відправка включає деякі обчислення (наприклад, використання деяких vtable ), щоб отримати це закриття, тому коштує дорожче, ніж непрямі дзвінки. Інакше приємна відповідь.
Василь Старинкевич

2

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

Як приклад розглянемо Java. На перший погляд, накладні виклики функцій повинні бути особливо домінуючими в цій мові:

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

З жахом цих практик пересічний програміст C передбачив, що Ява повинен бути хоча б на порядок повільнішим за C. І 20 років тому він мав би рацію. Однак сучасні орієнтири ідіоматичний код Java розміщують у межах кількох відсотків від еквівалентного коду С. Як це можливо?

Однією з причин є те, що сучасні 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.
meriton

4
Зокрема, iOS запобігає JIT для непривілейованих додатків. Chrome або Firefox повинні використовувати представлений Apple Apple замість власних двигунів. Хороший момент, хоча AOT проти JIT - це рівень реалізації, а не вибір рівня мови.
амон

@meriton Windows 10 S і консолі відеоігрових консолей також, як правило, блокують сторонні JIT-двигуни.
Damian Yerrick

2

Пам'ятайте, що вартість виклику методу може бути значною, залежно від мови. Майже завжди існує компроміс між написанням читаного коду та написанням коду виконавця.

На жаль, це дуже залежить від:

  • ланцюжок інструментів компілятора, включаючи JIT, якщо такий є,
  • домен.

Перш за все, перший закон оптимізації продуктивності є профіль першим . Є багато доменів, де продуктивність програмної частини не має значення для продуктивності всього стека: дзвінки в базу даних, операції в мережі, операції з ОС, ...

Це означає, що продуктивність програмного забезпечення абсолютно не має значення, навіть якщо це не покращує затримку, оптимізація програмного забезпечення може призвести до економії енергії та економії обладнання (або економії акумулятора для мобільних додатків), що може мати значення.

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

Отже, перш ніж оптимізувати, вам потрібно зрозуміти, для чого ви оптимізуєтесь… і чи варто того.


Що стосується чистої продуктивності програмного забезпечення, вона значно різниться між ланцюжками інструментів.

Існує дві витрати на виклик функції:

  • вартість часу виконання,
  • вартість складання часу.

Вартість запуску досить очевидна; для виконання функції виклику необхідна певна робота. Наприклад, використовуючи C на x86, для виклику функції знадобиться (1) розсипання регістрів до стеку, (2) висунення аргументів до регістрів, виконання виклику, а потім (3) відновлення регістрів зі стека. Дивіться цей підсумок викликів конвенцій, щоб побачити залучену роботу .

Цей розлив / відновлення реєстру займає нетривіальну кількість разів (десятки циклів процесора).

Як правило, очікується, що ця вартість буде тривіальною порівняно з фактичною вартістю виконання функції, однак деякі моделі тут є непродуктивними: getters, функції, що охороняються простою умовою тощо ...

Крім перекладачів , програміст сподівається, що їх компілятор або JIT оптимізують непотрібні виклики функцій; хоча інколи ця надія може не принести плодів. Тому що оптимізатори - це не магія.

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

Типовий приклад:

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

void call() { func(false); }

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

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


На закінчення, моя порада спочатку писати чітко , уникаючи передчасної алгоритмічної песимізації (кубічна складність або швидше укуси швидко), а потім лише оптимізувати те, що потребує оптимізації.


1

"Пам'ятайте, що вартість виклику методу може бути значною, залежно від мови. Майже завжди існує компроміс між написанням читабельного коду та написанням коду виконавця."

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

Я просто збираюся сказати ніколи. Я вважаю, що цитата є необережною, щоб просто викинути її туди.

Звичайно, я не кажу цілковито правди, але мені не байдуже бути правдивим настільки. Це як у тому фільмі "Матриця", я забув, якщо це було 1 або 2 або 3 - я думаю, це була та сама сексуальна італійська актриса з великими динями (мені не сподобалася жодна, крім першої), коли леді-оракул сказала Кіану Ривзу: "Я щойно сказала тобі, що потрібно почути", або щось для цього я хочу зробити зараз.

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

У будь-якому випадку, для більш точної відповіді це залежить. Деякі умови завантаження човна вже вказані серед тонких відповідей. Можливі умови просто вибору однієї мови самі по собі вже величезні, як C ++, який повинен був би потрапити в динамічну розсилку у віртуальних викликах, і коли її можна оптимізувати, і під якими компіляторами і навіть посиланнями, і це вже вимагає детальної відповіді, не кажучи вже про те вирішувати умови всіма можливими мовами та компілятором там. Але я додам зверху, «хто піклується?» тому що навіть працюючи в критичних для продуктивності областях, як ретракція, останнє, що я коли-небудь почну робити наперед, - це методи ручної вставки, перш ніж я буду проводити будь-які вимірювання.

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

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.