Написання високоефективного коду Javascript без деоптимізації


10

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

  1. Ми завжди хочемо, щоб наші масиви були запаковані SMI (малі цілі числа) або упаковані парні, залежно від того, чи робимо ми цілі чи обчислення з плаваючою комою.
  2. Ми завжди хочемо передавати однотипні речі функціям, щоб вони не були позначені "мегаморфними" та знезаражені. Наприклад, ми завжди хочемо телефонувати vec.add(x, y)з обома xі yзапакованими SMI-масивами або обома упакованими подвійними масивами.
  3. Ми хочемо, щоб функції були максимально накреслені.

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

  1. Ви можете перетворити запакований масив SMI в упакований подвійний масив через, здавалося б, нешкідливу операцію, як еквівалент myArray.map(x => -x). Це насправді "найкращий" поганий випадок, оскільки запаковані подвійні масиви все ще дуже швидкі.
  2. Ви можете перетворити упакований масив в загальний коробочки масив, наприклад , шляхом відображення масиву над функцією, (несподівано) повернутої nullабо undefined. Цього поганого випадку уникнути досить просто.
  3. Ви можете деоптимізувати цілу функцію, наприклад vec.add(), передаючи занадто багато речей і перетворюючи її на мегаморфну. Це може статися, якщо ви хочете робити "загальне програмування", де vec.add()воно використовується як у випадках, коли ви не стежите за типами (тому він бачить багато типів), так і у випадках, коли ви хочете отримати максимальну продуктивність (наприклад, він повинен отримувати лише парні бокси, наприклад).

Моє запитання є більш м'яким питанням про те, як писати високоефективний код Javascript з огляду на вищезазначені міркування, зберігаючи код приємним і читабельним. Деякі конкретні підпитання, щоб ви знали, на яку відповідь я прагну:

  • Чи є десь настанови, як програмувати, залишаючись у світі запакованих масивів SMI (наприклад)?
  • Чи можливо робити універсальне високоефективне програмування в Javascript, не використовуючи щось на зразок макросистеми для vec.add()вбудовування таких речей, як у callites?
  • Як один модульний код високої продуктивності перетворюється на бібліотеки у світлі таких речей, як мегаморфні сайти викликів та деоптимізація? Наприклад, якщо я із задоволенням використовую пакет Linear Algebra Aз високою швидкістю, а потім імпортую пакет B, від якого залежить A, але Bвикликає його з іншими типами і деоптимізує його, раптом (без зміни коду) мій код працює повільніше.
  • Чи є якісь хороші прості у використанні інструменти вимірювання для перевірки того, що працює двигун Javascript внутрішньо з типами?

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

"Моє запитання - це більш м'яке питання про те, як писати високоефективний код Javascript ..." Зауважте, що javascript забезпечує нерестовину фонових процесів (веб-працівників), а також є бібліотеки, які використовують GPU (tensorflow.js і gpu.js) пропонує інші засоби, крім того, щоб покладатися виключно на компіляцію для підвищення обчислювальної пропускної здатності додатка на основі Java ...
Jon Trent,

@JonTrent Насправді я трохи брехав на своїй посаді, мені так не важливо класичних додатків лінійної алгебри, але більше для комп'ютерної алгебри над цілими числами. Це означає, що багато існуючих числових пакетів негайно виключаються, оскільки (наприклад) при зменшенні рядків матриці вони можуть ділитися на 2, що "не дозволено" у світі, в якому я працюю (1/2) не є цілим числом. Я розглядав веб-працівників (особливо для кількох тривалих обчислень, які я хочу скасувати), але проблема, з якою я тут звертаюся, - це зменшення затримки, щоб відповідати на взаємодію.
Joppy

Для цілої арифметики в JavaScript ви, мабуть, переглядаєте код стилю asm.js, приблизно "ставлячи |0позаду кожну операцію". Це не дуже, але найкраще, що можна зробити мовою, яка не має належних цілих чисел. Ви також можете використовувати BigInts, але на сьогоднішній день вони не дуже швидкі в будь-якому з поширених двигунів (в основному через відсутність попиту).
jmrk

Відповіді:


8

Тут розробник V8. Враховуючи велику зацікавленість цим питанням та відсутність інших відповідей, я можу дати це зняти; Боюся, це не буде відповіддю, на який ви сподівалися.

Чи є десь настанови, як програмувати, залишаючись у світі запакованих масивів SMI (наприклад)?

Коротка відповідь: саме тут : const guidelines = ["keep your integers small enough"].

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

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

Повернення до прикладу: внутрішнє використання Smis має бути детальною інформацією про реалізацію, про яку не потрібно знати користувальницькому коду. Це зробить деякі випадки більш ефективними, а в інших випадках не зашкодить. Не всі двигуни використовують Smis (наприклад, AFAIK Firefox / Spidermonkey в минулому історично не було; я чув, що в деяких випадках вони використовують Smis в наші дні; але я не знаю жодних деталей і не можу спілкуватися з будь-яким органом на причина). У V8 розмір Smis є внутрішньою деталлю і фактично змінюється з часом і над версіями. На 32-бітних платформах, які раніше були випадками більшості, Smis завжди були 31-бітовими цілими підписами; на 64-бітних платформах вони раніше були 32-бітовими цілими числами, що останнім часом здавалося найпоширенішим випадком, поки в Chrome 80 ми не постачали "стискання вказівника" для 64-розрядних архітектур, для яких потрібно зменшити розмір Smi до 31 біта, відомого з 32-розрядних платформ. Якщо у вас трапилася реалізація на припущенні, що Smis, як правило, становить 32 біти, у вас виникнуть нещасні ситуації, наприкладце .

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

Чи можливо робити універсальне високоефективне програмування в Javascript, не використовуючи щось на зразок макросистеми для вбудовування таких речей, як vec.add () в callites?

"загальний", як правило, суперечить "високопродуктивним". Це не пов'язано з JavaScript або конкретними реалізаціями двигуна.

"Загальний" код означає, що рішення повинні прийматися під час виконання. Кожен раз, коли ви виконуєте функцію, код повинен запускатися, щоб визначити, скажімо, "це xціле число? Якщо так, перейдіть до цього кодового шляху. Чи xє рядок? Потім перестрибніть сюди. Це об'єкт? Чи є .valueOf? Ні? Потім?" можливо .toString()? Можливо, в її прототипі ланцюжок? Зателефонуйте це і перезавантажте спочатку з його результатом ". "Високопродуктивний" оптимізований код по суті побудований на ідеї відмовитись від усіх цих динамічних перевірок; це можливо лише тоді, коли двигун / компілятор має якийсь спосіб вивести типи достроково: якщо він може довести (або припустити з достатньо високою ймовірністю), що xзавжди буде цілим числом, тоді йому потрібно лише генерувати код для цього випадку ( охороняється за допомогою перевірки типу, якщо були задіяні недоведені припущення).

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

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

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

Так, це загальна проблема з JavaScript. V8 використовувався для внутрішньої реалізації певних вбудованих елементів (таких як Array.sort) у JavaScript, і ця проблема (яку ми називаємо "забрудненням зворотним зв'язком") була однією з головних причин, чому ми повністю віддалилися від цієї техніки.

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

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

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

Використання TypeScript може допомогти. Велике попередження про жирність: типи TypeScript спрямовані на продуктивність розробника, а не на продуктивність виконання (і як виявляється, ці дві перспективи мають дуже різні вимоги до типової системи). Однак, є певне перекриття: наприклад, якщо ви послідовно коментуєте речі number, компілятор TS попередить вас, якщо ви випадково помістили nullмасив або функцію, яка повинна містити / працювати лише на числах. Звичайно, все ж потрібна дисципліна: один number_func(random_object as number)люк для втечі може мовчки підірвати все, тому що правильність анотацій типу ніде не застосовується.

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

Чи є якісь хороші прості у використанні інструменти вимірювання для перевірки того, що працює двигун Javascript внутрішньо з типами?

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

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


2
Дякую за чудову відповідь, вона підтверджує багато моїх підозр щодо того, як все працює, і що важливо, як вони мають намір працювати. До речі, чи є повідомлення в блозі тощо про проблему "зворотного зв’язку", про яку ви згадали Array.sort()? Я хотів би прочитати трохи більше про це.
Joppy

Я не думаю, що ми спілкувались саме в цьому аспекті. Це, по суті, те, що ви самі описали у своєму запитанні: коли вбудовані файли реалізовані в JavaScript, вони є «як бібліотека» в тому сенсі, що якщо різні фрагменти коду називають їх різними типами, то продуктивність може постраждати - іноді просто небагато, іноді більше. Це не єдина і, мабуть, навіть не найбільша проблема з цією технікою; Я в основному просто хотів сказати, що я знайомий із загальним питанням.
jmrk
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.