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

Сейчас мы воспринимаем как должное быстрое выполнение 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?
Метки:
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 30
  • +6
    “Быстро, элегантно, корректно. Выберите 2, учитывая, что ‘корректно’ уже занято”
    “Fast, Slim, Correct. Pick any two, so long as one is ‘Correct’”

    я бы перевел так — выберите 2, но так чтоб одно из них было 'Корректно'
    • –7
      А не всё-равно?
      • +4
        учитывая что корректно уже занято — озночает что надо выбрать оставшиеся 2 варианта
        • +3
          Конечно нет.
          В первом варианте мне показалось, что надо выбрать оба оставшихся (плюс к уже выбранному «корректно»).
          (буду, буду читать каменты!)
        • +4
          Скорее: «можете выбрать два, если один из них „корректный“».
          • +2
            Ага, «цвет автомобиля может быть любым, при условии, что он чёрный».
        • +1
          А что Вы думаете о будущем компиляции Javascript?

          Помните, как в фильме «Москва слезам не верит» Родион рассказывал Саше про телевидение? Я бы примерно также сейчас сказал о Javascript. Но с телевидением все случилось не совсем так — посмотрим, как будет с JS.
          • –5
            Я думаю, если JS научится работать на скорости порядка 120% от натива, то можно будет спокойно забить на большинство других языков и писать всё (клиент, сервер, десктоп) на нём. :)
            Впрочем, гораздо круче было бы, если бы это был не JS, а, скажем, Ruby.
            • +5
              120%? от натива? В чем может быть секрет такого результата?

              Наверно, ближайшее будущее за кросс-компиляцией. Вопрос, насколько удобно отлаживать большие приложения через source-maps? Кто-нибудь практиковал?
              • –3
                120%? от натива? В чем может быть секрет такого результата?


                Кабы я знал, был бы ведущим разработчиком Мозиллы или Микрософота какого-нить ;)

                Вопрос, насколько удобно отлаживать большие приложения через source-maps? Кто-нибудь практиковал?


                Если я правильно понимаю, то это примерно как отлаживать паскалевские программы. Т.е. кто-то должен будет соорудить прозрачный мэппер, чтобы программисту вообще не надо было задумваться, есть там промежуточная компиляция или нет.
                • –1
                  Для тех, кто не понял мою мысль:
                  «120% скорости от натива» означает, что одну и ту же задачу нативный код выполнит за 100 секунд, а JS выполнит за 100*120% = 120 секунд, т.е. под скоростью выполнения я подразумевал время выполнения.
                  Почему я выразился именно так? Потому что, если тебя спрашивают: «Как быстро выполнится данная программа?», ты отвечаешь: «Секунд за 5», а не «1 500 000 элементарных операций в секунду». Ведь так? Отсюда и путаница в терминах.
                  Хотя, конечно, я согласен, что с формальной точки зрения термин «скорость» в данном случае использовать не совсем корректно.
                  • –1
                    «Как быстро» ≠ «с какой скоростью».
                    • 0
                      Как быстро ездит Ваш автомобиль, уважаемый?
                      • 0
                        Не очень. Разгоняется до сотни почти 30 секунд =)
                        • 0
                          Видите, Вы тоже решили нарушить формальность и вместо того, чтобы ответить, «как быстро ездит», рассказали мне, «как быстро набирает скорость».

                          Вот я выразился примерно по такому же принципу.
                          • –1
                            А разве набор скорости — это не езда?.. Так-то она ездит по-разному — так, как я хочу, то быстрее, то медленнее. Сейчас, например, вообще стоит.
                            Но вы правы, я указал недостаточно развёрнуто.
                            Развёрнуто
                            Разница в «как быстро выполнится» (через какое время закончится выполнение) VS «как быстро выполняется» (с какой скоростью выполняется). Совершенный вид против несовершенного.
                            У «как быстро» эта дифференциация есть, у «с какой скоростью» — нет.
                            Возьмём предложение «Моя машина с турбиной будет ездить со скоростью 120% от нынешней.» — придёт ли вам в голову при прочтении этого предложения, что она будет ездить медленнее?
                            • 0
                              Разница в «как быстро выполнится» (через какое время закончится выполнение) VS «как быстро выполняется» (с какой скоростью выполняется). Совершенный вид против несовершенного.


                              Если уж Вам так хочется по-воевать на тему языка, пожалуйста.
                              «Как быстро выполнится?» — это вопрос о том, что будет, если мы в будущем один раз запустим данную программу.
                              «Как быстро выполняется?» — может означать два варианта:
                              1. какое среднее время выполнения программы было зафиксировано после нескольких записей (аналогично Present Indefinite в агнлийском)
                              2. как много данных обрабатывает программа в единицу времени (аналогично Present Continuous в англ.)

                              А разве набор скорости — это не езда?.. Так-то она ездит по-разному — так, как я хочу, то быстрее, то медленнее. Сейчас, например, вообще стоит.

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

                              «Моя машина с турбиной будет ездить со скоростью 120% от нынешней.» — придёт ли вам в голову при прочтении этого предложения, что она будет ездить медленнее?


                              В отношении машины, аналогичный вопрос должен звучать так:
                              «С какой скоростью машина доезжает от Москвы до Одинцово?»

                              Да, такой вопрос звучит несколько коряво, но в принципе так сказать можно.

                              Хочу обратить Ваше особое внимание:
                              Я не пытаюсь доказать, что я всё правильно сделал и хорошо сформулировал по поводу 120% скорости, я прекрасно понимаю, почему ко мне претензии по этому поводу, однако я пытаюсь проанализировать и доказать, что я так выразился не от того, что я дурак, а в следствие объективно существующей путаницы с выражением «скорость выполнения программы». В момент написания «скорости порядка 120% от натива» я мыслил в терминах «времени выполнения», а не в терминах «количества операций в секунду». Кстати, нашлись люди, которые меня поняли, это значит, что я не один так могу мыслить.
                • +2
                  120%? Маловато будет. Как минимум 146%!
                  • +2
                    Человек имел в виду время исполнения, а не скорость (т. е. обратную величину). Непонятно, за что все на него накинулись.
                    • 0
                      <удалено>
                      • 0
                        О, спасибо, что объяснил, а то я сижу и не понимаю, за что меня вообще минусуют!

                  • +4
                    … и к августу того же кода Майкрософт уже…
                    Оставьте эту прекрасную опечатку, оставьте. Она очень мила
                    • +1
                      На всякий случай оставлю ссылку на последнюю часть оригинальной статьи: creativejs.com/2013/06/the-race-for-speed-part-4-the-future-for-javascript/ Там есть видео с Unreal Engine 3 в firefox.
                      • 0
                        github.com/languages — думаю что будущее у js однозначно есть
                        • НЛО прилетело и опубликовало эту надпись здесь
                          • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            Замечательное видео на эту же тему — vimeo.com/67050196
                            Видео на английском. Рассказывает Allen Wirfs-Brock
                            • +6
                              Подозрения начали закрадываться на этапе описания компиляторов. И подтвердились после:
                              Например, в C обычным способом хранения свойств и обращения к свойствам является хэш-таблица. Проблема с хэш-таблицей в том, что поиск по очень большой хэш-таблице может быть очень медленным.

                              После этого можно уже не читать. Тут сразу несколько ошибок.
                              • +6
                                Действительно. Подобное:
                                4. В конце концов байткод проходит через интерпретатор байткода, чтобы получился нативный код.

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

                                Хорошие статьи по реализации JavaScript, на мой взгляд, стоит искать туттут).
                                • +2
                                  Можно было догадаться уже с первого же предложения второго абзаца =) Оказывается, SpiderMonkey 1995 года уже был не интерпретатором, а компилятором, да ещё и в нативный машинный код.

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