Вне Интернета: автономные бинарные файлы WebAssembly с использованием Emscripten
Emscripten всегда был ориентирован в первую очередь на компиляцию для использования в Интернете и других средах JavaScript, таких как Node.js. Однако по мере того, как WebAssembly начинает использоваться без JavaScript, появляются новые варианты применения, и поэтому мы работаем над поддержкой генерации автономных файлов Wasm с помощью Emscripten, которые не зависят от JavaScript-рантайма Emscripten! Этот пост объясняет, почему это интересно.
Использование автономного режима в Emscripten
Сначала давайте посмотрим, что можно сделать с этой новой функцией! Аналогично этому посту начнем с программы типа «hello world», которая экспортирует одну функцию, складывающую два числа:
// add.c
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int add(int x, int y) {
return x + y;
}
Обычно мы компилируем это с помощью команды emcc -O3 add.c -o add.js
, которая генерирует add.js
и add.wasm
. Вместо этого давайте попросим emcc
создать только Wasm:
emcc -O3 add.c -o add.wasm
Когда emcc
видит, что нам нужен только Wasm, он делает его «автономным» - файл Wasm, который может запускаться сам по себе, насколько это возможно, без какого-либо JavaScript-рантайма от Emscripten.
При дизассемблировании он очень минималистичен - всего 87 байт! Содержит очевидную функцию add
(func $add (param $0 i32) (param $1 i32) (result i32)
(i32.add
(local.get $0)
(local.get $1)
)
)
и еще одну функцию, _start
,
(func $_start
(nop)
)
_start
является частью спецификации WASI, и автономный режим Emscripten генерирует его для возможности работы в средах выполнения WASI. (Обычно _start
выполнял бы глобальную инициализацию, но здесь она не нужна, поэтому функция пустая.)
Напишите собственный загрузчик JavaScript
Одно из преимуществ автономного файла Wasm заключается в том, что вы можете написать собственный JavaScript для его загрузки и выполнения, который может быть очень минималистичным в зависимости от вашего случая использования. Например, в Node.js можно сделать так:
// load-add.js
const binary = require('fs').readFileSync('add.wasm');
WebAssembly.instantiate(binary).then(({ instance }) => {
console.log(instance.exports.add(40, 2));
});
Всего 4 строки! Запуск этого кода выводит 42
, как и ожидалось. Обратите внимание, что хотя этот пример очень упрощен, существуют случаи, когда вам просто не нужно много JavaScript, и возможно сделать лучше, чем стандартный JavaScript-рантайм Emscripten (который поддерживает множество сред и опций). Реальный пример этого можно найти в meshoptimizer от zeux - всего 57 строк, включая управление памятью, увеличение и т. д.!
Работа в средах выполнения Wasm
Еще одно преимущество автономных файлов Wasm заключается в том, что их можно запускать в средах выполнения Wasm, таких как wasmer, wasmtime, или WAVM. Например, рассмотрим пример hello world:
// hello.cpp
#include <stdio.h>
int main() {
printf("hello, world!\n");
return 0;
}
Мы можем скомпилировать и выполнить это в любой из этих сред выполнения:
$ emcc hello.cpp -O3 -o hello.wasm
$ wasmer run hello.wasm
hello, world!
$ wasmtime hello.wasm
hello, world!
$ wavm run hello.wasm
hello, world!
Emscripten использует API WASI настолько, насколько это возможно, поэтому такие программы полностью используют WASI и могут работать в средах выполнения с поддержкой WASI (см. замечания ниже о программах, которые требуют большего, чем WASI).
Создание Wasm-плагинов
Помимо Интернета и сервера, захватывающее направление для Wasm - это плагины. Например, редактор изображений может иметь плагины Wasm, которые могут выполнять фильтры и другие операции с изображением. Для таких случаев применения требуется автономный бинарный файл Wasm, как показано в приведенных примерах, но также с корректным API для встраиваемого приложения.
Плагины иногда связаны с динамическими библиотеками, так как динамические библиотеки — это один из способов их реализации. Emscripten поддерживает динамические библиотеки с опцией SIDE_MODULE, и это был один из способов создания плагинов Wasm. Новый автономный вариант Wasm, описанный здесь, улучшает это несколькими способами: во-первых, динамическая библиотека имеет перенастраиваемую память, что добавляет накладные расходы, если они вам не нужны (а они вам не нужны, если вы не связываете Wasm с другим Wasm после его загрузки). Во-вторых, автономный вывод предназначен для работы также в Wasm-рантаймах, как упоминалось ранее.
Хорошо, пока все понятно: Emscripten может как издавать JavaScript + WebAssembly, как это делалось всегда, так и издавать только WebAssembly, что позволяет запускать его в тех местах, где нет JavaScript, таких как Wasm-рантаймы, или вы можете писать свой собственный код загрузчика JavaScript и так далее. Теперь давайте поговорим о предыстории и технических деталях!
Два стандартных API WebAssembly
WebAssembly может получить доступ только к тем API, которые оно получает в качестве импортов — основная спецификация Wasm не содержит конкретных деталей API. Учитывая текущую траекторию Wasm, кажется, что будет три основные категории API, которые люди импортируют и используют:
- Web API: Это то, что программы Wasm используют в Интернете; это существующие стандартизированные API, которые также может использовать JavaScript. В настоящее время они вызываются косвенно через JavaScript-код, но в будущем с интерфейсными типами они будут вызываться напрямую.
- WASI API: WASI сосредоточен на стандартизации API для Wasm на сервере.
- Другие API: Различные пользовательские среды выполнения будут определять свои собственные специфические для приложения API. Например, ранее был приведен пример редактора изображений с плагинами Wasm, которые реализуют API для выполнения визуальных эффектов. Обратите внимание, что плагин может также иметь доступ к „системным“ API, как это делает родная динамическая библиотека, или он может быть сильно изолирован и вообще не иметь импортов (если среда выполнения вызывает только его методы).
WebAssembly занимает интересное положение, имея два стандартизированных набора API. Это имеет смысл, поскольку один из них предназначен для Интернета, а другой — для сервера, и эти среды имеют разные требования; по аналогичным причинам Node.js не имеет идентичных API с JavaScript в Интернете.
Однако существует не только Интернет и сервер, в частности, существуют также плагины Wasm. С одной стороны, плагины могут выполняться внутри приложения, которое может находиться в Интернете (точно так же, как JS плагины) или вне Интернета; с другой стороны, независимо от того, где находится встраиваемое приложение, среда плагинов не является ни веб-средой, ни серверной средой. Поэтому сразу не очевидно, какие наборы API будут использоваться — это может зависеть от переносимого кода, среды выполнения Wasm и так далее.
Давайте упростим как можно больше
Один из конкретных способов, которым Emscripten надеется помочь здесь, заключается в том, что используя API WASI как можно больше, мы можем избежать ненужных различий в API. Как упоминалось ранее, в Интернете код Emscripten получает доступ к веб-API косвенно через JavaScript, так что там, где этот JavaScript-API мог бы выглядеть как WASI, мы бы устраняли ненужное различие в API, и тот же бинарный файл мог бы также выполняться на сервере. Другими словами, если Wasm хочет записать некоторую информацию, ему нужно вызвать JS, что-то вроде этого:
wasm => function musl_writev(..) { .. console.log(..) .. }
musl_writev
— это реализация интерфейса системных вызовов Linux, который musl libc использует для записи данных в файловый дескриптор, и который в конечном итоге вызывает console.log
с нужными данными. Модуль Wasm импортирует и вызывает этот musl_writev
, который определяет ABI между JS и Wasm. Этот ABI произволен (и, в действительности, Emscripten изменял свой ABI с течением времени, чтобы оптимизировать его). Если мы заменим это на ABI, который соответствует WASI, мы получим следующее:
wasm => function __wasi_fd_write(..) { .. console.log(..) .. }
Это небольшое изменение, требующее небольшого рефакторинга ABI, и при выполнении в среде JS это не имеет большого значения. Но теперь Wasm может выполняться без JS, поскольку этот API WASI распознается рантаймами WASI! Именно так работают описанные выше автономные примеры Wasm, просто путем рефакторинга Emscripten для использования API WASI.
Еще одним преимуществом использования API WASI в Emscripten является то, что мы можем помочь спецификации WASI, находя проблемы реального мира. Например, мы обнаружили, что изменение констант "whence" в WASI было бы полезным, и мы начали некоторые обсуждения по размеру кода и совместимости с POSIX.
Использование Emscripten API WASI по максимуму также полезно тем, что позволяет пользователям использовать единый SDK для целевых сред веба, сервера и плагинов. Emscripten не единственный SDK, позволяющий это, поскольку вывод SDK WASI может быть выполнен в вебе с помощью WASI Web Polyfill или wasmer-js от Wasmer, но вывод Emscripten для веба более компактный, что позволяет использовать единый SDK без ущерба для веб-производительности.
Кстати, вы можете создать автономный файл Wasm из Emscripten с дополнительным JS всего одной командой:
emcc -O3 add.c -o add.js -s STANDALONE_WASM
Это создаёт add.js
и add.wasm
. Файл Wasm является автономным, как и раньше, когда мы создавали только отдельный файл Wasm (флаг STANDALONE_WASM
устанавливался автоматически, когда мы указывали -o add.wasm
), но теперь добавляется JS-файл, который может загружать и запускать его. JS полезен для запуска в Интернете, если вы не хотите писать собственный JS для этого.
Нужен ли нам не-автономный Wasm?
Почему существует флаг STANDALONE_WASM
? Теоретически Emscripten всегда мог бы использовать STANDALONE_WASM
, что было бы проще. Но автономные файлы Wasm не могут зависеть от JS, что имеет некоторые недостатки:
- Мы не можем минимизировать имена импортов и экспортов Wasm, так как минимизация работает только если обе стороны согласны — файл Wasm и то, что его загружает.
- Обычно создание памяти Wasm происходит на стороне JS, чтобы JS мог начать использовать её при запуске, что позволяет выполнять задачи параллельно. Но в автономном Wasm память должна быть создана в самом Wasm.
- Некоторые API просто удобнее реализовать в JS. Например,
__assert_fail
, который вызывается, когда заверение в C терпит неудачу, обычно реализуется в JS. Это занимает всего одну строку, а даже если включить функции JS, которые он вызывает, общий размер кода остаётся довольно небольшим. С другой стороны, в автономной сборке мы не можем зависеть от JS, поэтому мы используемassert.c
из musl. Это используетfprintf
, что означает, что задействуются многие функции Cstdio
, включая те, которые используют косвенные вызовы, усложняя удаление неиспользуемых функций. В целом таких деталей много, и они влияют на общий размер кода.
Если вы хотите запускать код как в Интернете, так и вне его, и хотите добиться 100% оптимального размера кода и времени запуска, вам следует создать две отдельные сборки: одну с -s STANDALONE
и другую без. Это очень просто, так как достаточно всего лишь изменить один флаг!
Необходимые различия API
Мы видели, что Emscripten использует API WASI настолько, насколько это возможно, чтобы избежать необязательных различий API. Существуют ли какие-либо необходимые различия? К сожалению, да — некоторые API WASI требуют компромиссов. Например:
- WASI не поддерживает различные функциональности POSIX, такие как права доступа для пользователя/группы/всех, из-за чего вы, например, не можете полностью реализовать системный
ls
(Linux) (см. подробности по указанной ссылке). Существующий файловый слой Emscripten поддерживает некоторые из этих вещей, поэтому если мы переключимся на API WASI для всех файловых операций, это приведёт к утрате части поддержки POSIX. path_open
в WASI увеличивает размер кода, так как требует дополнительных операций по управлению разрешениями непосредственно в Wasm. Этот код нужен только вне Интернета.- WASI не предоставляет API для уведомлений о росте памяти, из-за чего JS-рантаймы должны постоянно проверять, увеличилась ли память, и, если да, обновлять свои представления при каждом импорте и экспорте. Чтобы избежать этого, Emscripten предоставляет API уведомления,
emscripten_notify_memory_growth
, который можно увидеть реализованным в одной строке в meshoptimizer zeux, который упоминался ранее.
Со временем WASI может добавить больше поддержки POSIX, уведомления о росте памяти и т. д. — WASI всё ещё находится в стадии экспериментов и ожидает значительных изменений. Пока что, чтобы избежать регрессий в Emscripten, мы не создаём 100% бинарные файлы WASI, если вы используете определённые функции. В частности, при открытии файлов используется метод POSIX вместо WASI, что означает, что если вы вызовете fopen
, созданный файл Wasm не будет 100% WASI — однако если вы просто используете printf
, который работает с уже открытым stdout
, то он будет полностью соответствовать WASI, как в примере "hello world", который мы видели в начале, где вывод Emscripten работает в средах выполнения WASI.
Если это окажется полезным для пользователей, мы можем добавить опцию PURE_WASI
, которая пожертвует размером кода в обмен на строгую совместимость с WASI. Но если это не срочно (и большинство случаев использования плагинов, которые мы видели, пока не нуждаются в полном вводе/выводе файлов), возможно, мы можем подождать, пока WASI улучшится до такой степени, что Emscripten сможет исключить эти не-WASI API. Это было бы наилучшим результатом, и мы движемся в этом направлении, как вы можете видеть по приведённым выше ссылкам.
Однако, даже если WASI станет лучше, нельзя избежать того факта, что у Wasm есть два стандартизированных API, как было упомянуто ранее. В будущем я ожидаю, что Emscripten будет вызывать API Web напрямую, используя типы интерфейсов, потому что это будет более компактным решением, чем вызов JS API, похожего на WASI, который затем вызывает API Web (как в примере с musl_writev
, упомянутом ранее). Мы могли бы использовать полифил или какой-либо слой преобразования, чтобы помочь здесь, но мы не хотели бы использовать его без необходимости, поэтому нам потребуются отдельные сборки для сред Web и WASI. (Это довольно досадно; теоретически этого можно было бы избежать, если бы WASI был надмножеством API Web, но, очевидно, это означало бы компромиссы на стороне сервера.)
Текущее состояние
Многое уже работает! Основные ограничения следующие:
- Ограничения WebAssembly: Различные функции, такие как исключения C++, setjmp и pthreads, зависят от JavaScript из-за ограничений Wasm, и пока нет хорошей замены без JS. (Emscripten может начать поддерживать некоторые из них с использованием Asyncify, или, возможно, мы просто будем ждать появления нативных функций Wasm в виртуальных машинах.)
- Ограничения WASI: Библиотеки и API, такие как OpenGL и SDL, пока не имеют соответствующих API WASI.
Вы можете все еще использовать их в автономном режиме Emscripten, но вывод будет содержать вызовы поддержки кода JS. В результате он не будет на 100% соответствовать WASI (по похожим причинам эти функции также не работают в WASI SDK). Такие файлы Wasm не будут запускаться в средах выполнения WASI, но вы можете использовать их в Web, а также написать свой собственный JS runtime для них. Вы также можете использовать их как плагины; например, игровой движок может иметь плагины, которые рендерят с использованием OpenGL, и разработчик будет компилировать их в автономном режиме, а затем реализовывать импорт OpenGL в среде выполнения Wasm движка. Автономный режим Wasm все еще помогает здесь, так как делает вывод максимально автономным, насколько это возможно для Emscripten.
Вы также можете найти API, которые имеют замену без JavaScript, но которые мы еще не конвертировали, так как работа все еще продолжается. Пожалуйста, подавайте отчеты об ошибках, и, как всегда, мы приветствуем вашу помощь!