Компиляция и интерпретация в современном JIT. Как понимание работы JIT помогает писать код чище, а движку исполнять его быстрее
Привет, мы Вячеслав Орлов и Евгений Васильцов — Node.js разработчики в команде ITOMYCH STUDIO. У нас за плечами не один год работы с Node и еще больше — в бекенде. По итогам множества интервью с кандидатами мы пришли к пониманию, что уровень разработчика сильно зависит от знания инструментов, с которыми он работает.
Бывает, на собеседованиях в ITOMYCH мы спрашиваем: «JavaScript — интерпретируемый или компилируемый язык?». Ответы слышим разные, в различной степени обоснованные. По ним мы видим, насколько глубоко человек знаком с инструментом и понимает ли, как выполняется его код.
Действительно, JavaScript чаще всего поставляется в исходном виде. Можно написать код и перенести на любое устройство. При наличии JS-движка он выполнит программу, если код корректен. А компилируемые языки требуют предварительной компиляции. Только получив бинарный файл, оптимизированный под целевую систему, можно его выполнить.
То есть, на первый взгляд ответ очевиден: JavaScript интерпретируется. Так и было поначалу.
На заре существования JS использовался для создания относительно простых сайтов. Скорости интерпретации «на лету» хватало для удовлетворительного отображения. Но появились движки для выполнения кода на сервере и десктопе, JavaScript проник в многие сферы разработки, а сегодняшние web-страницы превратились в сложные системы с мегабайтами кода.
Вместе с задачами эволюционировал и JS. У движков, на которых он выполняется сегодня, сложная структура и множество оптимизаций. Они используют JIT компиляцию. Она перевернула понимание скорости работы JavaScript. JIT компилятор — потрясающий инструмент, в v8 он компилирует байт-код после предварительной работы интерпретатора и хранит результат компиляции в кеше, переиспользуя его при последующих обращениях.
В идеальных условиях сочетание интерпретатора с JIT обеспечивает скорость выполнения кода, сравнимую с компилируемыми языками. Но это в идеальных условиях. Как мы покажем дальше, понимание работы JIT поможет писать код чище, а движку исполнять его быстрее.
Преимущества и недостатки JIT компиляции
У каждой технологии есть преимущества и недостатки.
Преимущества
➕ JIT кеширует результаты компиляции и старается использовать оптимизации повторно при каждом выполнении кода. Благодаря этому скорость выполнения кода в jit может во много раз превышать скорость чистой интерпретации.
➕ JIT использует больше информации о машине, состоянии флагов и способах использования кода. Это значительно повышает качество и скорость оптимизации.
➕ Система собирает статистику о том, как программа на самом деле работает в среде, где находится, и ее можно перегруппировать и перекомпилировать для достижения оптимальной производительности.
➕ Код можно оптимизировать во время его работы.
➕ JIT использует различные уровни оптимизации, подбирая для вашего кода оптимальную.
Недостатки
➖ Может ощущаться небольшая задержка при первоначальном запуске приложения как следствие расхода времени на загрузку и компиляцию байткода.
➖ JIT вводит накладные расходы на память, связанные с хранением оптимизированного машинного кода.
➖ Циклы оптимизации и деоптимизации дорогостоящие.
Javascript-движки
Чтобы компьютер понимал JavaScript-код, его исполняют с помощью движка. Самый известный — V8, разработанный Google. Он используется в Chrome и Node.js.
Но на самом деле существует много других движков. Вот список самых популярных.
- SpiderMonkey разработан Mozilla. Он предназначен для встраивания в другие приложения, которые предоставляют среду хоста для JavaScript. Используются в Mozilla Firefox, Thunderbird, MongoDB, CouchDB, Adobe Acrobat, Adobe Reader, Adobe Flash Professional, GNOME (среда рабочего стола).
- JavaScriptCore — встроенный движок для WebKit, разработанный компанией Apple. WebKit используется в Safari, в почтовом клиенте Apple Mail и в App Store.
- JerryScript — легковесный движок, предназначенный для работы на очень ограниченных устройствах, например, микроконтроллеры. Он работает и на устройствах с менее 64 КБ оперативной памяти и менее 200 КБ флэш-памяти.
- V8 от Google — наиболее используемый движок. Он используется в Google Chrome и других веб-браузерах на основе Chromium (Brave, Opera, Vivaldi и Microsoft Edge), Couchbase (сервер базы данных), Deno (среда выполнения для JS и typescript), Electron (среда desktop приложений), MarkLogic (сервер баз данных), NativeScript (платформа мобильных приложений), Node.js (среда выполнения для JS), Qt Quick (для создания настраиваемых, высокодинамичных графических пользовательских интерфейсов с плавными переходами и эффектами).
Компиляция на примере V8 в Node.js
Как работает JS-движок? Разберем на примере V8. С ним мы работаем чаще всего, его считают одним из самых совершенных. В Node.js он показывает выдающиеся результаты в быстродействии, сравнимые с компилируемыми языками. Постараемся отобразить пошаговый процесс выполнения кода.
Первым делом за код берется парсер и преобразует его в список токенов. Из токенов формируется синтаксическое дерево AST, как представлено на рисунке ниже.
Парсер экономит время и проводит «ленивый синтаксический анализ». В отличие от полного анализа, при первом проходе составляется только дерево сигнатур существующих функций, проверяются синтаксические ошибки. Но область видимости переменных внутри функции и AST дерево для нее собираются уже при полном анализе, который проводится только при непосредственном вызове функции.
Далее за синтаксическое дерево берется интерпретатор Ignition. С помощью хеш-таблиц синтаксическое дерево преобразуется в байткод, собираются типы данных для последующей компиляции. Байткод — это платформонезависимая абстракция на ассемблер. Он не оптимизирован под текущую систему и непонятен ей, зато компилятор может быстро оптимизировать его на любом устройстве.
Байткод требует меньше затрат памяти от процессора, чем AST, и упрощает компиляцию на слабых устройствах. Как компилятор, так и интерпретатор далее используют байткод в роли источника информации об исходном коде. В дальнейшем все операции компилятор проводит уже с ним.
Jit компилятор TurboFan не просто оптимизирует наш байткод под текущую систему. Он использует инлайн-кеш для хранения результатов оптимизации. Впоследствии, кеш используется в интерпретаторе Ignition для интерпретаций и в самом TurboFan для спекулятивной оптимизации.
Оптимизация называется спекулятивной, потому что компилятор делает предположения на основе того, какие типы и скрытые классы он получал в функцию до этого. Он готовит оптимизированный код под эти типы, используя самый быстрый и оптимизированный кеш.
Но что будет, если поступает байткод с другим набором типов для той же функции?
Компилятор начинает деоптимизацию. На этот случай в кеше хранится менее оптимизированный код, способный обрабатывать различные входящие типы для операций. Обработка характеризуется избыточностью и рассчитана на высокоуровневые входные данные. Так что компиляция происходит дольше. Компилятор хранит несколько состояний кеша и после вынужденной деоптимизации запоминает, что эта операция может использовать неожиданные сочетания типов.
В дальнейшем к таким операциям компилятор изначально будет относиться осторожнее: для них он будет выбирать более высокоуровневый кеш.
Когда для операций на вход приходят одни и те же типы, код называется мономорфным. Он исполняется быстрее и с меньшими затратами, чем код с непредсказуемой типизацией. Это касается не только примитивных типов, но и объектов. Для объекта устанавливается специальный скрытый класс (hidden class).
Проблема в том, что в js у объектов нет класса в строгом понимании или схемы, которая могла бы предотвратить их динамическое изменение. Скрытые классы же имеют строгую структуру. К примеру эти два объекта преобразуются в два разных скрытых класса.
Мономорфности в коде нелегко придерживаться. Но мы можем постараться, чтобы наш код реже отправлялся на деоптимизацию и компилятор обрабатывал его с максимальной возможной скоростью.
Мономорфный и полиморфный код
Одна из самых ярких сторон JavaScript — его динамичность. Она позволяет писать код, не задумываясь о типах, и обновлять объекты на лету. Простота и динамичность сделали JS популярным языком. Но это создает множество проблем для нашего компилятора.
Встроенное кеширование имеет несколько состояний.
1. Неинициализированный кеш — свойство еще ни разу не компилировалось.
2. Предмономорфное — свойство прошло этап компиляции и кеш готов к использованию.
3. Мономорфное — кеш использован и у свойства постоянно один и тот же тип.
4. Полиморфное — свойство имело от одного до четырех дополнительных типов.
5. Мегаморфное — свойство имело слишком много типов.
Чем дальше мы отходим от мономорфности, тем больше времени понадобится JIT компилятору для обработки кода. Как увеличить количество мономорфного кода в приложениях?
Наша команда старается придерживаться принципов, которые могут ускорить выполнение и вашего кода.
Объявлять все свойства при создании объекта
Смысл в том, чтобы сразу определить объект со всеми свойствами. У двух объектов ниже разные скрытые классы, хотя для JS это одинаковые объекты.
Эти объекты получат разные скрытые классы, и функция, получающая их как атрибут, будет подвержена деоптимизации. Поэтому лучше определить все свойства объекта сразу. Также полезно определять свойства через конструктор.
Используя конструктор, мы перестраховываемся, что свойства будут объявлены не только все и сразу, но и в нужном порядке. Это позволяет избежать появления лишних скрытых классов.
Сохранять постоянными аргументы функций
Компилятор ожидает одни и те же типы данных в роли аргументов функции при каждом ее вызове. При получении нового типа данных происходит деоптимизация и функция переходит в полиморфное состояние, а при увеличении количества типов, как показано ниже, — мегаморфное.
В конце концов компилятор перестанет пытаться оптимизировать такую функцию.
Оптимизировать массивы
Массивы в V8 имеют собственную схему оптимизации. Скорость работы с ними зависит от набора типов данных, которые они содержат.
V8 различает 6 типов массивов:
Мы можем менять набор элементов в массиве при выполнении кода. При этом тип массива для компилятора будет изменяться только в сторону более общих значений. Если добавить в массив integer строку, а потом убрать ее, он уже не вернется к более конкретному типу PACKED_SMI_ELEMENTS. Чем более общие значения хранятся в массиве, тем дольше будет происходить оптимизация. Исходя из этого, в массивах стоит хранить только однородные типы данных.
Важно: V8 выделяет память отдельно при каждом добавлении элемента в массив. Если ожидается добавление большого количества элементов, желательно объявлять массив заданного размера new Array(n).
Избегать динамической генерации кода
Такие конструкции как eval(..), with(..) или new Function(..) вызывают проблемы с безопасностью, а движок не способен полностью предсказать их работу и корректно распарсить. Поэтому отправленный в них код интерпретируется в обход любых оптимизаций. Более того, with и eval могут оказывать влияние на лексическое окружение в коде, создавая новые непредусмотренные переменные и области видимости.
На практике любое использование этих неочевидных для компилятора конструкций заставляет его относиться с недоверием к лексическому окружению и усложнять или отменять оптимизации.
Использовать Typescript
Один из самых полезных инструментов по достижению мономорфности в нашем арсенале — typescript. Вот три причины использовать его для повышения мономорфности вашего кода.
1. Если у функций прописаны типы, наглядно видно, какая функция будет мономорфной. Даже не зная особенностей движка, мы интуитивно стараемся передавать аргументы с более конкретным типом. Только не забывайте валидировать типы, которые приложение получает извне.
2. Типизированные массивы в typescript позволяют ограничивать хранимые типы данных. Они предупреждают неожиданное обобщение типа массива для компилятора.
3. Есть более строгая возможность объявить класс. Чаще можно быть уверенным в предсказуемости получаемого hidden class.
Заключение
Javascript прекрасен простотой вхождения, но ей же и ужасен. Мало в каком языке так же просто написать рабочий код, и еще проще в JS написать код плохо. Поэтому JS-разработчикам нужно постоянно учится: лучше понимать язык, паттерны и механизмы, благодаря которым код выполняется.
Надеюсь, эта статья поможет вам сформировать и укрепить общее понимание, как работает JIT компиляция в JS. А наши практические советы окажутся полезными для ускорения выполнения вашего кода. Будем рады обсудить в комментариях JIT, JS и ваши мысли по поводу темы.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів