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

Es siempre rápido,

En mis artículos anteriores, hablé sobre cómo WebAssembly te permite llevar el ecosistema de bibliotecas de C/C++ a la Web. Una app que usa las bibliotecas C/C++ de manera extensiva es squoosh, nuestra app web que te permite comprimir imágenes con una variedad de códecs compilados 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 está bien escrito y estructurado de forma 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.

Según mi experiencia, la mayoría de los problemas de rendimiento en la Web se deben al diseño forzado y el exceso de pintura, pero, de vez en cuando, una app necesita realizar una tarea costosa en términos de procesamiento que lleva mucho tiempo. WebAssembly puede ayudar aquí.

El camino más popular

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 los navegadores a los que orientábamos su anuncio y, además, tiene un error en Chrome.

Esta función itera en cada píxel de una imagen de entrada y la copia en una posición diferente en la imagen de salida para lograr la rotación. Para una imagen de 4,094 por 4,096 píxeles (16 megapíxeles), necesitaría más de 16 millones de iteraciones del bloque de código interno, que es lo que llamamos una "ruta de acceso caliente". A pesar de la cantidad bastante alta de iteraciones, dos de los tres navegadores que probamos completan 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 realmente complicada, y los diferentes motores lo optimizan en función de diferentes elementos. Algunos optimizan la ejecución sin procesar y otros lo hacen para la interacción con el DOM. En este caso, encontramos una ruta de acceso no optimizada en un navegador.

WebAssembly, por otro lado, se basa completamente en 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 lograr el mismo rendimiento máximo. Sin embargo, en el caso de JavaScript, este rendimiento solo se puede alcanzar en la "ruta rápida", y a menudo resulta difícil mantener esa "ruta rápida". Un beneficio clave que ofrece WebAssembly es el rendimiento predecible, incluso en todos los navegadores. La escritura estricta y la arquitectura de bajo nivel permiten que el compilador haga garantías más sólidas, de modo que el código de WebAssembly solo se deba optimizar una vez y siempre use la "ruta rápida".

Escribe para WebAssembly

Anteriormente, tomamos bibliotecas C/C++ y las compilamos en WebAssembly para usar sus funciones en la Web. Realmente 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 WebAssembly.

Arquitectura de WebAssembly

Cuando escribes para WebAssembly, es conveniente comprender un poco más acerca de qué es realmente WebAssembly.

Cita de WebAssembly.org:

Cuando compilas un fragmento de código C o Rust en WebAssembly, obtienes un archivo .wasm que contiene una declaración de módulo. Esta declaración consiste en 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 que no me di cuenta hasta que exploré esto: la pila que convierte WebAssembly en una "máquina virtual basada en pilas" no se almacena en el fragmento de memoria que usan los módulos de WebAssembly. La pila es completamente interna de la VM y no puede acceder a ella para los desarrolladores web (excepto mediante Herramientas para desarrolladores). Por lo tanto, es posible escribir módulos de WebAssembly que no necesitan memoria adicional y solo usar la pila interna de la VM.

En nuestro caso, necesitaremos usar memoria adicional para permitir el acceso arbitrario a los píxeles de nuestra imagen y generar una versión rotada de esa imagen. Para esto 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 de tu archivo. El tamaño y el rendimiento de estas funciones de administración de memoria pueden variar de forma bastante significativa en función del algoritmo utilizado, por lo que muchos lenguajes ofrecen varias implementaciones entre las que elegir ("dmalloc", "emmalloc", "wee_alloc", etcétera).

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í observamos una oportunidad: tradicionalmente, pasamos el búfer RGBA de la imagen de entrada como parámetro a una función de WebAssembly y se muestra la imagen rotada como un valor de retorno. Para generar ese valor de retorno, tendríamos que usar el asignador. Sin embargo, como conocemos la cantidad total de memoria necesaria (el doble de la imagen de entrada, una 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 leer el resultado. Podemos escapar sin usar ningún tipo de administración de memoria.

Nada mal a elección

Si observaste la función original de JavaScript que queremos para WebAssembly-fy, puedes ver que es un código puramente computacional sin APIs específicas de JavaScript. Por lo tanto, debería ser bastante sencillo transferir este código a cualquier lenguaje. 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 la siguiente: ¿cómo accedemos a la memoria sin procesar sin usar funciones de administración de memoria?

C y Emscripten

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

El acceso a la memoria sin procesar es parte de la naturaleza de C, y los punteros existen por esa misma razón:

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

Aquí, convertiremos el número 0x124 en un puntero para números enteros de 8 bits (o bytes) sin firma. Esto convierte efectivamente 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 lectura y escritura. En este caso, buscamos el búfer RGBA de una imagen que queremos reordenar para lograr la rotación. Para mover un píxel, necesitamos mover 4 bytes consecutivos a la vez (un byte para cada canal: R, G, B y A). Para facilitar este proceso, 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 la 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 transferir 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 adhesión llamado c.js y un módulo de Wasm llamado c.wasm. Ten en cuenta que el módulo de wasm se comprime en gzip a solo 260 bytes, mientras que el código de adhesión es de alrededor de 3.5 KB después de gzip. Después de algunos problemas, pudimos descartar el código de adhesión y crear una instancia de los módulos de WebAssembly con las APIs básicas. Esto suele ser posible con Emscripten, siempre y cuando no uses nada de la biblioteca C estándar.

Rust

Rust es un lenguaje de programación nuevo y moderno con un sistema de tipos enriquecidos, sin entorno de ejecución y un modelo de propiedad que garantiza la seguridad de la memoria y de los subprocesos. Rust también es compatible con WebAssembly como 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 de rustwasm. wasm-pack toma tu código y lo convierte en un módulo compatible con la Web que funciona de inmediato 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 idiomas de segmentación de WebAssembly.

En Rust, las porciones son los arrays que están en C. Al igual que en C, tenemos que crear porciones que usen nuestras direcciones de inicio. Esto va en contra del modelo de seguridad de la memoria que aplica Rust, por lo que, para hacerlo, tenemos que usar la palabra clave unsafe, lo 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;
    }
}

Compilar los archivos de Rust con

$ wasm-pack build

produce un módulo de wasm de 7.6 KB con aproximadamente 100 bytes de código de adhesión (ambos después de gzip).

AssemblyScript

AssemblyScript es un proyecto relativamente nuevo que apunta a ser un compilador de TypeScript a WebAssembly. Sin embargo, es importante tener en cuenta que no consumirá elementos de TypeScript. AssemblyScript usa la misma sintaxis que TypeScript, pero cambia la biblioteca estándar por una propia. Su biblioteca estándar modela las capacidades de WebAssembly. Eso significa que no puedes compilar cualquier TypeScript que tengas por delante en WebAssembly, pero significa que no necesitas 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 superficie de tipo pequeña que tiene nuestra función rotate(), fue bastante fácil transferir 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 necesitamos instalar el paquete npm AssemblyScript/assemblyscript y ejecutarlo

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

AssemblyScript nos proporcionará un módulo wasm de aproximadamente 300 bytes y sin código de adhesión. El módulo simplemente funciona con las APIs convencionales de WebAssembly.

Investigación forense de WebAssembly

El tamaño de 7.6 KB de Rust es sorprendentemente grande en comparación con los otros 2 lenguajes. Hay un par de herramientas en el ecosistema de WebAssembly que pueden ayudarte a analizar tus archivos de WebAssembly (sin importar el lenguaje con el que se crearon) y decirte lo que está sucediendo y también ayudarte a mejorar tu situación.

Twiggy

Twiggy es otra herramienta del equipo de WebAssembly de Rust que extrae muchos datos útiles 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 sin usar o superfluas y descubrir qué secciones contribuyen al tamaño total del archivo de tu módulo. Esto último 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 parte 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 la subsección “nombres de funciones”.

tira de impermeables

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

$ wasm-strip rotate_bg.wasm

Esto reduce el tamaño del archivo del módulo de 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 para el tamaño y el rendimiento solo en función del código de bytes. Algunas herramientas, como Emscripten, ya ejecutan esa herramienta, otras no. Por lo general, es una buena idea ahorrar algunos bytes adicionales con estas herramientas.

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

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

#![no_std]

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

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

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

Rendimiento

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

Cómo obtener comparativas

A pesar de que WebAssembly es un formato de código de bytes de bajo nivel, se debe enviar 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 práctico es interesante porque el código para rotar una imagen se usará una vez, quizás dos veces. Por lo tanto, en la gran mayoría de los casos, nunca obtendremos los beneficios del compilador de optimización. Es importante tener esto en cuenta cuando realices comparativas. Ejecutar nuestros módulos de WebAssembly 10,000 veces de forma indefinida daría resultados poco realistas. Para obtener cifras realistas, debemos ejecutar el módulo una vez y tomar decisiones basadas en las cifras de esa única 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, en el segundo gráfico comparamos por idioma utilizado. Ten en cuenta que elegí una escala de tiempo logarítmica. También es importante que todas las comparativas usaran la misma imagen de prueba de 16 megapíxeles y la misma máquina anfitrión, excepto un navegador, que no se pudo ejecutar en la misma máquina.

Si no se analizan demasiado los gráficos, está claro que resolvimos el problema de rendimiento original: todos los módulos de WebAssembly se ejecutan en ~500 ms o menos. Esto confirma lo que presentamos al comienzo: WebAssembly te brinda un rendimiento predecible. Independientemente del idioma que elegimos, 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 unos 400 ms, mientras que la desviación estándar de todos nuestros módulos de WebAssembly en todos los navegadores es de aproximadamente 80 ms.

Esfuerzo

Otra métrica es el esfuerzo que tuvimos que invertir 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 hay algunas cosas que me gustaría señalar:

AssemblyScript no tuvo inconvenientes. No solo te permite usar TypeScript a fin de escribir WebAssembly, lo que facilita mucho la revisión de código para mis colegas, sino que también produce módulos WebAssembly sin adhesión que son muy pequeños y tienen un rendimiento decente. Es probable que las herramientas del ecosistema de TypeScript, como Prettier y Tlint, funcionen correctamente.

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 el coraje de entrar en el código de adhesión y reducirlo a las necesidades básicas, el tamaño total (módulo de WebAssembly + código de adhesión) termina siendo bastante grande.

Conclusión

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

Gráfico de comparación

En comparación con la compensación entre el tamaño del módulo y el rendimiento de los diferentes lenguajes que usamos, la mejor opción parece ser C o AssemblyScript. Decidimos lanzar Rust. Esta decisión se debe a varios motivos: todos los códecs enviados en Squoosh se compilan con Emscripten. Queríamos ampliar nuestro conocimiento sobre el ecosistema de WebAssembly y usar un lenguaje diferente en producción. AssemblyScript es una alternativa sólida, pero el proyecto es relativamente reciente y el compilador no está tan consolidado como el de Rust.

Si bien la diferencia en el tamaño del archivo entre Rust y los otros lenguajes de programación parece bastante drástica en el gráfico de dispersión, no es un problema en realidad: cargar 500 B o 1.6 KB, incluso en 2G, lleva menos de una décima de segundo. Esperamos que Rust pronto reduzca la brecha en cuanto al tamaño de los módulos.

En términos de rendimiento del entorno de ejecución, Rust tiene un promedio más rápido en todos los navegadores que AssemblyScript. Especialmente en proyectos más grandes, es más probable que Rust produzca código más rápido sin la 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 fue muy receptivo y está trabajando activamente para mejorar su cadena de herramientas. Estaremos atentos a AssemblyScript en el futuro.

Actualización: Rust

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

Un agradecimiento especial a Ashley Williams, Steve Klabnik, Nick Fitzgerald y Max Graey por toda su ayuda en este recorrido.