Pull to refresh

Прошлое и будущее компиляции JavaScript

Reading time 10 min
Views 46K
Сейчас мы воспринимаем как должное быстрое выполнение js-кода в браузерах, и с каждым днем становится все больше вдохновляющих примеров того, что можно реализовать с помощью JS. Но так было далеко не всегда. В этой статье поговорим о JS-движках, отвечающих за компиляцию кода в браузерах, об их историческом пути ускорения и возможных будущих путях.

Первым движком, интерпретирующим js-код стал SpiderMonkey, который был представлен в браузере Netscape 2.0 в 1995 г. Миф о его быстром создании хорошо задокументирован. У Брендана Айка было всего 10 дней на дизайн языка и построение компилятора. Javascript был успешен с самого начала, и к августу того же кода Майкрософт уже встроила свою версию JScript в Internet Explorer 3.0. К концу 1996 язык был принят в комиссию для формальной стандартизации, и уже в июне следующего года обрел официальный стандарт ECMA-262. С тех пор поддержка JS стала обязательно для каждого браузера, и каждый крупный производитель начал строить свой движок для поддержки JS. В течение долгих лет эти движки развивались, заменяли друг друга, переименовывались, и становились основой для следующих движков. Отследить все созданные версии — задача не для слабых духом.

image

Например, мало кто сейчас помнит о браузере Konquerer от KDE, который использовал свой опенсорсный KJS движок. Впоследствии разработчики Apple “форкнули” этот проект и развили до будущего ядра WebKit, сменив в процессе эволюции несколько названий: Squirrelfish, Squirrelfish Extreme, Nitro.

Противоположные процессы также имели место быть. Есть движки, названия которых остались неизменными, в то время как все внутренности были изменены. Например, в SpiderMonkey от Mozilla нет никаких намеков на код, существовавший в 1995.

К середине 2000-х JavaScript был стандартизирован и очень распространен, но его исполнение было все еще медленным. Гонка за скоростью началась с 2008, когда появился ряд новых движков. В начале 2008 самым быстрым движком был Futhark от Opera. К лету Mozilla представила Tracemonkey, а Google запустил свой Chrome с новым JavaScript-джвижком V8. Несмотря на обилие названий, все они пытались делать одно и то же, и каждый проект хотел выгодно отличаться в скорости исполнения. Начиная с 2008 движки улучшались за счет оптимизаций своего дизайна, и между основными игроками происходила гонка за построение самого быстрого браузера.

Когда мы говорим о JavaScript-движке, мы обычно подразумеваем компилятор, и сделать генерируемый компилятором код более быстрым — вот что является настоящей задачей. Возможно, не все пишущие JS-программы задмываются о том, как работает компилятор.
Подразумевается, что JavaScript является языком высокого уровня. Это означает, что он читаем и имеет выскую степень гибкости. Работа компилятора — сформировать из этого человеко-читаемого кода нативный код.

Обычно компиляция проходит в 4 стадии:

1. На стадии лексического анализа (сканирования) компилятор сканирует исходный код и разбивает его на отдельные составляющие, называемые токенами. Обычно то достигается через регулярные выражения.
2. Парсер структурирует переработанный код в синтаксическое дерево.
3. Затем эта структура преобразуется транслятором в байткод. В простейшем случае трансляцию можно представить как маппинг токенов в их байткод представления.
4. В конце концов байткод проходит через интерпретатор байткода, чтобы получился нативный код.

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

Быстро, элегантно, правильно

JavaScript — очень гибкий язык и довольно толерантен к конструкциям “на грани фола”. Каким же образом писать компилятор для слабо типизированного, динамического языка с поздним связыванием? Перед тем как делать его быстрым, Вы должны сделать его аккуратным. Как выразился Брендан Айк,
“Быстро, элегантно, корректно. Выберите 2, учитывая, что ‘корректно’ уже выбрано”
“Fast, Slim, Correct. Pick any two, so long as one is ‘Correct’”

Jesse Ruderman из Mozilla создал очень полезный инструмент jsfunfuzz для тестирования корректности компилятора. Брендан назвал это пародией на JavaScript-компилятор, так как его цель создавать самые странные, но валидные конструкции, которые отправляются на проверку компилятору. Инструмент позволил выявить многочисленные крайние случаи и баги.

JIT компиляторы

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

Решением является “ленивая компиляция”, или компиляция “на лету”. Как видно из названия, происходит компиляция кусков кода в машинный код имено к тому времени, как он понадобится. JIT-компиляторы появились в различных технологиях, с различными стратегиями оптимизации. Некоторые заточены под оптимизацию отдельных команд другие под оптимизацию повторяющихся операций, таких как циклы и функции. Современный JavaScript-движок применяет несколько таких компиляторов, работающих совместно для улучшения производительности вашего кода.

JavaScript JIT-компиляторы

Первым JavaScript JIT-компилятором стал TraceMonkey от Mozilla. Это был так называемый трассирующий JIT, так как он отслеживает наиболее повторяемые циклы. Эти “горячие циклы” компилируются в машинный код. Только благодаря одной этой оптимизации Mozilla получили улучшение производительности от 20% до 40% по сравнению с их предыдущим движком.

Вскоре после запуска TraceMonkey Google выпустил Chrome вместе с новым движком V8. V8 был разработан специально для оптимизации скорости. Основным архитектурным решением был отказ от генерации байткода, вместо чего транслятор генерирует напрямую нативный код. В течение года после запуска, команда также применила распределение регистров, улучшила инлайн кэширование, и полностью переписала движок регулярных выражений, сделав его в 10 раз быстрее. Это в совокупности увеличило скорость выполнения JavaScript на 150%. Гонка за скоростью продолжалась во всю!

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

Амбициозно целью всех этих преобразований является исполнение JavaScript кода на скорости нативного C. Еще несколько лет назад эта цель казалась невероятной, но разрыв в скорости исполнения все сокращается.

Теперь о некоторых частных особенностях компиляции JS.

Скрытые классы

Так как в JavaScript построение объектов и структур довольно просто для разработчика, навигация по этим нестрого детерминированным структурам может быть очень медленной для компилятора. Например, в C обычным способом хранения свойств и обращения к свойствам является хэш-таблица. Проблема с хэш-таблицей в том, что поиск по очень большой хэш-таблице может быть очень медленным.
Для ускорения этого процесса и V8, и SpiderMonkey применяют скрытые классы — внутреннее представление ваших JavaScript объектов. В Google их называют maps, а в Mozilla — shapes, но это по сути одно и то же. Эти структуры гораздо быстрее в поиске, чем стандартный поиск по словарю.

Вывод типов

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

Решением является метод вывода типов, который есть во всех современных JS-компиляторах. Компилятор делает предположения о типах данных свойств. Если преположение верно, он передает выполнение типизированному JIT, который генерирует быстрый машинны код для этих участков. Если же тип не совпадает, то код передается нетипизированному JIT, для выполнения на более медленном коде с проверками условий.

Инлайн кэширование

Это самая распространенная оптимизация в современных JavaScript-компиляторах. Это не новая техника ( впервые была применена 30 лет назад для Smalltalk компилятора), но очень полезная.

Инлайн кэширование требует обе предыдущие техники для своей работы: вывод типов и скрытые классы. Когда компилятор обнаруживает новый класс, он кэширует его скрытый класс вместе со всеми определенными типами. Если эта структура встречается позже, ее можно быстро сравнить с сохраненным кэшем. Если структура или тип данных изменились, они передаются в более медленный обобщенный (generic) код или в некоторых компиляторах выполняется полиморфное инлайн кэшировние — генерация отдельного кэша одной структуры для каждого типа данных. Подробнее об этом можно прочитать в статье Вячеслава Егорова из команды V8.
Когда компилятор получает структуру кода и типы данных в ней, становятся возможными разнообразные дополнительные оптимизации. Вот лишь несколько примеров:

inline expansion, или “inlining”

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

инвариантные изменения циклов, “подъем”

Циклы являются первым кандидатом на оптимизацию. Убрав ненужные вычисления из цикла, можно сильно улучшить производительность. Самый простой пример: цикл for по элементам массива. Вычислять длину массива на каждой итерации невыгодно, поэтому эта операция выносится, “поднимается” за цикл.

свертка констант

Вычисляются константные выражения, а также выражения, содержащие неизменяемые переменные.

удаление общих подвыражений

Аналогично сверстке констант, компилятор ищет выражения, содержащие одинаковые вычисления. Эти выражения могут заменяться на переменные с рассчитанными значениями.

устранение мертвого кода

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

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

ES.next

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

Поодержка ES.next браузерами еще довольно ограничена, но за этим можно следить хотя бы здесь, также можно начать тестирование с помощью Traceur – компилятора ES.next в JavaScript, написанный на JavaScript.

WebGL


JavaScript в браузере не ограничен манипуляциями с DOM. Большое число современных браузерных игр рендерятся напрямую на canvas элементе страницы, используя стандартный 2D-контекст. Самый быстрый способ рендеринга на канвасе — WebGL, API обеспечивающее оптимизацию за счет переноса дорогих операция на GPU, оставляя CPU для логики приложения.

WebGL в каком-то виде поддерживается в большинстве браузеров, в первую очередь в Chrome и Firefox. Пользователи Safari и Opera должны сначала включить соответствующую опцию. Microsoft также недавно объявили о поддержке WebGL в IE11.

К сожалению, даже с полноценно поддержкой браузеров, нельзя гарантировать, что WebGL будет работать одинаково хорошо для всех ваших пользователей, так как это зависит еще и от современных драйверов GPU. Google Chrome является единственным браузером, предлагающим альтернативное решение, если этих драйверов не установлено. WebGL — очень мощная технология, но ее звездный час еще не настал. Помимо вопросов безопасности, поддержка мобильных устройств очень неоднородна. И, конечно, в старых браузерах нет никакой поддержки.

Javascript как результат компиляции

Несмотря на то, что все современные веб-приложения используют JavaScript на клиенте, не все они были написаны на JavaScript. Многие написаны на абсолютно отличных языках (Java, C++, C#), и затем компилированы в JS. Некоторые же были созданы как языки, расширяющие JavaScript, для более удобной разработки, например TypeScript.

Кросс-компиляция, однако, имеет свои проблемы. Минифицированный код нечитаем, его сложно отлаживать, на практике это возможно лишь для браузеров с поддержкой сорс-маппинга — промежуточного файла, сохраняющего маппинг в код на исходном языке.
Пару лет назад Скотт Хансельман из Microsoft выдвинул постулат о том, что Javascript является языком компиляции для веба. Его замечание о том, что современнное минифицированное JavaScript приложение плохо читаемо, сложно оспаривать, но его пост тем не менее вызвал большую дискуссию. Многие веб-разработчики начинали с того, что просто изучали исходный код в браузере, а сейчас он практически всегда обфусцирован. Можем ли мы по этим причинам потерять часть будущих разработчиков?

Интересной демонстрацией идеи стал проект Emscripten, которы позоляет компилировать байткод LLVM в JavaScript. LLVM(Low Level Virtual Machine) является очень популярным форматом промежуточной компиляции, можно найти LLVM компилтор практически для любого языка. Такой подход позволит каждому писать исходный код на том языке, на котором ему удобно. Проект все еще в ранней стадии, но команда уже выпустила ряд впечатляющих демо. Например, разработчики Epic портировали Unreal Engine 3 в JavaScript и WebGL, используя LLVM компилятор C и Emscripten для компиляции в asm.js код.

asm.js

Проект, работающий в этом же направлении. Его создатели приняли призыв “javascript как машинный код” довольно буквально, взяв в качестве ассемблера JavaScript сильно ограниченное подмножество языка. таким образом теоретически можно писать asm.js код руками, но никто не захочет этого делать. Чтобы извлечь максимум пользы из этой возможности, вам потребуется 2 компилятора.
Компилятор Emscripten может производить код asm.js. результирующий JavaScript нечитаем, но он корректен и обратно совместим. Огромное ускорение прийдет тогда, когда движки браузеров будут распознавать формат asm.js и пропускать этот код через отдельный компилятор. Для этой цели в Mozilla работают над OdinMonkey, оптимизирующий asm.js компилятор, встраиваемый в IonMonkey. Google также заявил о поддержке asm.js в Chrome. Предварительные тесты показали производительность примерно в 50% от скомпилированного C++, это феноменальное достижение, сравнимое по скорости с Java и C#. Команда уверена, что результат будет улучшен.
Mozilla Research действительно находится на гребне волны в настоящее время. В дополнение к Emscripten и asm.js, также есть проект LLJS (JavaScript как C), а также совместно с Intel идет разработка River Trail – расширений ECMAScript для параллельных вычислений. Учитывая, как много усилий прикладывается в этом направлении, и какие уже результаты получены, можно предположить что исполнение JavaScript на нативной скорости не так недостижимо, как казалось раньше.

ORBX.js

Есть также те, кто предлагают решать проблему производительности JavaScript за счет полной виртуализации. Вместо того, чтобы запускать приложение на своей машине, оно запускается в облаке. Это, конечно, не решение самой проблемы компиляции JS, а альтернативное решение для пользователей. ORBX.js — реализация видеокодека, способного делаь стриминг видео с разрешением 1080 пикселей исключительно средствами JavaScript. Совместный проект Mozilla и Otoy.
Технология, конечно, впечатляет, но, возможно, создает больше проблем, чем решает.

А что Вы думаете о будущем компиляции Javascript?
Tags:
Hubs:
+66
Comments 30
Comments Comments 30

Articles