TL; DR Більш повільний цикл пов'язаний з доступом до масиву "поза межами", який або змушує двигун перекомпілювати функцію з меншими або навіть відсутніми оптимізаціями АБО не скомпілювати функцію з будь-якої з цих оптимізацій для початку ( якщо компілятор (JIT-) виявив / запідозрив цей стан до першої "версії" компіляції), читайте нижче чому;
Хто - то
повинен сказати , що це (зовсім здивований вже ніхто не робив):
Там раніше був час , коли фрагмент коду в OP був би де-факто приклад НАЧИНАЮЩИХ програмування книги , призначені для начерків / підкреслити , що «масиви» в JavaScript індексуються Відправною в 0, а не 1, і як такий можна використовувати як приклад поширеної «помилки початківців» (не любите ви, як я уникнув фразу «помилка програмування»
;)
):
поза межами масиву доступу .
Приклад 1:
a Dense Array
(будучи суміжним (означає відсутність проміжків між індексами) ТА фактично елементом у кожному індексі) з 5 елементів, використовуючи індексацію на основі 0 (завжди в ES262).
var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
// indexes are: 0 , 1 , 2 , 3 , 4 // there is NO index number 5
Таким чином, ми насправді не говоримо про різницю продуктивності між <
vs <=
(або «однією додатковою ітерацією»), але ми говоримо:
«чому правильний фрагмент (b) працює швидше, ніж помилковий фрагмент (a)»?
Відповідь є дворазовою (хоча з точки зору реалізатора мови ES262 обидва є формами оптимізації):
- Представлення даних: як представити / зберігати масив внутрішньо в пам'яті (об'єкт, хешмап, 'реальний' числовий масив тощо)
- Функціональний машин-код: як скласти код, який отримує доступ / обробляє (читає / змінює) ці "масиви"
Пункт 1 достатньо (і правильно IMHO) пояснюється прийнятою відповіддю , але це витрачає лише 2 слова ("код") на пункт 2: складання .
Точніше: JIT-компіляція і ще важливіше JIT- RE -компіляція!
Специфікація мови - це лише опис набору алгоритмів ("кроки для виконання визначеного кінцевого результату"). Що, як виявляється, дуже красивий спосіб описати мову. І він залишає фактичний метод, який використовує двигун для досягнення визначених результатів, відкритим для реалізаторів, даючи широкі можливості розробити ефективніші способи отримання визначених результатів. Двигун, що відповідає специфікації, повинен давати результати відповідності специфікації для будь-якого визначеного входу.
Тепер, зростаючи код JavaScript / бібліотеки / використання та пам’ятаючи, скільки ресурсів (часу / пам'яті / тощо) використовує «справжній» компілятор, зрозуміло, що ми не можемо змусити користувачів, які відвідують веб-сторінку, чекати так довго (і вимагати їх мати так багато ресурсів).
Уявіть таку просту функцію:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
Ідеально зрозуміло, правда? Ніякого додаткового уточнення не потрібно, правда? Тип повернення є Number
, правда?
Ну .. ні, ні і ні ... Це залежить від того, який аргумент ви передаєте для названого параметра функції arr
...
sum('abcde'); // String('0abcde')
sum([1,2,3]); // Number(6)
sum([1,,3]); // Number(NaN)
sum(['1',,3]); // String('01undefined3')
sum([1,,'3']); // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]); // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]); // Number(8)
Бачите проблему? Тоді вважайте, що це ледве вискоблювання можливих масивних перестановок ... Ми навіть не знаємо, який тип ВИДАЄТЬСЯ функцію, поки ми не виконаємо ...
Тепер уявіть, що цей самий функціональний код фактично використовується для різних типів або навіть варіацій введення, як повністю дослівно (у вихідному коді), описаних, так і динамічно вбудованих у програму "масивів".
Таким чином, якщо ви збирали функцію sum
JUST ONCE, то єдиний спосіб, який завжди повертає визначений специфікацією результат для будь-якого і всіх типів введення, тоді, очевидно, лише виконуючи ВСІ призначені специфікою основні І під кроки, може гарантувати відповідність специфікації результатам (як-от неназваний веб-переглядач pre-y2k). Не залишається жодних оптимізацій (бо немає припущень) і мертвої повільної інтерпретованої мови сценаріїв.
JIT-компіляція (JIT як в Just In Time) - це поточне популярне рішення.
Отже, ви починаєте компілювати функцію, використовуючи припущення щодо того, що вона робить, повертає та приймає.
ви придумали як можна простіші перевірки, щоб виявити, чи може функція почати повертати невідповідні результати (наприклад, тому, що вона отримує несподіваний ввід). Потім відкиньте попередній складений результат і перекомпілюйте на щось більш складне, вирішіть, що робити з частковим результатом, який у вас уже є (чи дійсно вам довіряти чи обчислити ще раз), зв’яжіть функцію назад у програму та спробуйте ще раз. У кінцевому рахунку відступає до поетапної інтерпретації сценарію, як у специфікації.
На все це потрібен час!
Усі браузери працюють на своїх двигунах, і для кожної під-версії ви побачите, що все покращується та змінюється. Струни були в якийсь момент історії справді незмінними струнами (отже, array.join був швидшим, ніж конкатенація струн), тепер ми використовуємо канати (або подібні), які полегшують проблему. Обидва повертають результати, що відповідають специфікаціям, і ось що важливо!
Короткий виклад короткого оповідання: тільки те, що семантика мови JavaScript часто повертається назад (як, наприклад, ця мовчазна помилка в прикладі ОП), не означає, що "дурні" помилки збільшують наші шанси компілятора виплюнути швидкий машинний код. Він передбачає, що ми написали правильні вказівки "зазвичай": поточна мантра, яку ми повинні "користувачі" (мови програмування), це: допомогти компілятору, описати, що ми хочемо, вподобати загальні ідіоми (прийміть підказки з asm.js для базового розуміння які браузери можна спробувати оптимізувати і чому).
Зважаючи на це, говорити про продуктивність є важливим, Але ТАКОЖ мінним полем (і через вказане мінне поле я дуже хочу закінчити вказівкою на (і цитуванням) якогось відповідного матеріалу:
Доступ до неіснуючих властивостей об'єкта та елементів масиву поза межами діапазону повертає undefined
значення замість підвищення винятку. Ці динамічні функції роблять програмування в JavaScript зручним, але вони також ускладнюють компіляцію JavaScript в ефективний машинний код.
...
Важливою умовою ефективної оптимізації JIT є те, що програмісти систематично використовують динамічні функції JavaScript. Наприклад, компілятори JIT використовують той факт, що властивості об'єктів часто додаються до об'єктів певного типу в певному порядку або що до доступу до масивів поза межами діапазону трапляються рідко. Компілятори JIT використовують ці припущення щодо регулярності для створення ефективного машинного коду під час виконання. Якщо блок коду задовольняє припущенням, механізм JavaScript виконує ефективний, генерований машинний код. В іншому випадку двигун повинен повернутися назад до повільнішого коду або до інтерпретації програми.
Джерело:
"JITProf: Pinpointing JIT-непривітний JavaScript-код"
Публікація в Берклі, 2014 р., Лян Гонг, Майкл Прадель, сенатор Кушик
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (також не подобається вихід із обмеженого масиву):
Попередня компіляція
Оскільки asm.js є суворим набором JavaScript, ця специфікація визначає лише логіку перевірки - семантика виконання - просто те, що JavaScript. Однак перевірений asm.js піддається попередній компіляції (AOT). Більше того, код, сформований компілятором AOT, може бути досить ефективним, включаючи:
- незмінені подання цілих чисел і чисел з плаваючою комою;
- відсутність перевірок типу виконання;
- відсутність збору сміття; і
- ефективні нагромадження купи та сховища (стратегії впровадження залежать від платформи).
Код, який не вдалося перевірити, повинен повернутися до виконання традиційними засобами, наприклад, інтерпретацією та / або складанням щойно вчасно (JIT).
http://asmjs.org/spec/latest/
і нарешті https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
чи був невеликий підрозділ про внутрішні покращення працездатності двигуна при усуненні меж- чек (у той час як лише підняття межі перевірки за межі циклу вже покращило 40%).
EDIT:
зауважте, що багато джерел говорять про різні рівні JIT-рекомпіляції аж до інтерпретації.
Теоретичний приклад, що ґрунтується на вищенаведеній інформації, щодо фрагменту роботи ОП:
- Дзвінок на isPrimeDivisible
- Компілювати isPrimeDivisible, використовуючи загальні припущення (як, наприклад, відсутність доступу поза межами)
- Робити роботу
- BAM, раптом масив має доступ за межі (прямо в кінці).
- Лайно, каже двигун, давайте перекомпілюємо isPrimeDivisible, використовуючи різні (менші) припущення, і цей приклад двигуна не намагається зрозуміти, чи може він використовувати повторно поточний частковий результат,
- Перерахуйте всю роботу, використовуючи більш повільну функцію (сподіваємось, вона закінчиться, інакше повторіть і на цей раз просто інтерпретуйте код).
- Повернення результату
Отже, час був:
Перший запуск (не вдалося в кінці) + виконувати всю роботу знову, використовуючи повільніший машинний код для кожної ітерації + перекомпіляція тощо. В цьому теоретичному прикладі явно потрібно> 2 рази довше !
EDIT 2: (відмова: домисли, засновані на фактах, наведених нижче)
Чим більше я думаю про це, тим більше я думаю, що ця відповідь може насправді пояснити більш домінуючу причину цього "штрафу" за помилковий фрагмент a (або бонус за ефективність на фрагменті b , залежно від того, як ви це думаєте), саме тому я прихильний називати це (фрагмент а) помилкою програмування:
Дуже привабливо вважати, що this.primes
це чистий числовий масив, чистий чисельний
- Жорстко закодований літерал у вихідному коді (відомий відмінний кандидат стати "справжнім" масивом, оскільки все вже відомо компілятору до часу компіляції) АБО
- швидше за все, генерується за допомогою числової функції, що заповнює попередньо розмір (
new Array(/*size value*/)
) у порядку зростання (інший давно відомий кандидат стати "справжнім" масивом).
Ми також знаємо, що primes
довжина масиву кешована як prime_count
! (із зазначенням його наміру та фіксованого розміру).
Ми також знаємо, що більшість двигунів спочатку передають масиви як модифікацію копіювання (коли це потрібно), що робить обробку їх набагато швидшими (якщо ви їх не змінюєте).
Тому доцільно припустити, що Array primes
- це, швидше за все, вже оптимізований масив, який не змінюється після створення (просто знати для компілятора, якщо немає коду, що модифікує масив після створення), а тому вже є (якщо застосовно до двигун) зберігається оптимізованим способом, майже так, як ніби це було Typed Array
.
Як я намагався зрозуміти на sum
прикладі своєї функції, аргументи (аргументи), які передаються, сильно впливають на те, що насправді має відбутися, і як такий, як саме цей код збирається в машинний код. Перехід String
до sum
функції не повинен змінювати рядок, а змінювати те, як функція компілюється JIT! При передачі масиву sum
слід скласти іншу (можливо, навіть додаткову для цього типу або "форму", як вони називають, версію машинного коду).
Як здається, злегка бонус перетворити масив, схожий на Typed_Array, primes
на льоту, в something_else, тоді як компілятор знає, що ця функція навіть не збирається її змінювати!
Згідно з цими припущеннями, що залишає 2 варіанти:
- Скомпілюйте як число-розбивач, припускаючи, що немає вихідних меж, в кінці виникають проблеми поза межами, перекомпілюйте та повторіть роботу (як зазначено в теоретичному прикладі в редакції 1 вище)
- Компілятор вже виявив (або підозрює?) Поза обмеженими доступом передовий доступ, а функція була JIT-Compiled так, ніби переданий аргумент був розрідженим об'єктом, що призводить до уповільнення функціонального машинного коду (оскільки це матиме більше перевірок / перетворень / примусів тощо). Іншими словами: функція ніколи не піддавалася певним оптимізаціям, вона була складена так, ніби вона отримала аргумент "розріджений масив" (- як).
Мені зараз справді цікаво, хто з цих 2 це!
<=
та<
однакова, як теоретично, так і фактично впроваджена у всіх сучасних процесорах (і перекладачах).