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 código C existente en Wasm. Se detectó ruido en el backend de Wasm de LLVM, así que comencé a profundizar en eso. Si bien puedes obtener programas simples para compilar de esta manera, en el momento en que quieras usar la biblioteca estándar de C o incluso compilar varios archivos, es probable que tengas problemas. Esto me llevó a la lección más importante que aprendí:

Si bien Emscripten solía ser un compilador de C a asm.js, desde entonces evolucionó para apuntar a Wasm y está en proceso de cambiar internamente al backend oficial de LLVM. Emscripten también proporciona una implementación compatible con Wasm de la biblioteca estándar de C. Usa Emscripten. Realiza muchas tareas ocultas, emula un sistema de archivos, proporciona administración de memoria y une OpenGL con WebGL, muchas cosas que realmente no necesitas experimentar por tu cuenta.

Si bien eso puede parecer que debes preocuparte por el aumento de tamaño (yo, al menos, me preocupé), el compilador de Emscripten quita todo lo que no es necesario. En mis 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 hacerlos aún más pequeños en el futuro.

Para obtener Emscripten, sigue las instrucciones en su sitio web o usa Homebrew. Si eres fan de los comandos con Docker como yo y no quieres instalar elementos en tu sistema solo para jugar con WebAssembly, hay una imagen de Docker bien mantenida 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 la escritura de una función en C que calcula el th 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 sabes C, pero sabes JavaScript, probablemente podrás comprender lo que sucede aquí.

emscripten.h es un archivo de encabezado que proporciona Emscripten. Solo la necesitamos para tener acceso a la macro EMSCRIPTEN_KEEPALIVE, pero proporciona muchas más funciones. Esta macro le indica al compilador que no quite una función, incluso si parece que no se usa. Si omitiéramos esa macro, el compilador optimizaría la función, ya que, después de todo, nadie la usa.

Guardemos todo eso en un archivo llamado fib.c. Para convertirlo en un archivo .wasm, debemos utilizar el 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 nuestro archivo C. Todo bien por ahora. -s WASM=1 le indica a Emscripten que nos proporcione un archivo Wasm en lugar de un archivo asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' le indica al compilador que deje la función cwrap() disponible en el archivo JavaScript. Más adelante, hablaremos más sobre esta función. -O3 le indica al compilador que realice optimizaciones de forma agresiva. Puedes elegir números más bajos para disminuir el tiempo de compilación, pero eso también hará que los paquetes resultantes sean más grandes, ya que es posible que el compilador no quite el código que no se usa.

Después 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 "módulo") contiene nuestro código C compilado y debería ser bastante pequeño. El archivo JavaScript se encarga de cargar e inicializar nuestro módulo de Wasm y de proporcionar una API más agradable. Si es necesario, también se encargará de configurar la pila, el montón y otras funciones que, por lo general, se espera que proporcione el sistema operativo cuando se escribe código C. Por lo tanto, el archivo JavaScript es un poco más grande y pesa 19 KB (~5 KB comprimidos con gzip).

Ejecuta algo simple

La forma más sencilla de cargar y ejecutar tu módulo es usar el archivo JavaScript generado. Una vez que cargues ese archivo, tendrás un Module global a tu disposición. Usa cwrap para crear una función nativa de JavaScript que se encargue de convertir los parámetros en algo compatible con C y de invocar la función unida. cwrap toma el nombre de la función, el tipo de devolución y los tipos de argumentos 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 ejecutas este código, deberías ver el número “144” en la consola, que es el 12º número de Fibonacci.

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

Hasta ahora, el código C que escribimos se escribió teniendo en cuenta Wasm. Sin embargo, un caso de uso principal para WebAssembly es tomar el ecosistema existente de bibliotecas de C y permitir que los desarrolladores las usen en la Web. Estas bibliotecas a menudo dependen de la biblioteca estándar de C, un sistema operativo, un sistema de archivos y otros elementos. Emscripten proporciona la mayoría de estas funciones, aunque existen algunas limitaciones.

Volvamos a mi objetivo original: compilar un codificador de WebP a Wasm. La fuente del códec WebP está escrita en C y está disponible en GitHub, así como una extensa documentación de la API. Ese es un muy buen punto de partida.

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

Para comenzar por lo simple, 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 programa sencillo y bueno para probar si podemos obtener el código fuente de libwebp para 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 los archivos de encabezado de libwebp con la marca -I y también pasarle todos los archivos C de libwebp que necesita. Voy a ser honesto: solo le di todos los archivos C que pude encontrar y dependí del compilador para quitar todo lo que no era necesario. Parece que funcionó de maravilla.

    $ 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 un poco de 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 el resultado:

Captura de pantalla de la consola de Herramientas para desarrolladores en la que se muestra el número de versión correcto.

Cómo convertir una imagen de JavaScript en Wasm

Obtener el número de versión del codificador es genial, pero codificar una imagen real sería más impresionante, ¿no? Hagámoslo.

La primera pregunta que debemos responder es la siguiente: ¿Cómo llevamos la imagen a la tierra de Wasm? Si observas la API de codificación de libwebp, se espera un array de bytes en RGB, RGBA, BGR o BGRA. Por suerte, la API de Canvas tiene getImageData(), que nos brinda un Uint8ClampedArray que contiene 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, “solo” se trata de copiar los datos del entorno de JavaScript al entorno de Wasm. Para ello, debemos exponer dos funciones adicionales. Una que asigna memoria a la imagen dentro de la tierra 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 búfer. Cuando el puntero se devuelve a JavaScript, se trata como un número. Después de exponer la función a JavaScript con cwrap, podemos usar 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 ahora está disponible en Wasm. Es hora de llamar al codificador WebP para que haga su trabajo. Si observas la documentación de WebP, WebPEncodeRGBA parece ser la opción perfecta. La función toma un puntero a la imagen de entrada y sus dimensiones, así como una opción de calidad entre 0 y 100. También nos asigna un búfer de salida, que debemos liberar con WebPFree() una vez que terminemos con la imagen WebP.

El resultado de la operación de codificación es un búfer de salida y su longitud. Debido a que las funciones en C no pueden tener arrays como tipos de datos que se muestran (a menos que asignemos memoria de forma dinámica), recurrí a un array global estático. Ya sé, no es C puro (de hecho, se basa en el hecho de que los punteros de Wasm son de 32 bits de ancho), pero para mantener la simplicidad, 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 que todo está en su lugar, podemos llamar a la función de codificación, tomar el puntero y el tamaño de la imagen, colocarlo en un búfer de JavaScript y liberar todos los búferes de Wasm 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 la imagen, es posible que encuentres un error en el que Wasm no puede aumentar la memoria lo suficiente como para admitir la imagen de entrada y la de salida:

Captura de pantalla de la consola de DevTools que muestra un error.

Por suerte, la solución a este problema está en el mensaje de error. Solo necesitamos agregar -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 usarlo 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 un paseo por el parque hacer que una biblioteca de C funcione en el navegador, pero una vez que comprendas el proceso general y cómo funciona el flujo de datos, se vuelve más fácil y los resultados pueden ser asombrosos.

WebAssembly abre muchas posibilidades nuevas en la Web para el procesamiento, el procesamiento numérico y los juegos. Ten en cuenta que Wasm no es una solución mágica que se deba aplicar a todo, pero cuando te encuentres con uno de esos cuellos de botella, puede ser una herramienta muy útil.

Contenido adicional: Cómo ejecutar algo simple de la manera difícil

Si quieres intentar evitar el archivo JavaScript generado, es posible que puedas hacerlo. Volvamos al ejemplo de Fibonacci. Para cargarlo y ejecutarlo nosotros mismos, podemos hacer 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 con la que trabajar, a menos que les proporciones memoria. La forma en que proporcionas un módulo Wasm con cualquier cosa es con el objeto imports, el segundo parámetro de la función instantiateStreaming. El módulo de Wasm puede acceder a todo lo que está dentro del objeto de importaciones, Por convención, los módulos compilados por Emscripting esperan un par de cosas del entorno de carga de JavaScript:

  • En primer lugar, tenemos env.memory. El módulo Wasm no está al tanto del mundo exterior, por lo que necesita obtener algo de memoria para trabajar. Ingresa WebAssembly.Memory. Representa una pieza de memoria lineal (que se puede ampliar opcionalmente). Los parámetros de 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 una opción maximum, la memoria crece teóricamente sin límites (actualmente, Chrome tiene 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 dónde se supone que la pila debe comenzar a crecer. La pila es necesaria para realizar llamadas a función y asignar memoria a las variables locales. Como no hacemos ningún truco de administración de memoria dinámica en nuestro pequeño programa de Fibonacci, podemos usar toda la memoria como una pila, por lo tanto, STACKTOP = 0.