Эксперимент с бинарным кодом в Glimmer

Original author: Сара Клаттербак, Чад Хиетала и Том Дейл
  • Translation
Перевод статьи об эксперименте с бинарным кодом в Glimmer, соавторы публикации: Сара Клаттербак, Чад Хиетала и Том Дейл.

Чуть более года назад Ember.js претерпел значительные изменения. В тесном сотрудничестве между инженерами LinkedIn и Open Source сообществом, мы заменили у Ember движок для рендиранга на новую библиотеку, Glimmer VM, что улучшило производительность и значительно уменьшило размер скомпилированных шаблонов.

Glimmer относится к Handlebars шаблонам как функциональный язык программирования и компилирует их в последовательность инструкций, которые могут быть выполнены в браузере. Эти инструкции, или опкоды (прим. переводчика коды операций) кодируются в компактную структуру данных в виде JSON.

Когда мы перенесли наше веб приложение linkedin.com на Glimmer, мы увидели значительные улучшения во времени загрузки. В дополнении к уменьшению размера файлов на 40%, мы также сократили время, затрачиваемое браузером на анализ JavaScript, благодаря компиляции шаблонов в JSON. Более того, это изменение улучшило время загрузки в 90% случаев более чем на 1 секунду.

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

Раскрытие эксперимента с Glimmer.js


Около шести месяцев назад команда Ember.js объявила о выпуске Glimmer.js в качестве отдельной библиотеки компонентов. Отделение слоя представления позволило нам взять все самое лучше из Ember и виртуальной машины Glimmer, и передать это разработчикам, которые создают легковесные продукты, такие например как мобильные приложения для развивающихся рынков, или SEO страницы.

Прорыв Glimmer позволило нашей команде провести много экспериментов в последующие месяцы.
Недавно, например, мы представили гибидный рендер, при котором html генерируется на сервере и далее происходит rehydrated (прим. переводчика см. здесь) в браузере. Это только начало преимуществ производительности, предоставляемых архитектурой виртуальной машины Glimmer.

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

Традиционно существует дилемма между доставкой минимального количества JavaScript кода для запуска мгновенных загрузок и возможностью иметь сложный отзывчивый UI. Похоже, что фундаментальный компромисс заключается в том, что по мере увеличения приложения производительность и продуктивность уменьшаются. С Glimmer наша цель — создавать легкие, быстрые и продуктивные приложения. Одним из ключей к достижению этой цели является снижение издержек каждого нового компонента, добавляемого в приложение.

Мгновенные шаблоны


При переключение с JavaScript на JSON уменьшаются издержки на парсинг скомпилированных шаблонов, мы объединили Glimmer с передовыми функциями браузера, чтобы полностью исключить шаг парсинга.

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

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

Как уже упоминалось выше, Glimmer компилирует шаблоны в последовательность опкодов, которые передаются в браузер как JSON. Благодаря тому, что грамматика JSON намного проще, чем грамматика JavaScript, JSON парсер может работать в 10 раз быстрее, чем парсер JavaScript при парсинге одних и тех же данных.

Но это все равно означает, что время парсинга будет увеличиваться по мере увеличения размера шаблона, правда уже медленнее. Что если бы мы могли вообще обойти шаг парсинга?
В последние годы браузеры научились отлично обрабатывать бинарные данные. С использованием низкоуровневого API, такого как ArrayBuffer, JavaScript программы умеют обрабатывать бинарные данные так же быстро как их нативные аналоги. Мы воспользовались этим преимуществом для компиляции шаблонов в наш собственный формат байт-кода, который виртуальная машина Glimmer умеет выполнять напрямую. По аналогии с форматом байт-кода JVM, байт-код Glimmer представляет собой платформонезависимый бинарный формат, который кодирует набор инструкций виртуальной машины Glimmer в поток байтов, состоящий из опкодов и его операторов. Вместо того, чтобы упираться в производительность парсинга JSON или JavaScript, теперь мы лишь ограничены возможностью браузера копировать необработанные байты из сети.

Кодирование байт-кода Glimmer


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

Например, шаблон

<h1>Hello World</h1>

будет скомпилирован в следующий JSON формат во время сборки:

[
  ["open-element", "h1", []],
  ["text", "Hello World"],
  ["close-element"]
]

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

const Program = [25, 1, 0, 0, 22, 2, 0, 0, 32, 0, 0, 0];

Обратите внимание, что строки в нашем JSON были заменены целыми числами. Все потому, что мы используем так называемую технику “string interning”, которая позволяет избавиться от дублирования одинаковых строк, здесь строки заменяются смещением в пуле строковых констант, что на практике значительно уменьшает размер файлов (просто представьте сколько раз вы повторяете строковую константу div в своих шаблонах).

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

Несмотря на то, что данный подход эффективен для выполнения кода, есть минус — размеры файлов с байт-кодом больше, чем это необходимо. Все потому, что мы всегда резервируем пространство для трех операндов, хотя большинство инструкций не нуждаются в операндах или принимают всего один операнд. Таким образом, программа заполняется пустыми байтами, который там не должны были быть. Кроме того, набор инструкций Glimmer содержит только 80 опкодов, поэтому мы можем сократить зарезервированное пространство для опкодов до 8 бит.

В конечном счете, мы остановились на более компактной схеме кодирования, которая все еще была 16-битной. Первые 8 бит представляют собой опкод, следующие 2 бита используются для кодирования количества операндов и последние 6 бит зарезервированы для будущего использования. Каждый операнд, если он имеется, кодируется в дополнительные 16 бит.

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

/* Fixed Opcode  */  /*   Operand?   */  /*   Operand?   */  /*   Operand?    */
[0bIIIIIIIILLRRRRRR, 0bAAAAAAAAAAAAAAAA, 0bAAAAAAAAAAAAAAAA, 0bAAAAAAAAAAAAAAAA]

/*
  I = instruction (opcode) type
  L = operand length
  R = reserved
  A = operand value
*/
view raw

Эта новая схема уменьшает размер скомпилированной программы на 50%. «Декодирование» этой схемы имеет незначительные накладные расходы, поскольку мы просто маскируем и сдвигаем биты, чтобы выяснить длину опкода и длину операнда.

Устранение разрыва между байт-кодом и JavaScript


Одна из проблем, с которой мы столкнулись, заключалась в том, чтобы переместить всю фазу компиляции в сборщик проекта. Ранее мы выполняли последний шаг компиляции шаблонов в браузере, как только JavaScript код приложения загрузился. Это позволяло нам соединять скомпилированные шаблоны с объектами JavaScript, таким как классы компонентов, которые обрабатывали действия пользователя.

Первый шаг заключался в том, чтобы обеспечить все уровни компиляции на Node.js. Мы создали новый интерфейс под названием “bundle compiler”, который инкапсулировал все уровни компиляции в один API, что позволяло инструментам сборки превращать “bundle” шаблонов в байт-код.

Затем мы столкнулись с дополнительной проблемой: при компиляции в байт-код, как бы нам «подключить» этот байт-код обратно к нужным объектам JavaScript во время выполнения? Чтобы решить эту проблему, мы ввели понятие “handles” (обработчики). Обработчик — это уникальный числовой идентификатор, который связан с внешними объектами в шаблоне, такими как компоненты или хелперы. Во время компиляции мы связываем каждый внешний объект с обработчиком, который кодируется в байт-код. Например, если мы видим вызов компонента <UserProfile />, мы можем связать его с обработчиком с идентификатором 42 (предполагая, что до этого уже было вызвано 41 уникальных компонентов).

Вызов компонента на подобие этого компилируется в несколько опкодов в наборе команд Glimmer. Одна из этих инструкций — это 0x003b PushComponentDefinition, которая помещает класс JavaScript компонента в стек виртуальной машины (VM). При компиляции в байт-код эта инструкция создаст четыре байта: 0x00 0x3b 0x01 0x2A. Первые два байта кодируют опкод PushComponentDefinition. Вторые два байта кодируют операнд, который в этом случае является обработчиком (число 42).

И так что произойдет когда мы запустим байт-код в браузере? Как превратить целое число 42 в живой, дышащий класс JavaScript? Этот трюк мы называем “external module table” (таблица внешних модулей). Это небольшой фрагмент сгенерированного JavaScript кода, который объединяет два мира, определяя структуру данных, которая позволяет эффективно сопоставлять обработчики с соответствующими JavaScript классами.

В нашем примере мы связали UserProfile с обработчиком 42, таким образом наша таблица внешних модулей — это массив, где класс UserProfile является 42 элементом в массиве.

import UserProfile from './src/ui/components/UserProfile/component';
/* ...other component imports */

export let table = [
  /* Component1  */,
  /* Component2  */,
  /* ...         */,
  /* Component41 */,
  UserProfile,
  /* Component43 */,
  /* ... */
];

Во время выполнения байт-кода, вспомогательный объект, называемый “resolver” (распознаватель) превращает обработчик в соответствующий JavaScript объект. Поскольку каждый обработчик также является смещением в массиве, этот код прост и быст:

resolve<U>(handle: number): U {
  return this.table[handle];
}

image

Сборка проекта в формате .gbx (Glimmer Binary Experience), передача браузеру, и VM рендеринг заголовка в браузере.

Что дальше


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

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

Все мы в LinkedIn большие поклонники open source, и вся работа описанная выше была открыта на GitHub. Если мы заинтересовали вас проектом Glimmer, мы приглашаем вас в репозитории Glimmer VM и Glimmer.js на GitHub.

Благодарности


Огромное спасибо Чаду Хиетале и Тому Дейлу, которые взялись за компиляцию байт-кода в LinkedIn. Кроме того, благодаря Иегуде Кацу и Годфри Чэну за помощь в реализации этого видения в open source сообществе.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 5

    0

    https://lifeart.github.io/sierpinski-glimmer/ — сихронная версия подтормаживает
    https://lifeart.github.io/demo-async-dom/glimmer-port/index.html — асинхронная версия даёт плавную анимацию, но так же и артефакты при первичном рендеринге и не очень шуструю реакцию на действия пользователя.

      0

      Дмитрий, несмотря не артефакты,


      и не очень шуструю реакцию на действия пользователя.

      это полшага в правильном направлении. На мой взгляд опять же.

        –1

        Мне кажется они в своем глиммере перемудрили с этим опкодом и уж тем более с бинарным кодом. Ради чего все это — для того чтобы уменьшить бандл? Или скорость парсинга? Мне кажется jsx версия <h1>Hello World</h1> которая скомпилируется в h('h1', null, 'Hello-world') не так уж и много занимает и парсить отдельно не нужно — скорость парсинга js достаточно быстрая в браузерах а браузеры и так ее пытаются постоянно улучшить. В общем экономия на спичках учитывая что сейчас модно использовать webpack со всяким code-splitting а не собирать все это в один большой бандл.
        А если взглянуть что скрывается за этими опкодами и виртуальной машиной то там будет перестраиваемое дерево (или точнее связанный список) функций (шаблонных привязок) которые будут проверяться при перерендере шаблона, и в зависимости от значений отдельных привязок это дерево будет перестраиваться чтобы уменьшить количество проверок. Идея этой техники шаблонизации интересная потому что она явно быстрее чем virtual-dom реакта которому нужно проверять все статические части а не только выражения внутри шаблонных-привязок. Но вместо того чтобы просто реализовать эту технику шаблонизации ребята в глиммере решили намудрить и сделать не полноценные выражения (то есть нельзя в шаблоне <div>{{...}}</div> выполнить произвольное js-выражение как в реакте) и закодировать эти привязки как опкоды, то есть взять на себя роль движка js и выполнять выражения которые там доступны самостоятельно. А это естественно будет медленней потому что тот же v8 скомпилирует обращение к свойству "obj.someproperty" в один "mov [eax + 0x4343] ebx" а интерпретатору глиммера потребуется вытащить следующий опкод проверить тип и т.д — то есть выполнить в десятки если не в сотню раз больше операций.

          +1
          Мне кажется jsx версия Hello World которая скомпилируется в h('h1', null, 'Hello-world') не так уж и много занимает и парсить отдельно не нужно

          Почему отдельно парсить не нужно? После того как браузер скачает скомпилированный файл разве он не начнет его парсить?
            –1
            Тут подразумевалось что после того как браузер распарсит javascript у нас будет только вызов функции, то есть парсить в самом фрейморке уже не нужно

        Only users with full accounts can post comments. Log in, please.