transformers.js, ONNX Runtime Web y tfjs: comparé los tres motores de ML del navegador con números reales
Monté un benchmark propio para medir transformers.js, ONNX Runtime Web y tfjs corriendo la misma tarea de embeddings de frases, más una MLP idéntica en los dos runtimes que pueden ejecutarla de verdad. WASM le ganó a WebGPU en las dos tareas, el backend que elige tfjs importó más que el runtime elegido, y por el camino encontré un backend que ni siquiera arrancaba por defecto en esta máquina.

"¿Qué motor de ML para el navegador es más rápido?" es una pregunta que se responde mucho de oído y se mide poco, y casi nunca con el mismo modelo en los tres bandos. Monté un benchmark reproducible para medirlo de verdad: transformers.js, ONNX Runtime Web crudo y tfjs, en Chrome, en esta máquina, el mismo día. Dos tareas, no una — y la razón de por qué dos es, para mí, el resultado más importante de todo esto.
La primera tarea es embeddings de frases con MiniLM cuantizado — el mismo modelo que ya uso en la demo de RAG del laboratorio y en el buscador de esta web. transformers.js y ONNX Runtime Web crudo corren el fichero `model_quantized.onnx` exactamente igual, con el mismo tokenizador — la única diferencia real entre esas dos filas es cómo cada uno invoca la inferencia. Pero tfjs no tiene ningún puerto de MiniLM. En vez de dejarlo fuera de la tabla, lo dejé correr el Universal Sentence Encoder (un modelo distinto: 512 dimensiones en vez de 384, una red de promediado en vez de un transformer) y lo digo así de claro en el README: esa fila no es una comparación de runtime aislada, es "lo que cuesta la tarea de embeddings más habitual con tfjs", con un asterisco de modelo pegado encima.
Para tener una comparación de verdad limpia construí una segunda tarea: una MLP diminuta con los mismos pesos, byte a byte, en los dos runtimes que pueden ejecutarla. Un script en Python genera `Linear(384,256) → ReLU → Linear(256,128) → ReLU → Linear(128,64)` con semilla fija (139.712 parámetros) y lo exporta dos veces: a JSON para que tfjs construya el modelo en caliente, y a un grafo ONNX para ONNX Runtime Web. transformers.js no entra en esta tabla — es una capa que carga modelos de Hugging Face, no un runtime genérico de tensores, y forzarlo a correr una MLP inventada habría sido hacer trampa con la herramienta. Comprobé los pesos tres veces: el script de construcción del ONNX los valida contra una fijación de referencia con onnxruntime, un test en JavaScript puro repite el forward pass a mano, y las cinco primeras cifras del resultado real de cada runtime coinciden con esa referencia hasta la quinta cifra decimal.
El harness son dos páginas HTML sin build, servidas con `python3 -m http.server`. Cada combinación se midió en 3 sesiones de navegador nuevas — caché desactivada en cada una, así que cada sesión vuelve a descargar el modelo de verdad — con 10 pasadas de calentamiento y 50 medidas para latencia por lote de uno, y 3 más 10 para lote de ocho. Fijé `numThreads` a 1 en ambos runtimes basados en ONNX Runtime Web para que el resultado no dependiera de si el servidor manda las cabeceras `Cross-Origin-Opener-Policy` que el WASM con hilos necesita — `python3 -m http.server` no las manda, y prefería un número reproducible con cualquier servidor estático a uno más rápido pero frágil.
El primer tropiezo fue tonto y real: el paquete de tfjs para el Universal Sentence Encoder apunta por defecto a TF Hub, y TF Hub ya no existe. `tfhub.dev` redirige ahora a Kaggle Models, así que el loader por defecto del paquete descarga una página HTML de Kaggle en vez de un modelo. La solución fue rodear ese default muerto y apuntar directamente al checkpoint alojado en `storage.googleapis.com/tfjs-models`, que sigue vivo y pesa 28 MB. No es un hallazgo sobre runtimes de ML, es un hallazgo sobre qué tan rápido se pudren los defaults de una librería de 2021 — y lo documento en el propio código del runner porque a mí me costó media hora entenderlo.
El segundo tropiezo fue más serio: WebGPU no arrancaba, punto, en esta máquina con una RTX 3050 real. `navigator.gpu.requestAdapter()` devolvía `null` en un Chrome normal, y `chrome://gpu` confirmaba por qué: Vulkan aparecía como "Disabled" pese a tener el ICD de NVIDIA instalado y WebGL funcionando perfectamente con la GPU real de por medio. La solución fue lanzar Chrome a mano con `--enable-features=Vulkan --use-vulkan=native`, lo que produce todas las cifras de WebGPU de este benchmark. Si abres el harness en un Chrome por defecto en Linux, la opción WebGPU reventará con "Failed to get GPU adapter" — y eso también es parte del resultado, no un error del harness.
Con todo eso resuelto, el hallazgo central: WASM le ganó a WebGPU en las dos tareas, siempre, y por mucho. Para MiniLM, WASM corrió entre 12 y 12,2 ms frente a los 158-173 ms de WebGPU, entre 13 y 14 veces más rápido. Para la MLP diminuta la diferencia es todavía más bestia: 0,10 ms en WASM contra 4,50 ms en WebGPU, 45 veces. Nada de esto es un fallo de WebGPU — está documentado en la comunidad de ambos runtimes que WebGPU solo compensa cuando el modelo o el lote son lo bastante grandes como para que el coste fijo de cada despacho a la GPU deje de dominar. Con lote de ocho la brecha se reduce (2,9 veces en vez de 13 para MiniLM) pero WASM sigue ganando — ninguno de los tamaños de este benchmark fue lo bastante grande como para invertir esa historia.
El backend que elige tfjs importó más que el runtime que elegí. Dentro del propio tfjs, WebGL contra CPU es una brecha de 18 veces en latencia para el Universal Sentence Encoder (15,95 ms contra 291,75 ms) y de 76 veces en throughput por lote (305 contra 4 elementos por segundo) — mayor que la brecha entre dos runtimes *distintos* en cualquiera de las dos tablas. Si una app real cae en el backend CPU de tfjs sin darse cuenta (pasa fácil: `tf.setBackend` no siempre se llama explícito, y la inicialización de WebGL puede fallar en silencio en una máquina sin GPU), esa caída de rendimiento eclipsa cualquier decisión sobre qué runtime usar.
Carga en frío y latencia por llamada apuntan en direcciones distintas para tfjs. El Universal Sentence Encoder carga en unos 228 ms, entre 4 y 6 veces más rápido que MiniLM por cualquiera de los dos caminos de ONNX Runtime Web (1.000-1.400 ms) — y esa diferencia es real, no un efecto de red, porque los dos modelos están vendorizados en local y el tiempo lo domina el parseo y la construcción de la sesión, no la descarga. Pero en latencia por llamada, con WASM como el backend más rápido medido, ONNX Runtime Web sigue ganando (12 ms contra 16 ms de tfjs+WebGL). Una app que crea una sesión y la usa muchas veces vive de ese segundo número; una que embebe una sola consulta y tira la sesión vive del primero.
Todo esto — la metodología completa, la tabla de resultados con las tres sesiones crudas por combinación, la sección "What this does not tell you" con cada límite que encontré (una sola máquina, el asterisco del modelo en tfjs, el WebGPU que necesitó forzar Vulkan, la memoria aproximada que solo cuenta el heap de JS) y los scripts exactos para reproducirlo — está en browser-runtimes-bench. No lo monté para decidir cuál es "el mejor runtime": lo monté porque esa pregunta, tal como se suele hacer, no tiene una respuesta honesta sin decir primero qué se mantuvo igual y qué no. Si venías de la guía de ML en el navegador, esto es la otra mitad: no solo cómo se sirve un modelo en el navegador, sino qué pasa por dentro cuando dos runtimes distintos lo ejecutan.
