Escribe una biblioteca de C a Wasm

A veces, deseas usar una biblioteca que solo está disponible como código C o C++. Tradicionalmente, aquí es donde te rindes. Bueno, ya no, porque ahora tenemos Emscripten y WebAssembly (o Wasm).

La cadena de herramientas

Me propuse descubrir cómo compilar un código C existente para Wasm. Hay ruido en el backend de Wasm de LLVM, así que Empecé a profundizar en eso. Mientras que puedes conseguir programas simples para compilar de esta manera, cuando quieras usar la biblioteca estándar de C o incluso compilar varios archivos, probablemente tendrás problemas. Esto me llevó a la etapa lección que aprendí:

Si bien Emscripten solía ser un compilador de C-to-asm.js, desde entonces ha madurado objetivo de Wasm y es en proceso de cambio al backend oficial de LLVM de forma interna. Emscripten también proporciona una Es la implementación compatible con Wasm de la biblioteca estándar de C. Usa Emscripten. Integra contiene una gran cantidad de trabajo oculto, emula un sistema de archivos, proporciona administración de memoria, une OpenGL con WebGL, muchas de las cosas que realmente no necesitas desarrollar por tu cuenta.

Aunque parezca que tienes que preocuparte por la hinchazón, — el compilador Emscripten quita todo lo que no es necesario. En mi experimentos, los módulos de Wasm resultantes están dimensionados adecuadamente para la lógica que contienen, y los equipos de Emscripten y WebAssembly están trabajando para hacer que incluso más pequeños en el futuro.

Para obtener Emscripten, sigue las instrucciones de su sitio web o con Homebrew. Si eres fanático de comandos con Docker como yo y no quieres instalar elementos en tu sistema, solo para jugar con WebAssembly, hay un conjunto bien mantenido Imagen de Docker que puedes usar en su lugar:

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

Compilar algo simple

Tomemos el ejemplo casi canónico de escritura de una función en C que calcula el enésimo número de Fibonacci:

    #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;
    }

Si conoces C, la función en sí no debería ser muy sorprendente. Incluso si no conoces C, pero sí JavaScript, es de esperar que puedas entender qué está pasando aquí.

emscripten.h es un archivo de encabezado que proporciona Emscripten. Solo lo necesitamos, así que tienen acceso a la macro EMSCRIPTEN_KEEPALIVE, pero ofrece mucha más funcionalidad. Esta macro le indica al compilador que no quite una función incluso si aparece sin usar. Si omitiéramos esa macro, el compilador optimizaría la función. Nadie lo usa después de todo.

Guardemos todo eso en un archivo llamado fib.c. Para convertirlo en un archivo .wasm, deberás recurrir al comando del compilador de Emscripten emcc:

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

Analicemos este comando. emcc es el compilador de Emscripten. fib.c es nuestra C . Todo bien por ahora. -s WASM=1 le indica a Emscripten que nos envíe un archivo Wasm. en lugar de un archivo asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' le indica al compilador que salga del Función cwrap() disponible en el archivo JavaScript; más información sobre esta función más adelante. -O3 le indica al compilador que realice optimizaciones de forma agresiva. Puedes elegir una cantidad menor números para disminuir el tiempo de compilación, pero eso también hará que los paquetes resultantes ya que es posible que el compilador no quite el código sin usar.

Luego de ejecutar el comando, deberías terminar con un archivo JavaScript llamado a.out.js y un archivo WebAssembly llamado a.out.wasm. El archivo Wasm (o "module") contiene nuestro código C compilado y debería ser bastante pequeño. El JavaScript se encarga de cargar e inicializar el módulo de Wasm. proporcionando una API más agradable. Si es necesario, también se encargará de configurar la pila, el montón y otras funcionalidades que normalmente se espera que proporcionen el un sistema operativo completo cuando se escribe el código C. El archivo JavaScript es un poco más grande, con un peso de 19 KB (~5 KB en formato gzip).

Ejecutar algo simple

La manera más fácil de cargar y ejecutar tu módulo es usar el código JavaScript generado . Cuando se cargue el archivo, aparecerá Module global a tu disposición. Usa cwrap para crear una función nativa de JavaScript que se encargue de convertir los parámetros a algo compatible con C e invocar la función unida. cwrap toma el nombre de la función, tipo de datos que se muestra y tipos de argumento como argumentos, en ese orden:

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

Si ejecutar este código deberías ver el número “144” en la consola, que es el número 12 de Fibonacci.

El santo grial: Cómo compilar una biblioteca de C

Hasta ahora, el código C que escribimos se escribía con Wasm en mente. Un núcleo caso de uso de WebAssembly es tomar el ecosistema existente de C bibliotecas y permiten a los desarrolladores usarlas en la Web. Estas bibliotecas suelen dependen de la biblioteca estándar de C, un sistema operativo, un sistema de archivos y otros las cosas. Emscripten proporciona la mayoría de estos atributos, aunque hay algunos limitaciones.

Volvamos a mi objetivo original: compilar un codificador para WebP con Wasm. El del códec WebP está escrito en C y está disponible en GitHub, así como algunas Documentación de la API. Ese es un muy buen punto de partida.

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

Para comenzar de forma sencilla, intentemos exponer WebPGetEncoderVersion() de encode.h a JavaScript escribiendo un archivo C llamado webp.c:

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

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

Este es un buen programa sencillo para probar si podemos obtener el código fuente de libwebp. compilar, ya que no necesitamos parámetros ni estructuras de datos complejas para invocar esta función.

Para compilar este programa, debemos indicarle al compilador dónde puede encontrar libwebp con la marca -I y, además, pasa todos los archivos C de libwebp que necesita. Para ser honesto, le di toda la C que podía encontrar y confié en el compilador para eliminar todo lo que era innecesarias. Parecía funcionar de manera brillante.

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

Ahora solo necesitamos HTML y JavaScript para cargar nuestro nuevo módulo:

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

Veremos el número de versión de la corrección en Resultado:

Captura de pantalla de la consola de Herramientas para desarrolladores en la que se muestra la versión correcta
de la fila.

Cómo convertir una imagen de JavaScript en Wasm

Es genial obtener el número de versión del codificador, pero codificar sería más impresionante, ¿verdad? Hagámoslo.

La primera pregunta que debemos responder es la siguiente: ¿Cómo llevamos la imagen a la tierra de Wasm? Si observas el encoding de libwebp, se espera un array de bytes en RGB, RGBA, BGR o BGRA. Afortunadamente, la API de Canvas tiene getImageData(), que nos brinda un Uint8ClampedArray con los datos de la imagen en 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);
}

Ahora es "solo" cuestión de copiar los datos de JavaScript que llegan a Wasm por tierra. Para eso, tenemos que exponer dos funciones adicionales. Una que asigna memoria para la imagen dentro de Wasm y otra que la libera otra vez:

    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 asigna un búfer para la imagen RGBA; por lo tanto, 4 bytes por píxel. El puntero que muestra malloc() es la dirección de la primera celda de memoria de ese margen. Cuando el puntero regresa al destino de JavaScript, se lo trata como solo un número. Después de exponer la función a JavaScript con cwrap, podemos ese número para encontrar el inicio de nuestro búfer y copiar los datos de la imagen.

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);

Gran final: Codifica la imagen

La imagen ya está disponible en Wasm. Es hora de llamar al codificador WebP para haga su trabajo. Si observas el Documentación de WebP, WebPEncodeRGBA parece un ajuste perfecto. La función toma un puntero hacia la imagen de entrada y sus dimensiones, así como una opción de calidad entre 0 y 100. También asigna un búfer de salida para nosotros, que debemos liberar con WebPFree() una vez que con la imagen WebP.

El resultado de la operación de codificación es un búfer de salida y su longitud. Porque las funciones en C no pueden tener arrays como tipos de datos que se devuelven (a menos que asignemos memoria de forma dinámica), recurrí a un array global estático. no está limpio (de hecho, se basa en el hecho de que los punteros Wasm tienen 32 bits de ancho), pero para mantener las simple, creo que este es un atajo justo.

    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];
    }

Ahora con todo eso implementado, podemos llamar a la función de codificación, tomar el y el tamaño de la imagen, colocarlo en nuestro búfer propio de JavaScript. para liberar todos los búferes de Wasm-land que asignamos en el proceso.

    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);

Según el tamaño de tu imagen, es posible que encuentres el error de Wasm No se puede aumentar la memoria lo suficiente como para admitir la imagen de entrada y la de salida:

Captura de pantalla de la consola de Herramientas para desarrolladores en la que se muestra un error.

Por suerte, la solución a este problema se encuentra en el mensaje de error. Solo necesitamos Agrega -s ALLOW_MEMORY_GROWTH=1 a nuestro comando de compilación.

Lo logró. Compilamos un codificador WebP y transcodificamos una imagen JPEG a WebP. Para demostrar que funcionó, podemos convertir nuestro búfer de resultados en un BLOB y usar en un elemento <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);

¡Contempla la gloria de una nueva imagen WebP!

Panel de red de Herramientas para desarrolladores y la imagen generada.

Conclusión

No es una caminata por el parque para hacer que una biblioteca de C funcione en el navegador, sino una vez comprendes el proceso general y cómo funciona el flujo de datos, se convierte sea más fácil y los resultados pueden ser alucinantes.

WebAssembly abre muchas posibilidades nuevas en la Web para el procesamiento, la cantidad procesar datos y jugar. Ten en cuenta que Wasm no es una solución milagrosa aplicarse a todo, pero cuando encuentras uno de esos cuellos de botella, Wasm puede una herramienta increíblemente útil.

Contenido adicional: Realiza acciones simples de manera difícil

Si quieres evitar el archivo JavaScript que se genera, es posible que puedas a los que tiene acceso una cuenta. Volvamos al ejemplo de Fibonacci. Para cargarlo y ejecutarlo nosotros mismos, podemos haz lo siguiente:

<!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>

Los módulos de WebAssembly que creó Emscripten no tienen memoria para funcionar. a menos que les proporciones memoria. La forma en que proporcionas un módulo de Wasm con any es usar el objeto imports, el segundo parámetro de la función instantiateStreaming. El módulo de Wasm puede acceder a todo su contenido el objeto imports, pero nada más fuera de él. Por convención, los módulos compilados por Emscripting esperan un par de cosas de la carga de JavaScript entorno:

  • En primer lugar, tenemos env.memory. El módulo de Wasm no conoce el exterior mundo por así decirlo, por lo que necesita algo de memoria para trabajar. Ingresar WebAssembly.Memory Representa una pieza de memoria lineal (que se puede ampliar opcionalmente). El tamaño están en "unidades de páginas de WebAssembly", lo que significa que el código anterior asigna 1 página de memoria, y cada página tiene un tamaño de 64 KiB Sin proporcionar un maximum , la memoria no tiene límites de crecimiento (en la actualidad, Chrome tiene con un límite estricto de 2 GB). La mayoría de los módulos de WebAssembly no deberían necesitar establecer un máximo.
  • env.STACKTOP define el lugar en el que se supone que la pila debe comenzar a crecer. La pila para realizar llamadas a funciones y asignar memoria a las variables locales. Como no hacemos travesuras de administración de la memoria dinámica de Fibonacci, podemos usar toda la memoria como una pila, por lo tanto, STACKTOP = 0