Замена горячего пути в 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.

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

C и Emscripten

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 сжимается всего до ~260 байт, в то время как код склейки занимает около 3,5 КБ после gzip. После некоторых манипуляций нам удалось избавиться от кода склейки и создать экземпляры модулей 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

AssemblyScript — довольно молодой проект, который стремится стать компилятором TypeScript-to-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

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

Твигги

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

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

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

wasm-полоска

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

$ wasm-strip rotate_bg.wasm

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

wasm-opt

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

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

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

#![нет_стд]

После некоторых консультаций и исследований мы переписали наш код 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 hot path и вы хотите сделать его быстрее или более согласованным с WebAssembly. Как всегда с вопросами о производительности, ответ: это зависит. Так что мы отправили?

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

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

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

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

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

Обновление: Rust

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

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