* notes / webassembly

WebAssembly: para qué sirve realmente (y para qué no)

Un repaso honesto a WASM tras varios experimentos: cuándo compensa, cuándo es puro fetichismo y por qué la frontera JS-WASM cuesta más cara de lo que parece.

January 8, 20265 min read#webassembly#rust#performance#navegador

Contexto

WebAssembly no nació para reemplazar a JavaScript, sino para complementarlo allí donde el dinamismo del lenguaje y el coste del recolector de basura empezaban a estorbar. Su modelo de ejecución es un máquina virtual de pila con tipos numéricos básicos, memoria lineal y una tabla de funciones; nada de objetos, nada de cadenas de caracteres nativas, nada de DOM. Esa restricción, que en principio parece una limitación, es precisamente lo que permite que el binario se descargue, valide y compile en un tiempo predecible.

Los casos de éxito conocidos (Figma, ffmpeg.wasm, SQLite en el navegador, el editor de Photoshop en línea, Google Earth) comparten un patrón: un núcleo computacional escrito originalmente en C++ o Rust, portado a WASM, con una fina capa de JavaScript encargada de la interfaz y de las operaciones de E/S. WASM brilla cuando hay que mover muchos bytes y calcular sobre ellos, y se vuelve estorbo cuando hay que ir y venir constantemente al DOM.

src/lib.rs#[wasm_bindgen]cargo buildtarget wasm32wasm-bindgenglue + .d.tspkg/module.jsES modulepkg/module_bg.wasmbinario

La frontera JS ↔ WASM tiene peaje

El malentendido más extendido es asumir que basta con compilar a WASM para ganar rendimiento. La realidad es más sutil: cada llamada cruza una frontera con un coste fijo, y si los datos son tipos no numéricos (cadenas, estructuras, arrays de objetos) hay que serializarlos. wasm-bindgen hace mucho trabajo invisible para que el desarrollador no vea esos memcpy, pero existen, y aparecen en el profiler en cuanto el bucle se vuelve interesante.

La regla pragmática que aplico es esta: si una función va a llamarse miles de veces por frame, que reciba un puntero y un tamaño a un buffer compartido y devuelva otro puntero; nunca pasar la cadena cada vez. Si la función es esporádica y opera sobre megabytes de datos, no hay coste perceptible.

A esto se añade el problema del arranque. Un binario WASM de 2 MB se descarga rápido, pero la fase de compilación e instanciación añade decenas o cientos de milisegundos en dispositivos modestos. WebAssembly.instantiateStreaming ayuda porque empieza a compilar mientras se descarga, pero no hace milagros. La consecuencia: WASM rara vez es la elección correcta para la primera pintura de la página.

Implementación: una función Rust expuesta al navegador

Para ilustrar el flujo, escribí un módulo Rust minimalista que calcula la mediana de un vector con el algoritmo de selección rápida. El objetivo no es la mediana (Array.prototype.sort la calcula sobrado para conjuntos pequeños), sino enseñar cómo se acopla con el JavaScript de la página.

use wasm_bindgen::prelude::*;
 
#[wasm_bindgen]
pub fn median_f32(input: &[f32]) -> f32 {
    if input.is_empty() {
        return f32::NAN;
    }
    let mut data: Vec<f32> = input.to_vec();
    let k = data.len() / 2;
    let (_, mid, _) = data.select_nth_unstable_by(k, |a, b| {
        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
    });
    *mid
}

Con wasm-pack build --target web --release obtenemos un paquete con un módulo ES, un archivo de tipos y el binario. La integración desde el navegador queda casi trivial:

import init, { median_f32 } from "./pkg/stats.js";
 
async function main() {
  await init();
  const data = new Float32Array(5_000_000);
  for (let i = 0; i < data.length; i++) {
    data[i] = Math.random() * 1000;
  }
  console.time("wasm");
  const m = median_f32(data);
  console.timeEnd("wasm");
  console.log("mediana:", m);
}
main();

En mi portátil, la versión WASM tardaba 38 ms frente a los 210 ms de una implementación pura en JavaScript con el mismo algoritmo. La diferencia justifica el esfuerzo en un escenario de procesamiento de señales, pero sería difícil defenderla en un formulario.

Threads, SharedArrayBuffer y los encabezados COOP/COEP

Habilitar hilos en WASM exige SharedArrayBuffer, y eso requiere que el servidor envíe encabezados de aislamiento cruzado:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Estos encabezados no son inocuos: rompen iframes sin crossorigin, complican la integración de scripts de terceros y, en sitios con anuncios o widgets sociales, pueden ser inviables. He visto equipos abandonar la idea de paralelizar tras descubrir que un único embed de un mapa rompía toda la página.

Hallazgos

  • WASM no acelera la web "porque sí". Si la tarea ya cabe en menos de 5 ms de JavaScript, el binario sólo añade peso.
  • El verdadero motivo para adoptarlo, en mi caso, fue reutilizar código existente: una librería de procesamiento de audio escrita en C que llevaba años en producción se compiló sin tocar una línea, y eso compensó cualquier consideración de rendimiento.
  • WASI abre la puerta a un futuro fuera del navegador (CDN edge runtimes, plugins sandboxed, software embebido) que para mí es más interesante hoy que el caso clásico web.
  • La depuración mejoró con DWARF y source maps, pero sigue sin ser placentera. Un panic mal capturado en Rust devuelve una traza ofuscada que requiere paciencia.
  • El tamaño se controla con wasm-opt -Oz, strip y --no-default-features, pero olvidarse de cualquiera de los tres convierte un módulo de 80 KB en uno de 1.2 MB.

Closure

Mi conclusión, después de varios proyectos en los que probé WASM por curiosidad y otros tantos en los que lo necesitaba de verdad, es que WebAssembly es una herramienta de nicho profundo. Cuando el nicho coincide con lo que tienes entre manos (compresión, criptografía, simulación, edición multimedia, ejecución de motores existentes) no hay nada comparable en la web. Cuando no coincide, JavaScript moderno con un buen JIT es más rápido de escribir, más fácil de depurar y, francamente, suficientemente rápido. La madurez se demuestra eligiendo la herramienta correcta, no la más vistosa.