Новости

[Перевод] Постигаем WebAssembly, рисуя кривую дракона

Познакомьтесь с WebAssembly на примере этого простого туториала, требующего лишь самых общих знаний в веб-разработке. Весь инструментарий, который понадобится вам, чтобы составить впечатление о Wasm на основе готовых к запуску примеров кода – это редактор кода, любой современный браузер, а также контейнер Docker с наборами инструментов для C и Rust – он прилагается к статье.

На момент написания статьи WebAssembly в ходу уже три года. Она работает во всех современных браузерах, а некоторые компании уже даже решились использовать ее в продакшене  (Figma, наше почтение). За этим продуктом стоит мощная интеллектуальная поддержка: Mozilla, Microsoft, Google, Apple, Intel, Red Hat — в разработке участвовал инженерный цвет этих и других компаний. Web Assembly повсеместно считается Следующим Большим Прпорывом в веб-технологиях, но широкая аудитория фронтенд-разработчиков не спешит на нее переходить. Все мы знаем HTML, CSS и JavaScript, тех трех китов, на которых зиждется Веб, и для пересмотра такой парадигмы трех лет мало. Особенно, если краткий поиск в Google дает нечто подобное:

WebAssembly – это виртуальная архитектура для набора инструкций и двоичный формат инструкций для виртуальной машины, основанной на стеке. 

Если вышеприведенный тезис вам ни о чем не говорит – то легко сразу сдаться.

Цель этого поста – объяснить WebAssembly более доступным образом и показать на конкретных примерах, как использовать его на веб-странице. Если вы – разработчик, интересуетесь WebAssembly, но вам никогда не доводилось с ней попрактиковаться, то этот текст для вас. Тем более, если вам нравятся дракончики.

Hic sunt dracones

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

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

Усаживайтесь поудобнее и послушайте, как все это работает, как можно скомпилировать код в Wasm, и когда целесообразно применять WebAssembly в ваших проектах.

Код для людей и для машин

До появления WebAssembly JavaScript счастливо доминировал как единственный полноценный язык программирования, исполняемый браузерами. Люди, которые пишут код для Веба, умеют изъясняться на JS и доверяют клиентским машинам исполнять их код. 

Значение следующих строк на JavaScript будет понятно любому, кто немного знает программирование или даже только английский язык. Пусть и задача, «решаемая» этим кодом, вполне бессмысленная: разделить случайное число на 2 и добавить результат в массив чисел 11 088 раз.

function div() {
  return Math.random() / 2;
}

const arr = [];

for (let i = 0; i < 11088; i++) {
  arr[i] = div();
}

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

Чтобы обработать короткий код, приведенный выше, моему процессору (Intel x86-64) требуется 516 инструкций. Вот как они выглядят на ассемблере, текстовом представлении машинного кода. Названия инструкций таинственны, и, чтобы в них разобраться, нужен толстый мануал, прилагаемый к процессору.

Несколько инструкций для ассемблера x86_64

При каждом такте процессора (2 GHz означает два миллиарда тактов в секунду) процессор пытается выбрать одну или несколько инструкций и выполнить их. Как правило, множество инструкций выполняется одновременно (такое явление называется «параллелизм на уровне инструкций»).

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

В разных процессорах используются различные архитектуры набора команд  (ISA), поэтому такой архитектуре с вашего ПК (вероятнее всего, на базе Intel x86) будет непонятен машинный код для вашего смартфона (скорее всего, написанный для одной из архитектур Arm).

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

Краткий курс по компиляторам

Чтобы понять, как во всем этом участвует WebAssembly, нужно немного поговорить о компиляторах. Задача компилятора – взять человеко-читаемый исходный код (написанный на JavaScript, C, Rust или другом языке на ваш выбор) и превратить его в набор команд, которые поймет целевой процессор. Прежде, чем выдать машинный код, компилятор переведет ваш код в промежуточное представление (IR)— точный «пересказ» вашей программы, не зависящий ни от исходного, ни от конечного языка.

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

Чтобы показать, что это значит, продемонстрирую вам небольшой фрагмент кода на C – он складывает и умножает числа.

#include <stdio.h>

int main()
{
    int result = 0;
    for (int i = 0; i < 100; ++i) {
        if (i > 10) {
            result += i * 2;
        } else {
            result += i * 11;
        }
    }

    printf("%dn", result);

    return 0;
}

А вот его внутреннее представление в широко используемом формате LLVM IR, которое сгенерировал компилятор:

define hidden i32 @main() local_unnamed_addr #0 {
entry:
  %0 = tail call i32 (i8*, ...) @iprintf(…), i32 10395)
  ret i32 0
}

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

Суть примера в том, что компилятор, выполняя оптимизации, выдал готовый результат вычисления, а не заставлял процессор заниматься математикой во время исполнения. Поэтому элемент i32 10395 – это именно то число, которое выдаст вышеприведенный код на C в качестве вывода.

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

Как работают компиляторы

У большинства современных компиляторов также есть “средняя часть”, в которой выполняются оптимизации между клиентской и серверной частью.

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

Фронтенд и бэкенд
Фронтенд и бэкенд

Теперь возвращаемся обратно в Веб.

Что, если бы мы располагали промежуточным представлением, понятным всем браузерам?

Тогда именно на это целевое представление мы и могли бы рассчитывать при компиляции программы, не задумываясь о возможных проблемах с совместимостью в клиентской системе. Также мы могли бы написать нашу программу на любом языке; более мы не вынуждены хранить верность одному лишь JavaScript. Браузер выберет промежуточное представление нашего кода и сотворит свое бэкендовое колдовство: превратит промежуточное представление в машинные инструкции, понятные клиентской архитектуре.

В этом и заключается вся суть WebAssembly!

WebAssembly: промежуточное представление для Веба

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

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

Чтобы компиляция шла эффективно, наше представление должно быть максимально приближено к машинным инструкциям, но при этом нельзя жертвовать портируемостью. Поскольку все архитектуры наборов команд (ISA) зависят от аппаратного обеспечения и невозможно подогнать под один шаблон все системы, на которых могут работать браузеры, создатели WebAssembly остановились на виртуальной ISA: наборе команд для абстрактной машины. Этот набор не соответствует ни одному реально существующему процессору, но поддается эффективной обработке на уровне программ.

Виртуальная ISA достаточно низкоуровневая, чтобы ее можно было легко транслировать в конкретные машинные инструкции. Абстрактная машина для WebAssembly, в отличие от реальных процессоров, не зависит от регистров – именно туда современные процессоры кладут данные, прежде, чем произвести над ними некоторые операции. Вместо этого она использует структуру данных под названием стек: к примеру, инструкция add поднимет два самых верхних числа из стека, сложит их и отправит результат обратно на верхушку стека.

Итак, разобравшись, наконец, что такое «архитектура виртуального набора команд» и «двоичный формат для виртуальной машины на основе стека», давайте раскрутим WebAssembly на полную мощность!

Выпускаем дракона!

Теперь время учиться на практике. Мы реализуем несложный алгоритм для отрисовки простой фрактальной кривой под названием «кривая дракона». Исходный код здесь – не самое важное. Мы покажем вам, что нужно, чтобы создать модуль WebAssembly и запустить его в браузере.

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

Когда все будет готово, браузер сможет отрисовать такую картинку:

Кривая дракона и ее повороты
Кривая дракона и ее повороты

Мы начертим на холсте линию из начальной точки, выполнив последовательность поворотов влево и вправо, чтобы получить искомую фрактальную форму.

Цель этой программы – сгенерировать массив координат, по которым должна пролегать наша линия. Задача JavaScript заключается в том, чтобы превратить это в картинку. Код, отвечающий за генерацию, написан на старом добром C.

Не волнуйтесь: вам не придется часами настраивать среду разработки, поскольку мы впекли все инструменты, которые могут вам понадобиться, в образ Docker. Единственное, что потребуется предварительно установить на компьютере – сам Docker. Поэтому, если вы ранее им не пользовались, то самое время его установить; для этого просто выполните следующие шаги для той операционной системы, которую предпочитаете.

Небольшое предупреждение: примеры с командной строкой предполагают, что вы работаете под Linux или под Mac. Чтобы они работали под Windows, можно либо воспользоваться WSL (рекомендуем обновиться до WSL2) или изменить синтаксис так, чтобы поддерживался  Power Shell: для разрыва строк используйте вместо  обратные галочки, а ${pwd}:/temp вместо $(pwd):$(pwd).

Открывайте окно терминала и создайте папку, куда мы положим наш пример:

mkdir dragon-curve-llvm && cd dragon-curve-llvm
touch dragon-curve.c

Теперь откройте ваш любимый текстовый редактор и напишите в новоиспеченном файле следующий код:

// dragon-curve-llvm/dragon-curve.c
#ifndef DRAGON_CURVE
#define DRAGON_CURVE

// Вспомогательная функция для генерации координат x,y на основе "поворотов"
int sign(int x) { return (x % 2) * (2 - (x % 4)); }

// Вспомогательная функция для генерации поворотов
// Адаптировано по образцу https://en.wikipedia.org/wiki/Dragon_curve#[Un]folding_the_dragon
int getTurn(int n)
{
  int turnFlag = (((n + 1) & -(n + 1)) << 1) & (n + 1);
  return turnFlag != 0 ? -1 : 1; // -1 for left turn, 1 for right
}

// Заполняет исходный код точками x и y [x0, y0, x1, y1,...]
// первый аргумент – это указатель на первый элемент массива,
// который будет предоставляться во время исполнения.
void dragonCurve(double source[], int size, int len, double x0, double y0)
{
  int angle = 0;
  double x = x0, y = y0;
  for (int i = 0; i < size; i++)
  {
    int turn = getTurn(i);
    angle = angle + turn;
    x = x - len * sign(angle);
    y = y - len * sign(angle + 1);
    source[2 * i] = x;
    source[2 * i + 1] = y;
  }
}
#endif

Теперь нам потребуется скомпилировать это скомпилировать в WebAssembly, воспользовавшись модулем Clang из LLVM и соответствующими backend и linker для WebAssembly. Запустите следующую команду, чтобы наш контейнер Docker выполнил работу. Это просто вызов к двоичному файлу clang с набором флагов.

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit 
clang --target=wasm32 -O3 -nostdlib -Wl,--no-entry -Wl,--export-all -o dragon-curve.wasm dragon-curve.c
  • --target=wasm32 приказывает компилятору использовать WebAssembly как целевую платформу для компиляции.

  • -O3 применяет максимум возможных оптимизаций.

  • -nostdlib приказывает не использовать системных библиотек, поскольку они бесполезны в контексте браузера.

  • -Wl,--no-entry -Wl,--export-all – все эти флаги сообщают линковщику, чтобы он экспортировал из модуля WebAssembly все функции на C, которые мы определили, и игнорировал при этом отсутствие main().

В результате увидите, что у вас в папке появится файл dragon-curve.wasm. Как и следовало ожидать, это двоичный файл, в котором содержатся все 530 байт вашей программы! Можно просмотреть его вот так:

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit 
wasm-objdump dragon-curve.wasm -s
wasm-objdump dragon-curve.wasm
wasm-objdump dragon-curve.wasm

Можно еще сильнее ужать наш двоичный файл при помощи великолепного инструмента Bynarien, входящего в арсенал WebAssembly.

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit 
wasm-opt -Os dragon-curve.wasm -o dragon-curve-opt.wasm

Таким образом, удается стесать с результирующего файла еще около сотни байт.

Что у дракона в брюхе

Самое удручающее свойство двоичных файлов – они не человеко-читаемые. К счастью, в WebAssembly есть два формата: двоичный и текстовый. Вы можете пользоваться инструментарием WebAssembly Binary toolkit для перевода информации из одного формата в другой. Попробуйте запустить: 

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit 
wasm2wat dragon-curve-opt.wasm > dragon-curve-opt.wat

Теперь давайте посмотрим в нашем текстовом редакторе результирующий файл dragon-curve-opt.wat.

Содержимое .wat
Содержимое .wat

Эти забавные скобочки называются s-выражениями (как в старом добром Lisp). Они используются для представления древовидных структур. Так что наш файл Wasm – это дерево. Корень этого дерева — module. С функциональной точки зрения он очень похож на знакомые вам модули JavaScript. В нем есть импорты и экспорты.

Базовые кирпичики WebAssembly – это инструкции, совершающие операции над стеком.

Инструкции Wasm
Инструкции Wasm

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

Экспортированные sign и getTurn
Экспортированные sign и getTurn

Здесь заметны операторы ifelse и loop, разбросанные по коду, и это одна из самых замечательных черт WebAssembly: используя так называемый структурированный поток управления, как в высокоуровневых языках, WebAssembly обходится без переходов GOTO и позволяет разбирать исходный код в один присест.

Структурированный поток управления
Структурированный поток управления

Теперь давайте разберемся с экспортированной функцией sign и посмотрим, как работает виртуальный набор команд на основе стека.

Функция sign
Функция sign

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

Наша функция принимает один целочисленный параметр (param i32) и возвращает целочисленный результат (result i32). Все делается в стеке. Сначала выталкиваем значения: целое число 2, за которым идет первый параметр функции (local.get 0), а дальше целое число 4. Затем применяем инструкцию i32.rem_s, удаляющую из стека два значения (первый параметр функции и целое число 4), делящую первое значение на второе и возвращающую остаток деления обратно на вершину стека. Теперь в самом верху стека находятся остаток деления и число 2. i32.sub выталкивает их из стека, вычитает одно из другого и ставит результат в стек. Первые пять инструкций эквивалентны (2 - (x % 4)).

Wasm использует простую линейную модель памяти: можете считать память WebAssembly простым байтовым массивом.

В нашем файле .wat память экспортируется из модуля при помощи (export memory (memory 0)). Таким образом, мы можем оперировать памятью программы на WebAssembly извне, именно этим и займемся ниже.

Свет, камера, мотор!

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

touch index.html

Вставим в качестве небольшого стереотипного фрагмента пустой тег canvas и инициализируем наши исходные значения: size – это количество шагов в нашей кривой, len – длина отдельного шага, а x0 и y0 задают начальные координаты.

<!-- dragon-curve-llvm/index.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script>
      const size = 2000;
      const len = 10;
      const x0 = 500;
      const y0 = 500;
    </script>
  </body>
</html>

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

<!-- dragon-curve-llvm/index.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script>
      const size = 2000;
      const len = 10;
      const x0 = 500;
      const y0 = 500;
      WebAssembly.instantiateStreaming(fetch("/dragon-curve.wasm"), {
        // для данного примера нам не требуется ничего импортировать
        imports: {},
      }).then((obj) => {
        const { memory, __heap_base, dragonCurve } = obj.instance.exports;
        dragonCurve(__heap_base, size, len, x0, y0);
        const coords = new Float64Array(memory.buffer, __heap_base, size);
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();

        // Если вы хотите анимировать вашу кривую, замените четыре последние строки на 
        // [...Array(size)].forEach((_, i) => {
        //   setTimeout(() => {
        //     requestAnimationFrame(() => {
        //       ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        //       ctx.stroke();
        //     });
        //   }, 100 * i);
        // });
      });
    </script>
  </body>
</html>

Теперь давайте подробнее рассмотрим наш instance.exports. Кроме функции dragonCurve на C, которая генерирует наши координаты, мы также получаем в ответ объект memory, представляющий линейную память нашего модуля WebAssembly. С ним нужно работать осторожно, так как тут могут содержаться важные вещи, например, наш стек инструкций для виртуальной машины.

Строго говоря, нам нужен аллокатор памяти, чтобы не смешивать функции. Но, все-таки, это простой пример, и здесь мы обойдемся считыванием внутреннего свойства __heap_base, которое обеспечивает нам смещение в ту область памяти, которым мы можем пользоваться, ничего не опасаясь (куча).

Мы сообщаем нашей функции dragonCurve, какое смещение нужно сделать, чтобы попасть в «хорошую» область памяти, затем вызываем ее и извлекаем содержимое кучи, заполненной координатами в формате Float64Array.

Эта глава вдохновлена замечательной статьей “Compiling C to WebAssembly without Emscripten” от Surma

Далее мы просто отрисовываем кривую на холсте, основываясь на координатах, извлеченных из нашего модуля Wasm. Все, что нам теперь требуется – локально подавать наш HTML. Нам нужен простейший веб-сервер, без него мы не сможем fetch (выбрать) модуль Wasm с клиента. К счастью, в нашем образе Docker все уже настроено:

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit 
python -m http.server

Переходим по http://localhost:8000 и восторгаемся драконьей кривой!

Снимаем с велосипеда детские колесики

Приведенный выше подход с “чистым LLVM” намеренно минималистичен: мы скомпилировали нашу программу без системных библиотек. Кроме того, мы избрали для управления памятью самый изуверский из всех возможных способов: вычисляли смещение до кучи. Так нам удалось разъяснить, какова же модель памяти в WebAssembly. В реалистичных приложениях мы стремимся выделять память как следует и использовать для этого системные библиотеки, где «система» — это наш браузер: WebAssembly до сих пор работает в песочнице и не имеет непосредственного доступа к вашей операционной системе.

Emscripten – это предтеча WebAssembly: изначально он использовался для компиляции кода C/C++ в JavaScript и asm.js. И по-прежнему может! it

Все это можно сделать при помощи emscripten: это инструментарий для компиляции WebAssembly, отвечающий за симуляцию многих системных возможностей внутри браузера, а именно: работа с STDIN, STDOUT и файловая система. Даже графика OpenGL автоматически транслируется в WebGL. В Emscripten также интегрирован инструмент Bynarien, при помощи которого мы ужимали наш двоичный файл, поэтому ни о каких дополнительных оптимизациях больше беспокоиться не нужно.

Emscripten
Emscripten

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

cd .. && mkdir dragon-curve-emscripten && cd dragon-curve-emscripten
cp ../dragon-curve-llvm/dragon-curve.c .

Мы позаботились за вас о том, чтобы упаковать ecmsripten в образ Docker, поэтому для выполнения нижеприведенной команды вам ничего не придется устанавливать на компьютер:

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit 
emcc dragon-curve.c -Os -o dragon-curve.js 
-s EXPORTED_FUNCTIONS='["_dragonCurve", "_malloc", "_free"]' 
-s EXPORTED_RUNTIME_METHODS='["ccall"]' 
-s MODULARIZE=1

Если команда выполнится успешно, вы увидите два новых файла: тоненький dragon-curve-em.wasm и 15-килобайтный монстр dragon-curve-em.js (минифицированный), содержащий установочную логику для модуля WebAssembly и различные браузерные полифиллы. Такова цена, которую сегодня приходится платить за использование Wasm в браузере: по-прежнему в качестве клея требуется много JavaScript, чтобы все удерживалось вместе.

Вот что мы сделали:

  • -Os приказывает emscripten оптимизировать размер: как для Wasm, так и для JS.

  • Обратите внимание: нам требуется указать в качестве вывода лишь имя файла .js, а.wasmгенерируется автоматически.

  • Мы также можем выбрать, какую функцию хотим экспортировать из результирующего модуля Wasm; обратите внимание, что здесь перед именем требуется нижнее подчеркивание, соответственно, пишем -s EXPORTED_FUNCTIONS='["_dragonCurve", "_malloc", "_free"]'. Последние две функции помогут нам работать с памятью.

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

  • MODULARIZE=1 позволяет использовать глобальную функцию Module, возвращающую промис с экземпляром нашего модуля wasm.

Теперь можно создать HTML-файл и вставить новое содержимое:

touch index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <script type="text/javascript" src="https://habr.com/ru/post/591741/./dragon-curve.js"></script>
  <body>
    <canvas id="canvas" width="1920" height="1080">
      Your browser does not support the canvas element.
    </canvas>

    <script>
      Module().then((instance) => {
        const size = 2000;
        const len = 10;
        const x0 = 500;
        const y0 = 500;
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");

        const memoryBuffer = instance._malloc(2 * size * 8);
        instance.ccall(
          "dragonCurve",
          null,
          ["number", "number", "number", "number"],
          [memoryBuffer, size, len, x0, y0]
        );

        const coords = instance.HEAPF64.subarray(
          memoryBuffer / 8,
          2 * size + memoryBuffer / 8
        );

        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();

        instance._free(memoryBuffer);
      });
    </script>
  </body>
</html>

При работе с ecmscripten не приходится использовать API браузера, чтобы инстанцировать WebAssembly напрямую, как мы это делали в нашем предыдущем примере с WebAssembly.instantiateStreaming.

Вместо этого работаем с функцией Module, которую нам предоставляет emscripten. Module возвращает промис со всеми экспортами, которые мы определили на этапе компиляции программы. Когда этот промис разрешается, можно использовать функцию _malloc, чтобы зарезервировать в памяти место для наших координат. Она возвращает целое число со смещением, и мы сохраняем ее в переменную memoryBuffer. Это гораздо надежнее, чем небезопасный подход heap_base из предыдущего примера.

Аргумент 2 * size * 8 означает, что мы собираемся выделить массив, достаточно длинный, чтобы в нем можно было сохранить две координаты (x, y) для каждого шага, где каждая координата занимает 8 байт (float64).

В Emscripten есть специальный метод для вызова функций на C —ccall. При помощи этого метода вызываем функцию dragonCurve, заполняющую память со смещением, предоставляемым в memoryBuffer. Код холста такой же, как и в предыдущем примере. Мы также используем метод instance._free из emscripten, чтобы почистить память после использования.

Rust и выполнение кода, написанного не вами

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

Разумеется, C и C++ — это не единственные языки, которые можно компилировать в WebAssembly. На эту роль лучше всего подходят языки, чьи фронтенды приспособлены для LLVM. На фоне таких языков особенно выделяется Rust.

Rust безусловно крут тем, что в нем есть чудесный встроенный менеджер пакетов Cargo, благодаря которому совсем легко обнаруживать и переиспользовать существующие библиотеки, по сравнению со старым добрым C.

Мы покажем, как легко превратить имеющуюся библиотеку Rust в модуль WebAssembly — и это мы сделаем с помощью потрясающего инструментария wasm-pack, позволяющего за минимальное время производить начальную загрузку проектов Wasm.

Запустим новый проект при помощи нашего образа Docker, в котором есть встроенный wasm-pack. Если вы все еще не ушли из каталога dragon-curve-ecmscripten, с которым мы работали в предыдущем примере – поднимитесь на уровень выше. Для генерации проектов в wasm-pack используется тот же подход, что и с rails new или create-react-app:

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack new rust-example

Теперь можно при помощи cd перейти в каталог rust-example и открыть его в вашем редакторе. Мы уже транслировали код C для кривой дракона на Rust и упаковали в виде контейнера Cargo.

Все зависимости в проектах Rust управляются в файле Cargo.toml, функционально он во многом похож на ваши package.json или Gemfile. Откройте его в вашем редакторе, найдите раздел [dependencies], в котором на данный момент находится только wasm-bindgen, и добавьте наш внешний контейнер: 

# Cargo.toml
[dependencies]
# ...
dragon_curve = {git = "https://github.com/HellSquirrel/dragon-curve"}

Исходный код проекта находится внутри src/lib.rs, и все, что от нас требуется – определить функцию, которая станет вызывать dragon_curve из импортированного контейнера. Вставьте этот код в конце файла:

// src/lib.rs
#[wasm_bindgen]
pub fn dragon_curve(size: u32, len: f64, x0: f64, y0: f64) -> Vec<f64>
{
  dragon_curve::dragon_curve(size, len, x0, y0)
}

Время скомпилировать результат. Обратите внимание: флаги выглядят более чем понятно. В

Wasm-pack есть встроенная поддержка Webpack для связывания JavaScript; по желанию мы даже можем генерировать здесь HTML, но мы изберем самый минималистичный подход и установим --target web. В результате просто скомпилируется модуль Wasm, а также обертка JS для него, которая будет иметь вид нативного ES-модуля.

Этот шаг может занять некоторое время, в зависимости от того, на какой машине вы работаете, и какое у вас соединение с интернетом:

docker run --rm -v $(pwd):$(pwd) -w $(pwd)/rust-example -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack build --release --target web

Результат вы найдете в каталоге pkg в вашем проекте. Время создавать HTML-файл в корне проекта. Код здесь самый простой из всех примеров, приведенных в этой статье: мы просто нативно использовали функцию dragon_curve как импорт JavaScript. За кулисами здесь наш двоичный файл Wasm выполняет тяжелую работу, и нам больше не приходится вручную работать с памятью, как мы это делали в предыдущих примерах.

Еще в данном случае нужно упомянуть асинхронную функцию init, которая позволяет нам подождать, пока модуль Wasm закончит инициализацию.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script type="module">
      import init, { dragon_curve } from "/pkg/rust_example.js";
      (async function run() {
        await init();
        const size = 2000;
        const len = 10;
        const x0 = 500;
        const y0 = 500;
        const coords = dragon_curve(size, len, x0, y0);
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();
      })();
    </script>
  </body>
</html>

Теперь выдаем HTML и наслаждаемся результатом!

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit 
python -m http.server

Очевидно, с точки зрения удобства для разработчика Rust и wasm-pack абсолютно выигрывают. Здесь мы только едва затронули основы; на самом деле, с emscripten или wasm-pack можно сделать гораздо больше, например, напрямую манипулировать DOM.

Почитайте документацию по “DOM hello world”, “Single Page Applications using Rust” и Emscripten.

Тем временем в далеком-далеком браузере…

WebAssembly хороша не только своей портируемостью, независимостью от исходников и переиспользованием кода. Она также может дать значительный выигрыш в производительности, связанный с тем, как в браузерах запускается код Wasm. Чтобы понять преимущества (и недостатки) переписывания логики наших веб-приложений на WebAssembly, нужно разобраться, что происходит под капотом у клиента, и чем происходящее отличается от выполнения JavaScript.

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

Не представляется возможным осветить внутреннее устройство всех движков, поэтому давайте просто поговорим о V8, среде выполнения JS для Chromium и NodeJS; в настоящее время этот движок доминирует как на браузерном рынке, так и в серверных окружениях для JavaScript.

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

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

Вот как V8 пытается решить эту проблему:

Как работает V8 (JS)
Как работает V8 (JS)

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

Интерпретируя JS в байт-код, Ignition собирает некоторую дополнительную информацию (обратную связь), которая позволяет решить, продолжать оптимизацию или нет. Функция, помеченная как подлежащая оптимизации, расценивается как «горячая».

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

Для достижения оптимальной производительности TurboFan приходится выдвигать версии на основе обратной связи, полученной от Ignition. Например, он может «угадать» типы аргументов для функции. Если по мере поступления нового кода сделанные ранее предположения не подтверждаются, движок просто отметает все сделанные оптимизации и начинает с нуля. Такой механизм приводит к тому, что время выполнения вашего кода становится непредсказуемым.

Время выполнения JS
Время выполнения JS

Благодаря Wasm, работа браузерного движка значительно упрощается: код уже поступает в форме внутреннего представления, приспособленного для легкого многопоточного разбора, поскольку здесь используется формат.wasm. Плюс, некоторые варианты оптимизации уже были запечены в файл WebAssembly, когда он компилировался на машине разработчика. Таким образом, V8 может сразу скомпилировать и выполнить код, не переключаясь между этапами оптимизации и деоптимизации, как это делается в случае с JavaScript.

Как работает V8 (Wasm)
Как работает V8 (Wasm)

Базовый компилятор Liftoff обеспечивает функцию «быстрого старта» в V8. Без TurboFan со всеми его причудливыми оптимизациями здесь также не обходится, но на этот раз он уже не должен ни о чем гадать, поскольку в исходном коде уже содержится вся необходимая информация о типах. Концепция «горячих» функций больше не применяется, поэтому время выполнения становится детерминированным показателем. Мы заранее знаем наверняка, сколько времени понадобится для выполнения программы.

Время выполнения Wasm
Время выполнения Wasm

Естественно, WebAssembly можно выполнять и вне браузера. Существует множество проектов, позволяющих выполнять при помощи Wasm любой код на любом клиенте: Wasm3, Wasmtime, WAMR, Wasmer и другие. Как видите, амбиции WebAssembly предполагают окончательный выход за пределы браузера и укоренение во всевозможных системах и в разных вариантах применения.

В каких случаях использовать WebAssembly

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

Одна из многообещающих перспектив WebAssembly – стереть границы между веб-приложениями и другими типами программ. Зрелые базы кода, разработанные на разных языках, можно ценой минимальных усилий внести в браузер. Многие проекты уже портированы на Wasm, в том числе, игры, кодеки изображений, библиотеки для машинного обучения и даже языковые среды выполнения.

Figma – незаменимый инструмент современных дизайнеров, с самого зарождения использует WebAssembly в продакшене.

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

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

Изучите предложения по разработке Web Assembly, чтобы сориентироваться в перспективах использования двоичного кода в Вебе.

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

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

Добавить комментарий

Кнопка «Наверх»