Cómo reemplazar una ruta de acceso caliente en el JavaScript de tu app con WebAssembly

Es rápido y constante.

En mis artículos anteriores, expliqué cómo WebAssembly te permite llevar el ecosistema de bibliotecas de C/C++ a la Web. Una app que hace un uso intensivo de las bibliotecas C/C++ es squoosh, nuestra app web que te permite comprimir imágenes con una variedad de códecs que se compilaron de C++ a WebAssembly.

WebAssembly es una máquina virtual de bajo nivel que ejecuta el código de bytes almacenado en archivos .wasm. Este código de bytes tiene un tipo definido y está estructurado de tal manera que se puede compilar y optimizar para el sistema host mucho más rápido que JavaScript. WebAssembly proporciona un entorno para ejecutar código que tuvo en cuenta la zona de pruebas y la incorporación desde el principio.

En mi experiencia, la mayoría de los problemas de rendimiento en la Web se deben a un diseño forzado y a una pintura excesiva, pero, de vez en cuando, una app necesita realizar una tarea computacionalmente costosa que lleva mucho tiempo. WebAssembly puede ayudarte aquí.

La ruta de acceso caliente

En squoosh, escribimos una función de JavaScript que rota un búfer de imagen en múltiplos de 90 grados. Si bien OffscreenCanvas sería ideal para esto, no es compatible con todos los navegadores para los que segmentamos la publicidad y tiene algunos errores en Chrome.

Esta función itera por cada píxel de una imagen de entrada y lo copia a una posición diferente en la imagen de salida para lograr la rotación. Para una imagen de 4094 px por 4096 px (16 megapíxeles), se necesitarían más de 16 millones de iteraciones del bloque de código interno, lo que llamamos una "ruta de acceso directa". A pesar de esa gran cantidad de iteraciones, dos de los tres navegadores que probamos terminan la tarea en 2 segundos o menos. Es una duración aceptable para este tipo de interacción.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Sin embargo, un navegador tarda más de 8 segundos. La forma en que los navegadores optimizan JavaScript es muy complicada, y los diferentes motores realizan optimizaciones para diferentes aspectos. Algunos optimizan la ejecución sin procesar, mientras que otros optimizan la interacción con el DOM. En este caso, encontramos una ruta no optimizada en un navegador.

Por otro lado, WebAssembly se compila por completo en función de la velocidad de ejecución sin procesar. Por lo tanto, si queremos un rendimiento rápido y predecible en todos los navegadores para un código como este, WebAssembly puede ayudar.

WebAssembly para un rendimiento predecible

En general, JavaScript y WebAssembly pueden alcanzar el mismo rendimiento máximo. Sin embargo, en el caso de JavaScript, solo se puede alcanzar este rendimiento en la “ruta rápida”, y a menudo es difícil mantenerse en esa “ruta rápida”. Uno de los beneficios clave que ofrece WebAssembly es el rendimiento predecible, incluso en todos los navegadores. La tipificación estricta y la arquitectura de bajo nivel permiten que el compilador ofrezca garantías más sólidas para que el código de WebAssembly solo se tenga que optimizar una vez y siempre use la "ruta rápida".

Cómo escribir para WebAssembly

Anteriormente, tomamos bibliotecas de C/C++ y las compilamos en WebAssembly para usar su funcionalidad en la Web. En realidad, no tocamos el código de las bibliotecas, solo escribimos pequeñas cantidades de código C/C++ para formar el puente entre el navegador y la biblioteca. Esta vez, nuestra motivación es diferente: queremos escribir algo desde cero teniendo en cuenta WebAssembly para poder aprovechar las ventajas que tiene.

Arquitectura de WebAssembly

Cuando escribes para WebAssembly, es beneficioso comprender un poco más sobre lo que es en realidad.

Según WebAssembly.org:

Cuando compilas un código C o Rust en WebAssembly, obtienes un archivo .wasm que contiene una declaración de módulo. Esta declaración consta de una lista de “importaciones” que el módulo espera de su entorno, una lista de exportaciones que este módulo pone a disposición del host (funciones, constantes, fragmentos de memoria) y, por supuesto, las instrucciones binarias reales para las funciones que contiene.

Algo de lo que no me di cuenta hasta que investigué esto: La pila que hace que WebAssembly sea una "máquina virtual basada en pila" no se almacena en el fragmento de memoria que usan los módulos de WebAssembly. La pila es completamente interna a la VM y los desarrolladores web no pueden acceder a ella (excepto a través de las Herramientas para desarrolladores). Por lo tanto, es posible escribir módulos de WebAssembly que no necesiten memoria adicional y que solo usen la pila interna de la VM.

En nuestro caso, necesitaremos usar un poco de memoria adicional para permitir el acceso arbitrario a los píxeles de nuestra imagen y generar una versión rotada de esa imagen. Para eso sirve WebAssembly.Memory.

Administración de la memoria

Por lo general, una vez que uses memoria adicional, necesitarás administrarla de alguna manera. ¿Qué partes de la memoria están en uso? ¿Cuáles son gratuitos? En C, por ejemplo, tienes la función malloc(n) que encuentra un espacio de memoria de n bytes consecutivos. Las funciones de este tipo también se denominan "asignadores". Por supuesto, la implementación del asignador en uso debe incluirse en tu módulo de WebAssembly y aumentará el tamaño del archivo. El tamaño y el rendimiento de estas funciones de administración de memoria pueden variar bastante según el algoritmo que se use, por lo que muchos lenguajes ofrecen varias implementaciones para elegir ("dmalloc", "emmalloc", "wee_alloc", etc.).

En nuestro caso, conocemos las dimensiones de la imagen de entrada (y, por lo tanto, las dimensiones de la imagen de salida) antes de ejecutar el módulo de WebAssembly. Aquí, vimos una oportunidad: tradicionalmente, pasábamos el búfer RGBA de la imagen de entrada como un parámetro a una función de WebAssembly y devolvíamos la imagen rotada como un valor devuelto. Para generar ese valor que se muestra, tendríamos que usar el asignador. Sin embargo, como conocemos la cantidad total de memoria necesaria (el doble del tamaño de la imagen de entrada, una vez para la entrada y otra para la salida), podemos colocar la imagen de entrada en la memoria de WebAssembly con JavaScript, ejecutar el módulo de WebAssembly para generar una segunda imagen rotada y, luego, usar JavaScript para volver a leer el resultado. Podemos hacerlo sin usar ninguna administración de memoria.

No hay escasez de opciones

Si observaste la función original de JavaScript que queremos convertir a WebAssembly, puedes ver que es un código puramente computacional sin APIs específicas de JavaScript. Por lo tanto, debería ser bastante sencillo portar este código a cualquier idioma. Evaluamos 3 lenguajes diferentes que se compilan en WebAssembly: C/C++, Rust y AssemblyScript. La única pregunta que debemos responder para cada uno de los lenguajes es: ¿Cómo accedemos a la memoria sin procesar sin usar funciones de administración de memoria?

C y Emscripten

Emscripten es un compilador C para el destino WebAssembly. El objetivo de Emscripten es funcionar como un reemplazo directo de compiladores C conocidos, como GCC o clang, y es compatible con la mayoría de las marcas. Esta es una parte fundamental de la misión de Emscripten, ya que su objetivo es hacer que la compilación del código C y C++ existente en WebAssembly sea lo más fácil posible.

Acceder a la memoria sin procesar es parte de la naturaleza de C, y los punteros existen por ese mismo motivo:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Aquí convertimos el número 0x124 en un puntero a números enteros (o bytes) de 8 bits sin signo. Esto convierte, de manera efectiva, la variable ptr en un array que comienza en la dirección de memoria 0x124, que podemos usar como cualquier otro array, lo que nos permite acceder a bytes individuales para leer y escribir. En nuestro caso, estamos viendo un búfer RGBA de una imagen que queremos reordenar para lograr la rotación. Para mover un píxel, en realidad, debemos mover 4 bytes consecutivos a la vez (un byte para cada canal: R, G, B y A). Para facilitar esto, podemos crear un array de números enteros de 32 bits sin firma. Por convención, nuestra imagen de entrada comenzará en la dirección 4 y nuestra imagen de salida comenzará directamente después de que finalice la imagen de entrada:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Después de portar toda la función de JavaScript a C, podemos compilar el archivo C con emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Como siempre, emscripten genera un archivo de código de unión llamado c.js y un módulo wasm llamado c.wasm. Ten en cuenta que el módulo wasm se comprime a solo alrededor de 260 bytes, mientras que el código de unión es de alrededor de 3.5 KB después de la compresión. Después de algunos ajustes, pudimos descartar el código de unión y crear instancias de los módulos de WebAssembly con las APIs de Vanilla. Esto suele ser posible con Emscripten, siempre que no uses nada de la biblioteca estándar de C.

Rust

Rust es un lenguaje de programación nuevo y moderno con un sistema de tipos enriquecido, sin tiempo de ejecución y un modelo de propiedad que garantiza la seguridad de la memoria y de los subprocesos. Rust también admite WebAssembly como una función principal, y el equipo de Rust contribuyó con muchas herramientas excelentes al ecosistema de WebAssembly.

Una de estas herramientas es wasm-pack, del grupo de trabajo rustwasm. wasm-pack toma tu código y lo convierte en un módulo compatible con la Web que funciona listo para usar con agrupadores como webpack. wasm-pack es una experiencia extremadamente conveniente, pero, por el momento, solo funciona para Rust. El grupo está considerando agregar compatibilidad con otros lenguajes orientados a WebAssembly.

En Rust, los fragmentos son lo que los arrays son en C. Y, al igual que en C, debemos crear fragmentos que usen nuestras direcciones de inicio. Esto va en contra del modelo de seguridad de la memoria que aplica Rust, por lo que, para lograr nuestro objetivo, debemos usar la palabra clave unsafe, que nos permite escribir código que no cumple con ese modelo.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Compila los archivos de Rust con

$ wasm-pack build

genera un módulo wasm de 7.6 KB con alrededor de 100 bytes de código de unión (ambos después de gzip).

AssemblyScript

AssemblyScript es un proyecto bastante nuevo que pretende ser un compilador de TypeScript a WebAssembly. Sin embargo, es importante tener en cuenta que no consumirá ningún TypeScript. AssemblyScript usa la misma sintaxis que TypeScript, pero cambia la biblioteca estándar por la suya. Su biblioteca estándar modela las capacidades de WebAssembly. Eso significa que no puedes compilar cualquier TypeScript que tengas en WebAssembly, pero significa que no tienes que aprender un lenguaje de programación nuevo para escribir WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Teniendo en cuenta la pequeña superficie de tipo que tiene nuestra función rotate(), fue bastante fácil portar este código a AssemblyScript. AssemblyScript proporciona las funciones load<T>(ptr: usize) y store<T>(ptr: usize, value: T) para acceder a la memoria sin procesar. Para compilar nuestro archivo AssemblyScript, solo debemos instalar el paquete npm AssemblyScript/assemblyscript y ejecutar

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript nos proporcionará un módulo wasm de alrededor de 300 bytes y sin código de unión. El módulo solo funciona con las APIs de WebAssembly básicas.

Intrusiones de WebAssembly

Los 7.6 KB de Rust son sorprendentemente grandes en comparación con los otros 2 lenguajes. Existen algunas herramientas en el ecosistema de WebAssembly que pueden ayudarte a analizar tus archivos de WebAssembly (independientemente del lenguaje con el que se crearon) y decirte qué sucede y ayudarte a mejorar tu situación.

Twiggy

Twiggy es otra herramienta del equipo de WebAssembly de Rust que extrae muchos datos valiosos de un módulo de WebAssembly. La herramienta no es específica de Rust y te permite inspeccionar elementos como el gráfico de llamadas del módulo, determinar las secciones que no se usan o son superfluas y descubrir qué secciones contribuyen al tamaño total del archivo de tu módulo. Esto se puede hacer con el comando top de Twiggy:

$ twiggy top rotate_bg.wasm
Captura de pantalla de la instalación de Twiggy

En este caso, podemos ver que la mayoría del tamaño de nuestro archivo proviene del asignador. Eso fue sorprendente, ya que nuestro código no usa asignaciones dinámicas. Otro factor importante es una subsección de "nombres de funciones".

wasm-strip

wasm-strip es una herramienta del kit de herramientas binarias de WebAssembly, o wabt para abreviar. Contiene un par de herramientas que te permiten inspeccionar y manipular módulos de WebAssembly. wasm2wat es un desemsamblador que convierte un módulo wasm binario en un formato legible. Wabt también contiene wat2wasm, que te permite volver a convertir ese formato legible por humanos en un módulo wasm binario. Si bien usamos estas dos herramientas complementarias para inspeccionar nuestros archivos de WebAssembly, descubrimos que wasm-strip es la más útil. wasm-strip quita secciones y metadatos innecesarios de un módulo de WebAssembly:

$ wasm-strip rotate_bg.wasm

Esto reduce el tamaño del archivo del módulo rust de 7.5 KB a 6.6 KB (después de gzip).

wasm-opt

wasm-opt es una herramienta de Binaryen. Toma un módulo de WebAssembly y trata de optimizarlo en función del tamaño y el rendimiento solo en función del código de bytes. Algunas herramientas, como Emscripten, ya ejecutan esta herramienta, pero otras no. Por lo general, es recomendable intentar ahorrar algunos bytes adicionales con estas herramientas.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Con wasm-opt, podemos quitar otro puñado de bytes para dejar un total de 6.2 KB después de gzip.

#![no_std]

Después de algunas consultas y de investigar, reescribimos nuestro código Rust sin usar la biblioteca estándar de Rust, con la función #![no_std]. Esto también inhabilita las asignaciones de memoria dinámica por completo, lo que quita el código del asignador de nuestro módulo. Compila este archivo de Rust con

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

generó un módulo wasm de 1.6 KB después de wasm-opt, wasm-strip y gzip. Si bien sigue siendo más grande que los módulos generados por C y AssemblyScript, es lo suficientemente pequeño como para considerarse ligero.

Rendimiento

Antes de sacar conclusiones solo en función del tamaño del archivo, ten en cuenta que emprendimos este recorrido para optimizar el rendimiento, no el tamaño del archivo. Entonces, ¿cómo medimos el rendimiento y cuáles fueron los resultados?

Cómo generar comparativas

A pesar de que WebAssembly es un formato de código de bytes de bajo nivel, aún debe enviarse a través de un compilador para generar código máquina específico del host. Al igual que JavaScript, el compilador funciona en varias etapas. En pocas palabras, la primera etapa es mucho más rápida en la compilación, pero tiende a generar un código más lento. Una vez que el módulo comienza a ejecutarse, el navegador observa qué partes se usan con frecuencia y las envía a través de un compilador más optimizado, pero más lento.

Nuestro caso de uso es interesante porque el código para rotar una imagen se usará una vez, quizás dos. Por lo tanto, en la gran mayoría de los casos, nunca obtendremos los beneficios del compilador de optimización. Esto es importante tenerlo en cuenta cuando se realizan comparativas. Ejecutar nuestros módulos de WebAssembly 10,000 veces en un bucle daría resultados poco realistas. Para obtener números realistas, debemos ejecutar el módulo una vez y tomar decisiones en función de los números de esa sola ejecución.

Comparación del rendimiento

Comparación de velocidad por idioma
Comparación de velocidad por navegador

Estos dos gráficos son vistas diferentes de los mismos datos. En el primer gráfico, comparamos por navegador y, en el segundo, por idioma utilizado. Ten en cuenta que elegí una escala de tiempo logarítmica. También es importante que todas las comparativas usen la misma imagen de prueba de 16 megapíxeles y la misma máquina host, excepto un navegador, que no se pudo ejecutar en la misma máquina.

Sin analizar demasiado estos gráficos, está claro que resolvimos nuestro problema de rendimiento original: todos los módulos de WebAssembly se ejecutan en alrededor de 500 ms o menos. Esto confirma lo que dijimos al principio: WebAssembly te brinda un rendimiento predecible. Sin importar el idioma que elijamos, la variación entre los navegadores y los idiomas es mínima. Para ser exactos, la desviación estándar de JavaScript en todos los navegadores es de alrededor de 400 ms, mientras que la desviación estándar de todos nuestros módulos de WebAssembly en todos los navegadores es de alrededor de 80 ms.

Esfuerzo

Otra métrica es la cantidad de esfuerzo que tuvimos que poner para crear e integrar nuestro módulo de WebAssembly en squoosh. Es difícil asignar un valor numérico al esfuerzo, por lo que no crearé ningún gráfico, pero me gustaría señalar lo siguiente:

AssemblyScript fue muy sencillo. No solo te permite usar TypeScript para escribir WebAssembly, lo que facilita mucho la revisión de código para mis colegas, sino que también produce módulos de WebAssembly sin pegamento que son muy pequeños y tienen un rendimiento decente. Es probable que las herramientas del ecosistema de TypeScript, como prettier y tslint, funcionen.

Rust en combinación con wasm-pack también es muy conveniente, pero se destaca más en proyectos de WebAssembly más grandes en los que se necesitan vinculaciones y administración de memoria. Tuvimos que desviarnos un poco del camino ideal para lograr un tamaño de archivo competitivo.

C y Emscripten crearon un módulo WebAssembly muy pequeño y de alto rendimiento listo para usar, pero sin la valentía de pasar al código de unión y reducirlo a lo esencial, el tamaño total (módulo WebAssembly + código de unión) termina siendo bastante grande.

Conclusión

Entonces, ¿qué lenguaje debes usar si tienes una ruta de acceso directa de JS y quieres que sea más rápida o más coherente con WebAssembly? Como siempre con las preguntas sobre el rendimiento, la respuesta es: Depende. ¿Qué enviamos?

Gráfico de comparación

Si comparamos la compensación de tamaño del módulo / rendimiento de los diferentes lenguajes que usamos, la mejor opción parece ser C o AssemblyScript. Decidimos enviar Rust. Existen varios motivos para esta decisión: todos los códecs que se enviaron en Squoosh hasta el momento se compilan con Emscripten. Queríamos ampliar nuestros conocimientos sobre el ecosistema de WebAssembly y usar un lenguaje diferente en producción. AssemblyScript es una alternativa sólida, pero el proyecto es relativamente nuevo y el compilador no es tan maduro como el de Rust.

Si bien la diferencia en el tamaño del archivo entre Rust y el tamaño de los otros lenguajes parece bastante drástica en el gráfico de dispersión, en realidad no es tan importante: cargar 500 B o 1.6 KB, incluso en 2 G, tarda menos de un décimo de segundo. Y, con suerte, Rust pronto cerrará la brecha en términos de tamaño del módulo.

En términos de rendimiento del entorno de ejecución, Rust tiene un promedio más rápido en todos los navegadores que AssemblyScript. En particular, en proyectos más grandes, Rust tendrá más probabilidades de producir un código más rápido sin necesidad de optimizaciones manuales. Pero eso no debería impedirte usar lo que te resulte más cómodo.

Dicho esto, AssemblyScript fue un gran descubrimiento. Permite que los desarrolladores web produzcan módulos de WebAssembly sin tener que aprender un lenguaje nuevo. El equipo de AssemblyScript ha sido muy receptivo y trabaja de forma activa para mejorar su cadena de herramientas. Definitivamente, seguiremos de cerca a AssemblyScript en el futuro.

Actualización: Rust

Después de publicar este artículo, Nick Fitzgerald del equipo de Rust nos recomendó su excelente libro Rust Wasm, que contiene una sección sobre la optimización del tamaño de los archivos. Seguir las instrucciones que se indican allí (en particular, habilitar las optimizaciones del tiempo de vinculación y el manejo manual de pánico) nos permitió escribir código Rust “normal” y volver a usar Cargo (el npm de Rust) sin aumentar el tamaño del archivo. El módulo de Rust termina con 370 B después de gzip. Para obtener más información, consulta la PR que abrí en Squoosh.

Agradecimientos especiales a Ashley Williams, Steve Klabnik, Nick Fitzgerald y Max Graey por toda su ayuda en este viaje.