Escribe una biblioteca de C a Wasm

En ocasiones, es posible que quieras usar una biblioteca que solo esté disponible como código C o C++. Tradicionalmente, aquí es donde te rendiste. Bueno, ya no, porque ahora tenemos Emscripten y WebAssembly (o Wasm).

La cadena de herramientas

Me propuse el objetivo de descubrir cómo compilar algunos códigos C existentes para Wasm. Se detectó ruido en el backend de Wasm de LLVM, así que comencé a investigarlo. Si bien puedes hacer que se compilen programas simples de esta manera, es probable que, apenas quieras usar la biblioteca estándar de C o compilar varios archivos, tengas problemas. Esto me llevó a la lección principal que aprendí:

Si bien Emscripten solía ser un compilador de C-to-asm.js, desde entonces maduró para orientarse 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. Lleva mucho trabajo oculto, emula un sistema de archivos, proporciona administración de memoria y une OpenGL con WebGL. Son muchas acciones que no necesitas desarrollar para ti.

Si bien puede parecer que tienes que preocuparte por el aumento (sin dudas), el compilador Emscripten quita todo lo que no es necesario. En mis experimentos, los módulos de Wasm resultantes tienen el tamaño adecuado para la lógica que contienen, y los equipos de Emscripten y WebAssembly trabajan 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 de Docker, como yo, y no deseas 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>

Compila 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 demasiado sorprendente. Incluso si no conoces C, pero sí JavaScript, con suerte podrás entender 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 funcionalidades. Esta macro le indica al compilador que no quite una función, incluso si parece que no está en uso. Si omitiéramos esa macro, el compilador optimizaría la función; después de todo, nadie la usa.

Guardemos todo eso en un archivo llamado fib.c. Para convertirlo en un archivo .wasm, debemos 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 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 (hablaremos de esto más adelante en esta función). -O3 le indica al compilador que realice optimizaciones de forma agresiva. Puedes elegir números inferiores para disminuir el tiempo de compilación, pero eso también agranda los paquetes resultantes, ya que es posible que el compilador no quite el código que no se usa.

Después de ejecutar el comando, deberías tener 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 atractiva. Si es necesario, también se encargará de configurar la pila, el montón y otras funcionalidades que, por lo general, se espera que proporcione el sistema operativo cuando se escribe el código C. Por lo tanto, el archivo JavaScript es un poco más grande, con un peso de 19 KB (~5 KB gzip).

Ejecutar algo sencillo

La forma más fácil 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 datos que se muestra y los 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 ejecutas este código, deberías ver "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 pensando en 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. A menudo, esas bibliotecas 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 para WebP en Wasm. La fuente del códec WebP está escrita en C y está disponible en GitHub, así como cierta documentación de la API extensa. Ese es un buen punto de partida.

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

Para comenzar de manera 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 buen programa simple para probar si podemos obtener el código fuente de libwebp para compilar, ya que no se requieren 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. Para ser honesto, le di todos los archivos C que pude encontrar y dependí del compilador para quitar todo lo que no era necesario. Parecía que funcionaba a la perfección.

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

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

Cómo obtener 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, ¿verdad? Hagamos eso.

La primera pregunta que debemos responder es la siguiente: ¿Cómo llevamos la imagen a Wasm land? Si observas la API de codificación de libwebp, esta espera un array de bytes en RGB, RGBA, BGR o BGRA. Afortunadamente, la API de Canvas tiene getImageData(), que nos proporciona 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, "solo" es cuestión de copiar los datos del contenedor de JavaScript a Wasm Land. Para eso, debemos exponer dos funciones adicionales. una que asigna memoria para la imagen dentro de Wasm land y otra que la libera de nuevo:

    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 vuelve a JavaScript, se lo trata como un solo 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 land. Es hora de llamar al codificador WebP para que haga su trabajo. Si observas la documentación de WebP, verás que WebPEncodeRGBA parece una 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 asigna un búfer de salida para nosotros, que necesitamos liberar con WebPFree() una vez que terminamos 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. Lo sé, no está limpio (de hecho, se basa en el hecho de que los punteros de Wasm tienen 32 bits de ancho), pero para que sea sencillo, creo que este es un buen atajo.

    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 puntero y el tamaño de la imagen, colocarlos en un búfer de JavaScript propio y 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 la imagen, podrías encontrarte con un error en el que Wasm no pueda 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 donde se muestra un error.

Por suerte, la solución a este problema está en el mensaje de error. Solo debemos 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 el 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);

Mira la gloria de una nueva imagen WebP.

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

Conclusión

Hacer que una biblioteca de C funcione en el navegador no es una simple caminata por el parque, pero una vez que entiendes el proceso general y cómo funciona el flujo de datos, todo se vuelve más fácil y los resultados pueden ser alucinantes.

WebAssembly abre muchas posibilidades nuevas en la Web para procesamiento, procesamiento de números y videojuegos. Ten en cuenta que Wasm no es una solución milagrosa que se pueda aplicar a todo, pero cuando llegas a uno de esos cuellos de botella, Wasm puede ser una herramienta increíblemente útil.

Contenido adicional: Cómo implementar algo simple

Si deseas 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 para trabajar, a menos que les proporciones memoria. La forma en que proporcionas cualquier cosa a un módulo de Wasm es con el objeto imports, el segundo parámetro de la función instantiateStreaming. El módulo de Wasm puede acceder a todo el contenido dentro del objeto de importaciones, pero a nada más fuera de él. Por convención, los módulos compilados por Emscripting esperan algunas cosas del entorno de carga de JavaScript:

  • En primer lugar, está env.memory. El módulo de Wasm no tiene en cuenta el mundo exterior, por así decirlo, por lo que necesita algo de memoria para trabajar. Ingresa WebAssembly.Memory. Representa una pieza de memoria lineal (que se puede aumentar de forma opcional). 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 una tiene un tamaño de 64 KiB. Si no proporcionas la opción maximum, en teoría no habrá límites de crecimiento para la memoria (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 funciones y asignar memoria para variables locales. Dado que no hacemos ninguna broma de administración de memoria dinámica en nuestro pequeño programa deFibonacci, podemos usar toda la memoria como una pila, por lo tanto, STACKTOP = 0.