Эмскрипт библиотеки 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 .