Производительность JavaScript в 2021

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Меня зовут Шерстюк Юрий и за десять лет мне посчастливилось работать во многих командах, включая работу в Microsoft. Сейчас я участвую в разработке нескольких проектов в Intellias, а также постоянно выступаю с докладами и оказываю услуги консалтинга.

Данная статья может быть полезна инженерам, у которых не было времени разобраться в работе компиляторов и интерпретаторов. Также статья может заинтересовать тех, у кого JavaScript — первый язык программирования. Я буду поднимать вопросы Runtime производительности. Не DOM rendering, не Network latency, а именно Computing and Running JavaScript.

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

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

У меня есть особые чувства к JavaScript, поэтому и статья будет вокруг него. Хотя нет, также рассмотрим «Плюсы» в качестве антагониста всему, что происходит на скриптовых языках. Далее по тексту я намеренно буду упрощать некоторые темы, в ином случае, статья грозит стать по объему как одна из серии книг Толкина.

Что такое JavaScript

Простой пример — объявление переменной на JS и C++:

Всего одна строка кода, а сколько больших тем можно затронуть! Синтаксис тут не важен, а вот что действительно важно, так это отсутствие описания типа в JS. Более того, ту же переменную мы можем переопределить ниже в коде на абсолютно другой тип данных. Попробуй то же самое выполнить в C++. Если не брать некоторые эзотерические сценарии, то у тебя это не получится — компилятор упадет с ошибкой. Если инженер сам может указать тип данных, то он очень сильно упрощает написание компилятора/интерпретатора для такого языка. Почему? Потому что инженер прямо дает понять сколько нужно использовать физической памяти для данной переменной, но дело не только в этом.

Вот еще пример, но уже со ссылочным типом данных:

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

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

Начнем с процесса компиляции

Задача: получить из исходного файла с кодом — исполняемый файл, который непосредственно запускает программу.

По шагам:

  1. Код инженера, написанный в соответствии с синтаксисом языка, попадает в препроцессор.
  2. Препроцессор — это макропроцессор, который преобразовывает программу для дальнейшего компилирования. На данной стадии происходит работа с препроцессорными директивами.
  3. Компилятор (Ahead-of-Time) — преобразует полученный на предыдущем шаге код без директив в ассемблерный код. То есть, это промежуточный шаг между высокоуровневым языком и машинным кодом (бинарным кодом).
    Ассемблер, в свою очередь, преобразовывает ассемблерный код в машинный код, сохраняя его в объектном файле (промежуточный файл, хранящий кусок машинного кода).
  4. Компоновщик (линкер) — связывает все объектные файлы и статические библиотеки в единый исполняемый файл.
  5. Загрузчик — нужен для загрузки нашей программы в память. На данной стадии также возможна подгрузка динамических библиотек.

Теперь рассмотрим интерпретацию

Не все интерпретаторы работают одинаково, но часто это выглядит так:

  1. Код инженера, написанный в соответствии с синтаксисом языка, попадает в парсер.
  2. Парсер преобразовывает код в абстрактное синтаксическое дерево (структура данных, выглядящая как ориентированное дерево, где вершины — операторы языка программирования, а листья — операнды).
  3. Абстрактное синтаксическое дерево переходит в интерпретатор, который преобразовывает его в байт-код (не путай с бинарным кодом).
    Байт-код — компактное, низкоуровневое, промежуточное представление кода, которое похоже на ассемблерный код, но в отличие от него платформо-независимое, потому что выполняется не для конкретной архитектуры процессора, а внутри виртуальной машины, которая, в свою очередь, универсальна для большинства архитектур.
  4. И тут, внимание, снова компилятор!
    Но, в данном случае, это JIT компилятор. В отличии от AOT компилятора, он работает во время выполнения программы (Just-In-Time) и транслирует часто используемые фрагменты байт-кода в машинный код, применяя при этом различные оптимизации.
  5. На выходе мы получаем машинный код, который в отличие от компиляции в C++ может вернуться обратно в JIT компилятор для кэширования или проведения дополнительных оптимизаций.

Компиляцию JS выполняют движки и сейчас среди них есть три крупных игрока: V8, SpyderMonkey и JavaScriptCore (он же SquirrelFish):

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

По сути V8 является наиболее популярным движком на рынке, во многом из-за использования его в NodeJS, поэтому рассмотрим его работу:

То, что было представлено выше в схеме интерпретации языка повторяется. Тут важно отметить, что интерпретатор (Ignition) и компилятор (TurboFan) появились только в 2017 году, что стало большим прорывом. До этого компилятор работал не с байт-кодом, а на прямую с AST и это было очевидным бутылочным горлышком работы движка. Вот несколько преимуществ байт-кода над AST:

  • Байт-код занимает значительно меньший объем памяти.
  • Байт-код проще обрабатывать как более низкоуровневое представление, чем AST.

Сам TurboFan не менее прорывной и, в отличие от своих предшественников (Full-codegen и Crankshaft), он стал более модульным, что позволило вводить новые элементы из новых стандартов языка значительно легче.

Как насчет сборки мусора?

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

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

Фреймворки могут многое, но не все

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

Непрошеный совет — очень рекомендую ознакомиться с такими идеями как мономорфные операторы, Hidden Classes, Bit Fields, Bloom Filters и так далее. Все они имеют значительное применение в построении современных фреймворков и для лучшего их понимания, можно начать с доклада Maxim Koretskyi — A sneak peek into super optimized code in JS frameworks.

И пара слов о TypeScript...

Возможно, еще в первом примере данного текста ты подумал: «почему не использовать TS и закрыть вопрос с типизацией?». С одной стороны, ответ очевиден — TS является суперсетом JS и, в итоге, все равно будет преобразован в JS, поэтому для интерпретатора не будет особых отличий. Или отличия все-таки будут? У меня есть масса вопросов к TS, но нужно признать, что он стал очень популярен и, что более важно, он таки решает часть проблем, которые существуют в JS.

Пройдемся по положительным сторонам TS:

  • Кроме более строгой типизации, дает возможность использовать инструменты из более «взрослых» языков программирования (перегрузка функций, интерфейсы, дженерики и так далее).
  • Новые элементы стандарта языка, зачастую, сначала появляются в TS.
  • Когда TS-код преобразован в JS, он может быть более эффективным именно из-за того, что у TS-компилятора есть больше данных о типах (вспомни про мономорфные операторы, о которых я упоминал выше).
  • Большинство IDE умеют работать с TS, поэтому очень легко получать информацию о типах прямо при наведении на элемент кода.

Что в TS не совсем гладко:

  • Временами, компилятор TS выдает ошибки, которые сложно понять. От версии к версии TS, есть положительная динамика в этом вопросе.
  • Типы существуют только когда используется компилятор TS (AOT компиляция). Как только код уходит в Runtime, вся информация о типах теряется, потому что это уже JS и проверить динамически подгружаемые данные на типы мы не можем. Даже те типы, что были валидными в TS при компиляции, могут иметь совсем иные значения в Runtime и это большая проблема.

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

На самом деле, теория типов — еще одна прекрасная тема для изучения, на которую стоит обратить внимание. Если хочешь сочетать это с практикой, то можно остановиться на ReasonML (суперсет OCaml). Типы в нем богаче, чем в TS и его тоже можно скомпилировать в JS. В сочетании с React, работает отлично (конечно, писал один и тот же человек — Jordan Walke). Можно сказать, что к ReasonML у меня академический интерес, потому что далеко не в любой проект получится интегрировать его без костылей.

Вместо заключения

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

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

  • Используй статически типизированные языки/нотации/суперсеты, они ускорят работу кода и сделают его более стабильным.
  • Чем более иммутабельными будут данные, тем более предсказуемо будет выполняться код.
  • Упрости работу сборщику мусора, не создавай данные, на которые ссылается корневой объект. Хорошим тоном будет использование WeakMap и WeakSet, где это возможно.
  • Не пренебрегай инструментами профилирования. В JS они есть почти в любом Runtime.
  • Изучи алгоритмы и структуры данных, если еще этого не сделал. Это те знания, которые тебе пригодятся в любых IT технологиях.

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

P.S. Что почитать про компиляторы и интерпретаторы:

👍НравитсяПонравилось10
В избранноеВ избранном7
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Типы существуют только когда используется компилятор TS (AOT компиляция). Как только код уходит в Runtime, вся информация о типах теряется, потому что это уже JS и проверить динамически подгружаемые данные на типы мы не можем. Даже те типы, что были валидными в TS при компиляции, могут иметь совсем иные значения в Runtime и это большая проблема.

Я не сказав би, що це мінус TS, а скоріше його особливість, тому що сучасні браузери на жаль не вміють виконувати код, написаний на TS.
Якби вміли, то була б зовсім інша річ.
Та й автору було б не зайвим згадати про випадки, коли таке можливе:
1) Використання різних зовнішніх JS-бібліотек
2) Отримання даних ззовні (з темплейту або HTTP), коли справді в runtime типи можуть відрізнятися.

Назвать статью «Производительность JS» и вообще не дать ни трендов производительности ни показателей производительности а начать с сравнения JS с C++ (а почему не с C# / Java ведь они тоже компилируются в промежуточный язык который потом JITится) это как то ну очень странно имхо.
Надеюсь это только первая часть серии статей иначе выглядит «галопом по Эуропах».

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

Да было бы полезно и довольно интересно почитать про то как изменилась производительность JS в топ 3 наиболее популярных движках. Ну и как для меня.. человека далекого от JS инетесно было бы подчерпнуть информацию о неких правильных практиках кодирования для TypeScript которые дают прирост производительности JS т.е. списка DOs & DONTs с кратким пояснением почему именно DO или DONT. Возможно не в одной а в нескольких статьях.
Стиль написания у вас хороший и статья читается легко..

Типы существуют только когда используется компилятор TS (AOT компиляция). Как только код уходит в Runtime, вся информация о типах теряется, потому что это уже JS и проверить динамически подгружаемые данные на типы мы не можем. Даже те типы, что были валидными в TS при компиляции, могут иметь совсем иные значения в Runtime и это большая проблема.

А в рантайме нужна типизация? В C# все что дает типизация в рантайме — исключение.
Главная проблема того что нет типизации в JS, это что в рантайм нельзя рефлексировать на типы и на те же дженерики которые были указаны в статье, и нельзя проверять на соответствие интерфейсу.
Динамически типизируемые языки имеют больше гибкости.
В TS я могу под тип передать любой объект или интстанс класса который совпадает по сигнатуре с запрашиваемым типом без надобности явного наследования или использования миксинов, и это открывает куда больше возможностей

В C# все что дает типизация в рантайме — исключение.

еще ж полиморфизм...

В C# все что дает типизация в рантайме — исключение.

так-так
за виключенням такої дрібнички, як JIT або AOT компіляція

В C# все что дает типизация в рантайме — исключение.

Типизация даёт пользу на компиляции, огромное количество ошибок отсеивается. До рантайма доходит уже малая часть. Акцент именно на этом.
В случае интерпретатора пусть даже с JIT может оказаться, что то, что было ошибкой (например, вызов sendMessage вместо send_message) приведёт к тому, что не просто нельзя как-то сократить поиск метода — надо каждый раз проверять, а не появился ли он вдруг:)

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

Динамически типизируемые языки имеют больше гибкости.

И в 99% эта гибкость нафиг не нужна. Нужна в специально отведённых местах, и желательно, чтобы эти места были оборудованы.

Вот сравнение с C# не зря — там такие оборудованные места это, например, тип Variant. Ну и сам object с конверсией типов предоставляет такое, явную конверсию можно отследить по коду.

В TS я могу под тип передать любой объект или интстанс класса который совпадает по сигнатуре с запрашиваемым типом без надобности явного наследования или использования миксинов, и это открывает куда больше возможностей

И возможностей ошибиться — тоже. Вот у шприца с вкациной есть apply(), и у ядерной бомбы есть apply(). Ну чуть не то вызвали, долго китайцы понять не могли, что за грибок появился вдали ©.
Требование наследоваться от интерфейсов (Java с компанией) — порой слишком жёстко, но утиная типизация — обычно зло. Возможность явно пометить как реализацию какого-то свойства не через иерархию (обычно зовётся traits) — тут хороший компромисс, и вот для динамических языков он вполне хорош.

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

Подробнее можно почитать тут : bibliography.selflanguage.org/_static/pics.pdf

Вообще не понял о чём статья, о PR для Корецкого, ReasonML и еще пары ссылок.
Кажется в статье есть Введение и Конец, но отсутствует Контент.

Пройдемся по положительным сторонам TS:
дает возможность использовать инструменты из более «взрослых» языков программирования (перегрузка функций...

Перезагрузка функций нужна в статически типизированных языках, в динамических это третья нога, так что эта перезагрузка в TS сводится к исключительно вопросу документирования интерфейса функции для восприятия человеком, т.е по сути тот же JSDoc.
TS в принципе во многом ограничивает возможности JS- назвать это положительным моментом трудно. В нем не написать интерфейс вроде fn(...members: number, options: object), хотя в JS тут нет рамок. Конечно тут можно спорить о дизайне, но этот просто сферический кейс.
Для микрооптимизаций это еще одна черная коробка на пути получения байткода, которая по своей природе противопоказана. Изучать особенности оптимизирующих компиляторов, потом изучать особенности транслятора TS, чтобы понимать во что он превратит твой код текущая его версия, такое себе удовольствие. Имхо TS должен был быть мягким инструментом документирования и ни грамма не трогать код.

Временами, компилятор TS выдает ошибки, которые сложно понять. От версии к версии TS, есть положительная динамика в этом вопросе.

Такая положительная, что тайпинги того же axios не могут до ума довести уже месяц, целые дебаты, голосования, 10 раз реверты и 100500 issues на эту тему, ведь не компилится то у одних, то у других. TS таки упростил жизнь :)

вся информация о типах теряется, потому что это уже JS и проверить динамически подгружаемые данные на типы мы не можем

Проблема таки решаема, если захотят, то явно не проблема запилить. Ассерты с JSDoc’а есть. Но из всей метаинформации о типах описанных в TS 0.5-2% бы использовалась в рантайм, остальная ни на что не влияет в продакшене, а только как ассерты для отладки в dev mode.

По GC могу посоветовать вот такой доклад
www.youtube.com/watch?v=-NpUG8OJSf8

Там еще интересно про memory fragmentation

PS подход с reference counting актуален и для php к примеру (zval)

Стаття про перф і жодного бенчмарка?

Подписаться на комментарии