Escribe una biblioteca de C a Wasm

A veces, quieres 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 como objetivo descubrir cómo compilar código C existente en Wasm. Se ha hablado un poco sobre el backend de Wasm de LLVM, así que comencé a investigar al respecto. Si bien puedes compilar programas simples 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 maduró para orientarse a Wasm y está en proceso de cambiar al backend oficial de LLVM de forma interna. Emscripten también proporciona una implementación compatible con Wasm de la biblioteca estándar de C. Usa Emscripten. Realiza mucho trabajo oculto, emula un sistema de archivos, proporciona administración de la memoria y une OpenGL con WebGL, muchas cosas que realmente no necesitas experimentar por tu cuenta.

Si bien puede parecer que tienes que preocuparte por el exceso de código (yo me preocupé), el compilador de 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 están trabajando para que sean aún más pequeños en el futuro.

Puedes obtener Emscripten siguiendo las instrucciones de su sitio web o usando Homebrew. Si eres fan de los comandos de 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>

Compila algo simple

Tomemos el ejemplo casi canónico de escribir una función en C que calcula el n-ésimoth 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 sorprenderte demasiado. Incluso si no conoces C, pero sí JavaScript, esperamos que puedas 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 está usando.

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. Más adelante, se brindará más información sobre esta función. -O3 le indica al compilador que realice una optimización 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 sin usar.

Después de ejecutar el comando, deberías obtener 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 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, con un peso de 19 KB (aproximadamente 5 KB comprimido 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 datos que se devuelve 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 "144" en la consola, que es el 12º número de Fibonacci.

El santo grial: Compilar una biblioteca de C

Hasta ahora, el código de C que escribimos se creó 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 suelen depender 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 disponible en GitHub, así como en una extensa documentación de la API. Ese es un buen punto de partida.

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

Para empezar 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 simple para probar si podemos obtener el código fuente de libwebp para compilar, ya que no necesitamos ningún parámetro 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, además, pasarle todos los archivos C de libwebp que necesita. Seré honesto: solo le di todos los archivos C que pude encontrar y confié en que el compilador quitaría todo lo que fuera innecesario. 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 corrección en el resultado:

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

Obtén 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? Hagámoslo.

La primera pregunta que debemos responder es: ¿Cómo llevamos la imagen al entorno de Wasm? Si observas la API de codificación de libwebp, verás que espera un array de bytes en RGB, RGBA, BGR o BGRA. Por suerte, la API de Canvas tiene getImageData(), que nos proporciona 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 asigne memoria para la imagen dentro del entorno de Wasm y otra que la libere 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 que se usan 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 al entorno de 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 el entorno de Wasm. Es hora de llamar al codificador de 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 asigna un búfer de salida para nosotros, 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 devuelven (a menos que asignemos memoria de forma dinámica), recurrí a un array global estático. Sé que no es C limpio (de hecho, se basa en el hecho de que los punteros de Wasm tienen un ancho de 32 bits), pero para mantener las cosas simples, 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á listo, 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 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 se produzca un error en el que Wasm no pueda aumentar la memoria lo suficiente para alojar la imagen de entrada y la de salida:

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

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

Lo logró. Compilamos un codificador de 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.

El panel de redes de Herramientas para desarrolladores y la imagen generada.

Conclusión

No es fácil lograr que una biblioteca de C funcione en el navegador, pero una vez que comprendes 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 cálculo de números y los juegos. Ten en cuenta que Wasm no es una solución mágica que deba aplicarse a todo, pero cuando te encuentres con uno de esos cuellos de botella, Wasm puede ser una herramienta increíblemente útil.

Contenido adicional: Ejecuta 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, 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 un módulo de Wasm con cualquier cosa es usando el objeto imports, el segundo parámetro de la función instantiateStreaming. El módulo de Wasm puede acceder a todo lo que se encuentra dentro del objeto de importaciones, pero a nada más fuera de él. Por convención, los módulos compilados por Emscripting esperan un par de cosas del entorno de carga de JavaScript:

  • En primer lugar, está env.memory. Por decirlo de alguna manera, el módulo de Wasm no conoce el mundo exterior, por lo que necesita obtener algo de memoria para trabajar. Ingresa WebAssembly.Memory. Representa un fragmento de memoria lineal (que puede crecer 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 página tiene un tamaño de 64 KiB. Sin proporcionar una opción maximum, la memoria tiene un crecimiento ilimitado en teoría (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 debe comenzar a crecer la pila. La pila es necesaria para realizar llamadas a funciones y asignar memoria para las variables locales. Como no hacemos ninguna maniobra de administración de la memoria dinámica en nuestro pequeño programa de Fibonacci, podemos usar toda la memoria como una pila, por lo tanto, STACKTOP = 0.