Эмскрипт библиотеки C в Wasm

Иногда вам нужно использовать библиотеку, доступную только в виде кода C или C++. Традиционно на этом этапе вы сдаетесь. Ну, больше нет, потому что теперь у нас есть Emscripten и WebAssembly (или Wasm)!

Инструментальная цепочка

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

Хотя Emscripten раньше был компилятором C-to-asm.js, с тех пор он стал ориентирован на Wasm и находится в процессе внутреннего перехода на официальный бэкэнд LLVM. Emscripten также предоставляет Wasm-совместимую реализацию стандартной библиотеки C. Используйте Эмскриптен . Он выполняет много скрытой работы , эмулирует файловую систему, обеспечивает управление памятью, оборачивает OpenGL в WebGL — множество вещей, которые вам действительно не нужно испытывать при разработке самостоятельно.

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

Вы можете получить Emscripten, следуя инструкциям на их веб-сайте или используя Homebrew. Если вы, как и я, являетесь поклонником докеризованных команд и не хотите устанавливать что-либо в свою систему только для того, чтобы поиграть с WebAssembly, вместо этого вы можете использовать хорошо поддерживаемый образ Docker :

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Компиляция чего-то простого

Давайте возьмем почти канонический пример написания функции на C, которая вычисляет n число Фибоначчи:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Если вы знаете C, сама функция не должна вызывать удивления. Даже если вы не знаете C, но знаете JavaScript, мы надеемся, что вы сможете понять, что здесь происходит.

emscripten.h — это заголовочный файл, предоставляемый Emscripten. Он нужен нам только для того, чтобы иметь доступ к макросу EMSCRIPTEN_KEEPALIVE , но он предоставляет гораздо больше функций . Этот макрос сообщает компилятору не удалять функцию, даже если она кажется неиспользуемой. Если бы мы опустили этот макрос, компилятор оптимизировал бы функцию — в конце концов, ее никто не использует.

Давайте сохраним все это в файле с именем fib.c Чтобы превратить его в файл .wasm , нам нужно обратиться к команде компилятора Emscripten emcc :

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Давайте разберем эту команду. emcc — компилятор Emscripten. fib.c — это наш файл C. Все идет нормально. -s WASM=1 сообщает Emscripten предоставить нам файл Wasm вместо файла asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' сообщает компилятору оставить функцию cwrap() доступной в файле JavaScript — подробнее об этой функции позже. -O3 сообщает компилятору об агрессивной оптимизации. Вы можете выбрать меньшие числа, чтобы сократить время сборки, но это также увеличит размер получаемых пакетов, поскольку компилятор может не удалить неиспользуемый код.

После выполнения команды у вас должен получиться файл JavaScript с именем a.out.js и файл WebAssembly с именем a.out.wasm . Файл Wasm (или «модуль») содержит наш скомпилированный код C и должен быть довольно небольшим. Файл JavaScript отвечает за загрузку и инициализацию нашего модуля Wasm и предоставляет более удобный API. При необходимости он также позаботится о настройке стека, кучи и других функциях, которые обычно должны предоставляться операционной системой при написании кода на C. Таким образом, файл JavaScript немного больше и весит 19 КБ (~ 5 КБ в сжатом виде).

Запуск чего-то простого

Самый простой способ загрузить и запустить модуль — использовать сгенерированный файл JavaScript. Как только вы загрузите этот файл, в вашем распоряжении появится глобальный Module . Используйте cwrap для создания встроенной функции JavaScript, которая преобразует параметры во что-то, совместимое с C, и вызывает обернутую функцию. cwrap принимает имя функции, тип возвращаемого значения и типы аргументов в качестве аргументов в следующем порядке:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Если вы запустите этот код , вы должны увидеть в консоли цифру «144», которая является 12-м числом Фибоначчи.

Святой Грааль: компиляция библиотеки C

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

Давайте вернемся к моей первоначальной цели: скомпилировать кодировщик для WebP в Wasm. Исходный код кодека WebP написан на C и доступен на GitHub, как и некоторая обширная документация по API . Это довольно хорошая отправная точка.

    $ git clone https://github.com/webmproject/libwebp

Для начала давайте попробуем предоставить доступ к WebPGetEncoderVersion() из encode.h для JavaScript, написав файл C с именем webp.c :

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

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

Чтобы скомпилировать эту программу, нам нужно сообщить компилятору, где он может найти файлы заголовков libwebp, используя флаг -I , а также передать ему все необходимые C-файлы libwebp. Я буду честен: я просто загрузил в него все файлы C, которые смог найти, и полагался на то, что компилятор удалит все ненужное. Казалось, это сработало блестяще!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Теперь нам нужен только HTML и JavaScript, чтобы загрузить наш новый блестящий модуль:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

И мы увидим номер версии исправления в выводе :

Снимок экрана консоли DevTools, показывающий правильную версию. число.

Получить изображение из JavaScript в Wasm

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

Первый вопрос, на который нам предстоит ответить: как перенести изображение в страну Васм? Глядя на API кодирования libwebp , он ожидает массив байтов в RGB, RGBA, BGR или BGRA. К счастью, в Canvas API есть getImageData() , который дает нам Uint8ClampedArray, содержащий данные изображения в формате RGBA:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

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

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer выделяет буфер для изображения RGBA — следовательно, 4 байта на пиксель. Указатель, возвращаемый функцией malloc() является адресом первой ячейки памяти этого буфера. Когда указатель возвращается на территорию JavaScript, он рассматривается как просто число. После предоставления функции JavaScript с помощью cwrap мы можем использовать это число, чтобы найти начало нашего буфера и скопировать данные изображения.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Грандиозный финал: закодируйте изображение

Изображение теперь доступно на территории Васм. Пришло время вызвать кодировщик WebP, чтобы он выполнил свою работу! Глядя на документацию WebP , WebPEncodeRGBA кажется идеальным вариантом. Функция принимает указатель на входное изображение и его размеры, а также параметр качества от 0 до 100. Она также выделяет для нас выходной буфер, который нам нужно освободить с помощью WebPFree() как только мы закончим с изображением WebP. .

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

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

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

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

В зависимости от размера вашего изображения вы можете столкнуться с ошибкой, из-за которой Wasm не может увеличить объем памяти, чтобы разместить как входное, так и выходное изображение:

Снимок экрана консоли DevTools, показывающий ошибку.

К счастью, решение этой проблемы находится в сообщении об ошибке! Нам просто нужно добавить -s ALLOW_MEMORY_GROWTH=1 к нашей команде компиляции.

И вот оно! Мы скомпилировали кодировщик WebP и перекодировали изображение JPEG в WebP. Чтобы доказать, что это сработало, мы можем превратить наш буфер результатов в большой двоичный объект и использовать его в элементе <img> :

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Узрите славу нового изображения WebP !

Сетевая панель DevTools и сгенерированное изображение.

Заключение

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

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

Бонусный контент: выполнение чего-то простого с трудом

Если вы хотите попытаться избежать сгенерированного файла JavaScript, вы можете это сделать. Давайте вернемся к примеру Фибоначчи. Чтобы загрузить и запустить его самостоятельно, мы можем сделать следующее:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Модули WebAssembly, созданные Emscripten, не имеют памяти для работы, если вы не предоставите им память. Способ предоставления чего-либо модулю Wasm — это использование объекта imports — второго параметра функции instantiateStreaming . Модуль Wasm может получить доступ ко всему внутри объекта импорта, но не к чему-либо еще за его пределами. По соглашению модули, скомпилированные с помощью Emscripting, ожидают от среды загрузки JavaScript нескольких вещей:

  • Во-первых, есть env.memory . Модуль Wasm, так сказать, не знает о внешнем мире, поэтому ему нужно немного памяти для работы. Введите WebAssembly.Memory . Он представляет собой (необязательно расширяемую) часть линейной памяти. Параметры размера указаны в «единицах страниц WebAssembly», что означает, что приведенный выше код выделяет 1 страницу памяти, причем каждая страница имеет размер 64 КиБ . Без предоставления maximum опции объем памяти теоретически не ограничен в росте (в настоящее время в Chrome установлено жесткое ограничение в 2 ГБ). Для большинства модулей WebAssembly не требуется устанавливать максимум.
  • env.STACKTOP определяет, где стек должен начать расти. Стек необходим для вызова функций и выделения памяти для локальных переменных. Поскольку в нашей маленькой программе Фибоначчи мы не делаем никаких манипуляций с динамическим управлением памятью, мы можем просто использовать всю память как стек, следовательно, STACKTOP = 0 .