Замена горячего пути в JavaScript вашего приложения на WebAssembly

Это стабильно быстро, йоу.

В своих предыдущих статьях я рассказывал о том, как WebAssembly позволяет перенести библиотечную экосистему C/C++ в Интернет. Одним из приложений, широко использующих библиотеки C/C++, является squoosh , наше веб-приложение, которое позволяет сжимать изображения с помощью различных кодеков, скомпилированных из C++ в WebAssembly.

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

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

Горячий путь

В squoosh мы написали функцию JavaScript , которая поворачивает буфер изображения на угол, кратный 90 градусам. Хотя OffscreenCanvas был бы идеальным для этого, он не поддерживается в целевых браузерах и немного глючит в Chrome .

Эта функция перебирает каждый пиксель входного изображения и копирует его в другую позицию выходного изображения для достижения поворота. Для изображения размером 4094х4096 пикселей (16 мегапикселей) потребуется более 16 миллионов итераций внутреннего блока кода, что мы называем «горячим путем». Несмотря на такое довольно большое количество итераций, два из трёх протестированных нами браузеров выполняют задачу за 2 секунды или меньше. Приемлемая продолжительность для такого типа взаимодействия.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Однако один браузер занимает более 8 секунд. Способ, которым браузеры оптимизируют JavaScript, действительно сложен , и разные движки оптимизируют разные вещи. Некоторые оптимизируют необработанное выполнение, некоторые оптимизируют взаимодействие с DOM. В данном случае мы попали по неоптимизированному пути в одном браузере.

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

WebAssembly для предсказуемой производительности

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

Написание для WebAssembly

Ранее мы взяли библиотеки C/C++ и скомпилировали их в WebAssembly, чтобы использовать их функциональность в Интернете. Мы особо не трогали код библиотек, а просто написали небольшие фрагменты кода на C/C++, чтобы сформировать мост между браузером и библиотекой. На этот раз наша мотивация другая: мы хотим написать что-то с нуля, имея в виду WebAssembly, чтобы можно было использовать преимущества WebAssembly.

Архитектура веб-сборки

При написании для WebAssembly полезно немного больше понять, что такое WebAssembly на самом деле.

Цитирую WebAssembly.org :

Когда вы компилируете фрагмент кода C или Rust в WebAssembly, вы получаете файл .wasm , содержащий объявление модуля. Это объявление состоит из списка «импорта», который модуль ожидает от своей среды, списка экспорта, который этот модуль делает доступным хосту (функции, константы, фрагменты памяти) и, конечно же, фактических двоичных инструкций для функций, содержащихся внутри. .

Кое-что я не осознавал, пока не изучил это: стек, который делает WebAssembly «виртуальной машиной на основе стека», не хранится в том фрагменте памяти, который используют модули WebAssembly. Стек полностью является внутренним и недоступен веб-разработчикам (кроме DevTools). Таким образом, можно писать модули WebAssembly, которым вообще не нужна дополнительная память и которые используют только внутренний стек виртуальной машины.

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

Управление памятью

Обычно, как только вы используете дополнительную память, вам необходимо каким-то образом управлять этой памятью. Какие части памяти используются? Какие из них бесплатны? В C, например, есть функция malloc(n) , которая находит пространство памяти из n последовательных байтов. Функции такого типа еще называют «распределителями». Конечно, реализация используемого распределителя должна быть включена в ваш модуль WebAssembly и увеличит размер вашего файла. Размер и производительность этих функций управления памятью могут существенно различаться в зависимости от используемого алгоритма, поэтому многие языки предлагают на выбор несколько реализаций («dmalloc», «emmalloc», «wee_alloc» и т. д.).

В нашем случае мы знаем размеры входного изображения (и, следовательно, размеры выходного изображения) до запуска модуля WebAssembly. Здесь мы увидели возможность: традиционно мы передавали буфер RGBA входного изображения в качестве параметра функции WebAssembly и возвращали повернутое изображение в качестве возвращаемого значения. Чтобы сгенерировать это возвращаемое значение, нам придется использовать распределитель. Но поскольку мы знаем общий объем необходимой памяти (в два раза больше размера входного изображения, один раз для ввода и один раз для вывода), мы можем поместить входное изображение в память WebAssembly с помощью JavaScript , запустить модуль WebAssembly для генерации второго, повернутое изображение, а затем используйте JavaScript, чтобы прочитать результат. Мы можем обойтись вообще без использования какого-либо управления памятью!

Избалован выбором

Если вы посмотрите на исходную функцию JavaScript , которую мы хотим использовать в WebAssembly, то увидите, что это чисто вычислительный код без API-интерфейсов, специфичных для JavaScript. Таким образом, портировать этот код на любой язык должно быть довольно просто. Мы оценили три разных языка, которые компилируются в WebAssembly: C/C++, Rust и AssemblyScript. Единственный вопрос, на который нам нужно ответить для каждого из языков: как получить доступ к необработанной памяти без использования функций управления памятью?

С и Эмскриптен

Emscripten — это компилятор C для цели WebAssembly. Цель Emscripten — служить заменой широко известных компиляторов C, таких как GCC или clang, и в основном совместим с флагами. Это основная часть миссии Emscripten, поскольку компания хочет максимально упростить компиляцию существующего кода C и C++ в WebAssembly.

Доступ к необработанной памяти заложен в самой природе C, и указатели существуют именно по этой причине:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Здесь мы превращаем число 0x124 в указатель на беззнаковые 8-битные целые числа (или байты). Это фактически превращает переменную ptr в массив, начинающийся с адреса памяти 0x124 , который мы можем использовать как любой другой массив, позволяя нам получать доступ к отдельным байтам для чтения и записи. В нашем случае мы смотрим на буфер RGBA изображения, порядок которого мы хотим изменить для достижения вращения. Чтобы переместить пиксель, нам фактически нужно переместить одновременно 4 последовательных байта (по одному байту на каждый канал: R, G, B и A). Чтобы упростить задачу, мы можем создать массив 32-битных целых чисел без знака. По соглашению, наше входное изображение начинается с адреса 4, а выходное изображение начинается сразу после окончания входного изображения:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

После переноса всей функции JavaScript на C мы можем скомпилировать файл C с помощью emcc :

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Как всегда, emscripten генерирует файл связующего кода под названием c.js и модуль wasm под названием c.wasm . Обратите внимание, что модуль Wasm сжимается с помощью gzip всего до 260 байт, а код соединения после gzip занимает около 3,5 КБ. После некоторых усилий нам удалось отказаться от связующего кода и создать экземпляры модулей WebAssembly с помощью ванильных API. Это часто возможно с помощью Emscripten, если вы не используете ничего из стандартной библиотеки C.

Ржавчина

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

Одним из таких инструментов является wasm-pack , созданный рабочей группой Rustwasm . wasm-pack берет ваш код и превращает его в веб-модуль, который готово работать с такими сборщиками, как webpack. wasm-pack — чрезвычайно удобный инструмент, но на данный момент он работает только с Rust. Группа рассматривает возможность добавления поддержки других языков, ориентированных на WebAssembly.

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

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Компиляция файлов Rust с использованием

$ wasm-pack build

дает модуль Wasm размером 7,6 КБ с примерно 100 байтами связующего кода (оба после gzip).

Ассемблерскрипт

AssemblyScript — довольно молодой проект, целью которого является компилятор TypeScript в WebAssembly. Однако важно отметить, что он не будет просто использовать TypeScript. AssemblyScript использует тот же синтаксис, что и TypeScript, но заменяет стандартную библиотеку своей собственной. Их стандартная библиотека моделирует возможности WebAssembly. Это означает, что вы не можете просто скомпилировать любой TypeScript, который у вас есть, в WebAssembly, но это означает, что вам не нужно изучать новый язык программирования, чтобы писать WebAssembly!

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Учитывая небольшой размер шрифта, который имеет наша функция rotate() , перенести этот код на AssemblyScript было довольно легко. Функции load<T>(ptr: usize) и store<T>(ptr: usize, value: T) предоставляются AssemblyScript для доступа к необработанной памяти. Чтобы скомпилировать наш файл AssemblyScript , нам нужно всего лишь установить пакет npm AssemblyScript/assemblyscript и запустить

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript предоставит нам модуль Wasm размером около 300 байт без связующего кода. Модуль работает только с ванильными API WebAssembly.

Экспертиза WebAssembly

Размер Rust в 7,6 КБ на удивление велик по сравнению с двумя другими языками. В экосистеме WebAssembly есть несколько инструментов, которые могут помочь вам проанализировать ваши файлы WebAssembly (независимо от языка, на котором они были созданы) и рассказать вам, что происходит, а также помочь вам улучшить вашу ситуацию.

Твигги

Twiggy — еще один инструмент от команды Rust WebAssembly, который извлекает кучу полезных данных из модуля WebAssembly. Этот инструмент не является специфичным для Rust и позволяет вам проверять такие вещи, как граф вызовов модуля, определять неиспользуемые или лишние разделы и выяснять, какие разделы вносят вклад в общий размер файла вашего модуля. Последнее можно сделать с помощью top команды Твигги:

$ twiggy top rotate_bg.wasm
Скриншот установки Twiggy

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

васм-стрип

wasm-strip — инструмент из WebAssembly Binary Toolkit , или сокращенно wabt. Он содержит несколько инструментов, которые позволяют проверять модули WebAssembly и манипулировать ими. wasm2wat — это дизассемблер, который преобразует двоичный модуль Wasm в удобочитаемый формат. Wabt также содержит wat2wasm , который позволяет вам превратить этот удобочитаемый формат обратно в двоичный модуль Wasm. Хотя мы использовали эти два взаимодополняющих инструмента для проверки файлов WebAssembly, мы обнаружили, что wasm-strip оказался наиболее полезным. wasm-strip удаляет ненужные разделы и метаданные из модуля WebAssembly:

$ wasm-strip rotate_bg.wasm

Это уменьшает размер файла модуля ржавчины с 7,5 КБ до 6,6 КБ (после gzip).

васм-опт

wasm-opt — инструмент от Binaryen . Он берет модуль WebAssembly и пытается оптимизировать его как по размеру, так и по производительности, основываясь только на байт-коде. Некоторые инструменты, такие как Emscripten, уже используют этот инструмент, некоторые — нет. Обычно полезно попытаться сэкономить дополнительные байты с помощью этих инструментов.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

С помощью wasm-opt мы можем сбрить еще несколько байт, чтобы после gzip осталось 6,2 КБ.

#![no_std]

После некоторых консультаций и исследований мы переписали наш код Rust без использования стандартной библиотеки Rust, используя функцию #![no_std] . Это также полностью отключает динамическое распределение памяти, удаляя код распределителя из нашего модуля. Компиляция этого файла Rust с помощью

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

дал модуль Wasm размером 1,6 КБ после wasm-opt , wasm-strip и gzip. Хотя он все еще больше, чем модули, созданные C и AssemblyScript, он достаточно мал, чтобы считаться легким.

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

Прежде чем мы сделаем поспешные выводы, основываясь только на размере файла: мы пошли на этот путь, чтобы оптимизировать производительность, а не размер файла. Итак, как мы измеряли производительность и каковы были результаты?

Как проводить бенчмаркинг

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

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

Сравнение производительности

Сравнение скорости по языкам
Сравнение скорости для каждого браузера

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

Не анализируя слишком много этих графиков, становится ясно, что мы решили нашу первоначальную проблему с производительностью: все модули WebAssembly выполняются примерно за 500 мс или меньше. Это подтверждает то, о чем мы говорили в начале: WebAssembly обеспечивает предсказуемую производительность. Независимо от того, какой язык мы выбираем, разница между браузерами и языками минимальна. Если быть точным: стандартное отклонение JavaScript во всех браузерах составляет ~ 400 мс, тогда как стандартное отклонение всех наших модулей WebAssembly во всех браузерах составляет ~ 80 мс.

Усилие

Еще одним показателем является количество усилий, которые нам пришлось приложить для создания и интеграции нашего модуля WebAssembly в Squoosh. Трудно присвоить усилиям числовое значение, поэтому я не буду создавать графики, но есть несколько вещей, на которые я хотел бы обратить внимание:

AssemblyScript работал без проблем. Он не только позволяет использовать TypeScript для написания WebAssembly, что упрощает проверку кода для моих коллег, но также позволяет создавать очень маленькие модули WebAssembly без клея, которые имеют очень маленькие размеры и приличную производительность. Инструменты в экосистеме TypeScript, такие как prettier и tslint, скорее всего, будут работать.

Rust в сочетании с wasm-pack также чрезвычайно удобен, но лучше подходит для более крупных проектов WebAssembly, где необходимы привязки и управление памятью. Нам пришлось немного отклониться от счастливого пути, чтобы добиться конкурентоспособного размера файла.

C и Emscripten создали очень маленький и высокопроизводительный модуль WebAssembly из коробки, но без смелости перейти к связующему коду и сократить его до самого необходимого, общий размер (модуль WebAssembly + связующий код) в конечном итоге оказывается довольно большим.

Заключение

Итак, какой язык вам следует использовать, если у вас есть горячий путь JS и вы хотите сделать его быстрее или более совместимым с WebAssembly. Как всегда в случае с вопросами о производительности, ответ таков: это зависит. Итак, что мы отправили?

Сравнительный график

Сравнивая соотношение размера модуля и производительности различных языков, которые мы использовали, лучшим выбором кажется C или AssemblyScript. Мы решили выпустить Rust . Для этого решения есть несколько причин: все кодеки, поставляемые в Squoosh, скомпилированы с использованием Emscripten. Мы хотели расширить наши знания об экосистеме WebAssembly и использовать другой язык в производстве . AssemblyScript — сильная альтернатива, но проект относительно молод, а компилятор не так зрел, как компилятор Rust.

Хотя разница в размере файла между Rust и другими языками выглядит довольно существенной на диаграмме разброса, на самом деле она не так уж и велика: загрузка 500 байт или 1,6 КБ даже через 2G занимает менее 1/10 секунды. И мы надеемся, что Rust скоро сократит разрыв в размерах модулей.

Что касается производительности во время выполнения, у Rust средний показатель в браузерах выше, чем у AssemblyScript. Особенно в крупных проектах Rust с большей вероятностью будет создавать более быстрый код без необходимости ручной оптимизации кода. Но это не должно мешать вам использовать то, что вам наиболее удобно.

При всем этом: AssemblyScript стал великим открытием. Это позволяет веб-разработчикам создавать модули WebAssembly без необходимости изучения нового языка. Команда AssemblyScript очень быстро отреагировала и активно работает над улучшением своего инструментария. Мы обязательно будем следить за AssemblyScript в будущем.

Обновление: ржавчина

После публикации этой статьи Ник Фицджеральд из команды Rust указал нам на их великолепную книгу Rust Wasm, в которой есть раздел об оптимизации размера файлов . Следование инструкциям (в частности, оптимизация времени компоновки и ручная обработка паники) позволило нам написать «нормальный» код Rust и вернуться к использованию Cargo ( npm Rust) без увеличения размера файла. Модуль Rust после gzip получает 370B. Для получения подробной информации, пожалуйста, ознакомьтесь с пиаром, который я открыл на Squoosh .

Особая благодарность Эшли Уильямс , Стиву Клабнику , Нику Фицджеральду и Максу Грею за всю их помощь в этом путешествии.