Как работает JS Engine, и советы по производительности!

Эта проблема

Вы буквально не можете купить более быстрые серверы, чтобы повысить производительность вашего клиентского приложения. Я имею в виду, что вы можете купить всем своим клиентам более быстрые компьютеры, я полагаю !! 🤑🤑🤑
Много времени и энергии тратится на сжатие ресурсов, удаление запросов и уменьшение задержки, но как насчет того, чтобы приложение было запущено? В большинстве случаев главным виновником является синтаксический анализ и компиляция.

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

Это отмечено тестом на моей 16-дюймовой модели Macbook Pro 2019 года. Но не у всех, кто обращается к странице, есть устройства высокого класса. Подумайте о мобильных пользователях среднего класса с подключением к 4G (я полагаю, что это подавляющее большинство пользователей), которые будут посещать веб-сайты. Обработка JavaScript в современных смартфонах среднего класса в среднем составляет 0,75 МБ / с.

Согласно опросу, проведенному сайтом websiteoptimization.com, среднестатистический пользователь больше не будет ждать загрузки веб-страницы более 8–10 секунд. А пользователь широкополосного доступа ожидает, что он будет загружаться еще быстрее.

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

Золотые правила исполнения

  • На выполнение меньшего количества задач нужно меньше времени. Каждый раз, когда мы делаем меньше задач, это будет быстрее. Еще лучше ничего не делать.
  • Если вы можете сделать это позже, сделайте это позже. Лучше делать что-то позже, чем делать это сейчас.

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

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

Давайте разберемся с движком JavaScript

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

Путь переводчика

Интерпретатор использует концепцию под названием REPL - read-eval-print-loop. Настоящее преимущество этого метода - немедленный вывод и простота реализации. Но это связано со скоростью выполнения:
- Eval работает медленно. Он не использует скорость машинного кода.
- Невозможно оптимизировать код во всей программе.

Итак, представьте, что у вас есть цикл, повторяющийся N раз, в котором вы вызываете функцию F, интерпретатор в конечном итоге выполнит все строки кода в функции F все N раз, что означает ненужную переоценку N-1 раз.

Компилятор

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

Но у этого есть минус. Есть догадки ?? У него будет медленный старт. Какой же путь выбрать, если вы создадите свой собственный компилятор?
На самом деле ничего не нравится, правда? Слава богу, у нас есть 3-я версия компилятора, которая использует лучшее из двух миров. Да, гуру JS угадали!

Способ JIT-компилятора 😎🤘

JIT означает Just-In-Time. Итак, JIT начинается с интерпретации, но отслеживает горячие коды, которые запускаются несколько раз, а также горячие коды что пробегает больше раз.

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

Компилятор JIT также требует выделения памяти во время интерпретации, потому что это ключ к отслеживанию вашего кода. Но теперь у нас есть ГБ ОЗУ, так что это не проблема для обычных веб-приложений.

Двигатель V8 (не спортивный!)

Существует множество движков JavaScript - разных для разных браузеров и поставщиков. Я не собираюсь касаться всех остальных движков в этой статье, но, возможно, смогу сделать общее погружение в архитектуру в следующих статьях (так что следите за обновлениями и следите за мной). Но обратите внимание, что большая часть обсуждаемых здесь оптимизаций более или менее применима ко всем другим поставщикам. Вот некоторые из других известных движков JavaScript - Rhino, SpiderMonkey, JavaScriptCore, Chakra и Nashorn и это лишь некоторые из них.

Давайте погрузимся в архитектуру V8.

Итак, есть вещь под названием облако. Когда мы создаем веб-пакет, он сохраняется в облаке, а наш js-код обслуживается из облака, когда пользователь запрашивает его. Как он дошел до облака? Это вопрос для тех, кто занимается бэкендом 😜
Теперь облако отправляет нам файл JavaScript, который, по сути, представляет собой мусор, и теперь, чтобы разобраться, он проходит через синтаксический анализатор который анализирует файл JavaScript и преобразует его в AST (абстрактное синтаксическое дерево). Вы можете думать об AST как о структуре данных, которая представляет то, что на самом деле означает этот код.
Теперь компиляторы V8 берут на себя остальную работу. Итак, первым шагом является интерпретатор, который интерпретирует код и идентифицирует горячие точки, о которых я упоминал ранее, и генерирует полуоптимизированный байт-код. Любой код, который можно оптимизировать, затем передается оптимизирующему компилятору. Затем оптимизирующий компилятор анализирует код и делает предположения, чтобы сделать его еще быстрее. Оптимизирующий компилятор генерирует высокооптимизированный машинный код, но мы обсуждали, что иногда он должен деоптимизироваться во время выполнения и вернуться к байтовому коду. Снимаю шляпу перед названием команды V8, они действительно знают, как назвать свои двигатели. Интерпретатор, который генерирует байт-код, называется Ignition (да, зажигание автомобиля, то есть запуск), а оптимизирующий компилятор называется TurboFan (турбо-ускорение, которое ускоряет автомобиль. ).

Как оптимизировать сейчас?

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

Три вещи, которые Engine делает, чтобы помочь нам

  • Спекулятивная оптимизация
  • Скрытые классы для динамического поиска
  • Встраивание функций

Давайте поиграем с кодом, чтобы понять концепции!

Методы оптимизации

Работа с типами, которые согласованно совместимы с операциями

Я написал простой алгоритм для вычисления k-го простого числа и нахожу 30 000-е простое число с помощью этого кода.

Чтобы рассчитать его на моем Macbook Pro, потребовалось немного больше 3 секунд.

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

for (let i = 0; i < len; ++i) {

Теперь давайте проверим это!

Вот ссылка jsbench для всех вас, ленивых, которые не пробуют на своем компьютере.

Увидеть разницу? Просто повторение меньшего значения имеет огромное значение. Теперь наш код стал ~ 160% быстрее! Так что же произошло под капотом? Прежде всего следует отметить, что JavaScript не жалуется на тип undefined. Поэтому всегда с последним элементом итерации он хочет выполнить операцию по модулю с undefined, следовательно, компилятор, который оптимизировал наш код для работы с небольшими целыми числами (V8 называет это SMI), теперь нужно изменить трек и перейти к деоптимизированному компилятору, который требует времени и выполняет по модулю undefined для получения NaN, который оценивает условие как false. На самом деле это нематематическая операция и требует времени в C ++. Следовательно, требуется больше времени. Теперь вы знаете, что делать - избегайте математических вычислений с нематематическими типами!

Оптимизация грамматики

Теперь давайте взглянем на другой аспект оптимизации. Угадайте, какой из них будет быстрее выполняться? Линия-1 или Линия-3

Вот что действительно зависит от этого. Но логически можно сказать, что Line-3 быстрее. Причина того, что { в грамматике JavaScript означает множество вещей, таких как запуск функции, начало объекта, начало блока, начало класса и многое другое. Таким образом, при его синтаксическом анализе компилятор должен пойти дальше и проанализировать больше, чтобы определить, что это на самом деле. Но в случае с JSON.parse() всегда нужно возражать! Таким образом, синтаксический анализатор знает, что это объект javascript, когда встречает {. Но эй, все в JavaScript является объектом, поэтому компилятор сильно оптимизирован для определения объектов, и, следовательно, строка-1 быстрее в общих случаях. Но вот загвоздка - когда объем данных начинает увеличиваться, например, 10 КБ или более, JSON.parse() начинает опережать скорость.

Спекулятивная оптимизация

Просмотрите код, игнорируйте performance часть, предназначенную для измерения производительности кода для вывода. ПРИМЕЧАНИЕ. Я использую узел v13.9.0

Когда вы запускаете код, он выводит как-

Выполнение заняло всего около 14,27 миллисекунд. Но если вы раскомментируете строку 15 и повторно запустите, вы будете удивлены, увидев результаты.

add(numA, '5') // add this line to code in line 15

Теперь прошло 52,63 миллисекунды. Давайте поиграем с некоторыми флагами V8, чтобы понять. Я собираюсь представить два флага --trace-opt и --trace-deopt, которые будут отслеживать оптимизацию и деоптимизацию, выполняемую движком. Теперь перезапустите программу с этими флагами -
node --trace-opt --trace-deopt speculative-optimization.js
Вы найдете кучу дампов в консоли. Но интересующий нас метод - это add, поэтому на выходе сделайте grep add -
node --trace-opt --trace-deopt speculative-optimization.js | grep add

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

Теперь давайте попробуем удалить все оптимизации, которые V8 делает для нас, и снова попробуем запустить код! Я только что написал в код удобную внутреннюю функцию V8, и теперь она выглядит так:

Теперь запустите код с внутренним флагом V8 --allow-natives-syntax как
node --allow-natives-syntax speculative-optimization-never-optimize.js

Уууу, сейчас ~ 230 мс! Для тех, кто думает, что это могло быть связано с дополнительным объявлением функции, см. Вывод, закомментировав строку 15

// neverOptimizeFunction(add)

Теперь посмотрим на разницу в выходе

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

Теперь просто запустите код до
node --allow-natives-syntax --trace-opt no-free-optimization.js

Есть только вывод производительности без сообщения об оптимизации.

Теперь снова попробуйте раскомментировать строку 15 (черт возьми, что-то со строкой 15 каждый раз)

optimizeFunctionOnNextCall(add) // add in line 15

Теперь мы получаем сообщение об оптимизации.

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

Итак, что мы можем сделать? По возможности передавайте фиксированный тип в функцию или просто используйте TypeScript. (Что ж, пора принять TypeScript)

Оптимизация для скрытого класса

Скрытый класс - это не функция JavaScript, это функция V8. Каждый объект / примитивы, которые вы определяете, отображаются на определенный скрытый класс. Мы, как программисты, не должны изменять структуру объекта, потому что она создает новый скрытый класс каждый раз, когда свойство добавляется или удаляется. В конце концов, весь наш код javascript выполняется кодом C ++, а C ++ не имеет концепции объектов, таких как javascript, поэтому требует затрат на изменение формы объекта. Возьмем пример -

При запуске он производит вывод

Для запуска программы потребовалось около 2,7 секунды. И снова раскомментируйте строку 15.

Это заняло 7 секунд. Но не должно ли JSON.stringify() работать теперь меньше? Но почему так? Это только потому, что мы изменяем скрытый класс объекта shape, удаляя свойство. Скрытый класс также зависит от последовательности добавленных свойств в объекте, если последовательность одинакова, то оба объекта сопоставляются с одним и тем же скрытым классом. Операции, выполняемые с объектами, также зависят от морфизма объекта.
Морфизм объектов может быть трех типов
- Мономорфный: все объекты имеют одну и ту же форму или скрытый класс, и компилятор может работать хорошо.
- Полиморфный: компилятор обнаружил несколько фигур и определил их из списка и провел оптимизацию для них.
- Мегаморфизм: компилятор видел много форм и не особо специализирован. Следовательно, оптимизация производиться не будет.

Теперь позвольте мне представить новый внутренний метод V8:
%HaveSameMap(arg1, arg2)
Этот метод, как вы уже догадались, сообщает о том, принадлежат ли оба переданных объекта к одному и тому же скрытому классу или нет. А теперь давайте посмотрим на интересные результаты! Надеюсь, теперь все станет немного яснее.

Если вы хотите поэкспериментировать, измените значения и запустите код с флагом --allow-natives-syntax.

Надеюсь, это дало вам хорошее представление о скрытом классе, вы можете прочитать больше здесь.

Оптимизация объема работ и прототипов

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

Это простая программа для вычисления периметра треугольника в предположении, что треугольник действителен. Выполнение этого занимает около ~ 3,87 секунды.

Как вы думаете, сколько времени займет перемещение класса Triangle в верхнюю область видимости? Есть предположения? Хорошо, конечно, это сделает код быстрым. Насколько быстро? 7 %? 70%? 700%? 7000%? 70 000% ?????

70 000% - это немного преувеличение 😅. Но правда в том, что моя машина на 69 936% быстрее. На моей машине это заняло ~ 5,54 мс. Не верьте мне, проверьте на своем компьютере!

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

%HaveSameMap() мне на помощь!

На выходе он производит false. Так что здесь происходит каждый раз, когда создается новый объект с новым прототипом. Он не имеет ссылки на тот же класс Triangle и, следовательно, не может быть оптимизирован. Тем не менее, если вы не верите, вы можете попробовать создать новый класс, скажем, Point, просто создайте его в тестовом методе и позвольте времени решить вашу гипотезу! Он действительно увеличивается, но не так сильно, как вы думаете!

Встраивание функций

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

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

Надеюсь, это имеет смысл и побуждает вас сосредоточиться на удобочитаемости.

Ключевые выводы

  • Самый простой способ сократить время синтаксического анализа, компиляции и выполнения - отправить меньше кода.
  • Используйте User Timing API (тот, который я использовал, он есть даже в браузере!), Чтобы определить, где больше всего ущерба.
  • Возможно, подумайте об использовании системы типов, чтобы вам не пришлось запоминать все то, о чем я только что написал.

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



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

Вы можете найти меня в LinkedIn Facebook Instagram или написать мне по электронной почте [email protected].

Отзывы и предложения приветствуются. Увидимся на следующей неделе!

[БОНУС]

Если вы хотите узнать больше об оптимизации именно в массиве, то читайте эту статью.

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

Правки

[20 апреля 2020 г.]

Спасибо, Абхас Бхаттачарья и Ашиш Мишра за ваши предложения, очень признательны!

  • Улучшения языка и грамматики

использованная литература