Los modelos de aprendizaje automático ya se ejecutan en el navegador. Y no de juguete: un modelo de separación de fuentes que divide una canción en voz e instrumental, y un motor de texto a voz con clonación de voz, ambos compilados a WebAssembly y ejecutados en el propio procesador del visitante. Sin subidas, sin servidor de inferencia, sin factura de GPU.
La inferencia resultó ser la mitad fácil. Llevar 130 MB de modelo a un alojamiento estático, y conseguir que el navegador aceptara ejecutarlo, produjo cuatro fallos. Tres no dan ningún mensaje de error útil. Uno da justo lo contrario de la verdad.
Todas las cifras de abajo están medidas sobre los artefactos desplegados, no estimadas.
El punto de partida
Una compilación de WebAssembly con pthreads necesita SharedArrayBuffer. Los navegadores solo lo exponen a páginas con aislamiento de origen cruzado activado, lo que significa tres cabeceras de respuesta:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
La tercera es la interesante, y es donde la mayoría de los artículos se equivocan. Con COEP: require-corp, los workers de pthreads de Emscripten y el worker anidado que lanza el motor piden sus propios subrecursos desde lo que el navegador trata como un origen opaco. Cross-Origin-Resource-Policy: same-origin los bloquea. Tiene que ser cross-origin, que es un valor más permisivo de lo que dicta el instinto, limitado al único prefijo de ruta que lo necesita en lugar de aplicarse a todo el sitio, porque cross-origin también permite que cualquier sitio incruste esos recursos.
En Cloudflare Pages eso se configura en un archivo _headers en la raíz del despliegue. Y ahí es donde está el primer fallo.
Fallo 1: Pages fusiona las cabeceras con el mismo nombre, y COOP es un único token
Para ir sobre seguro, declaré tanto el directorio como todo lo que hay debajo:
/ai-text-to-speech/
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
/ai-text-to-speech/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Las dos reglas coinciden con el documento. Pages no deja ganar a la regla más específica — fusiona las cabeceras con el mismo nombre en un único valor separado por comas:
cross-origin-opener-policy: same-origin, same-origin
cross-origin-embedder-policy: require-corp, require-corp
COOP y COEP son cabeceras estructuradas de un solo token. Ese valor no se puede analizar, y el navegador hace lo peor posible con una cabecera que no puede analizar: recurre a unsafe-none en silencio. Ni un aviso en la consola sobre la cabecera. Lo que recibes en su lugar, desde un sitio completamente distinto, es:
DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated
Nada en ese mensaje apunta a una cabecera de respuesta, y mucho menos a una duplicada.
Dos cosas para llevarse:
*coincide también con la cadena vacía./prefix/*ya coincide con/prefix/en sí. La regla extra de ruta exacta no era una precaución de más; era el error.- La fusión es inofensiva para
Cache-Control, que es una lista de directivas. Es fatal para cualquier cabecera cuya gramática sea un único token.
La duplicación es también la forma en que demostré lo primero: ver cada cabecera aparecer exactamente dos veces solo es posible si coincidieron ambos patrones.
Mi script de despliegue ahora hace fallar la compilación si algún archivo coincide con dos reglas que fijan la misma cabecera. Esa comprobación cuesta veinte líneas y me habría ahorrado un día.
Fallo 2: la caché HTTP guarda las cabeceras de respuesta, no solo los cuerpos
Con el archivo _headers ya corregido y desplegado, seguía fallando. La misma página, la misma clase de error, pero ahora desde otro sitio: el worker ni siquiera arrancaba. El ErrorEvent tenía un message vacío, un filename vacío y el número de línea 0. Se disparaba unos 4 ms después de crear el worker.
La causa: la caché HTTP del navegador tenía una copia de tts-worker.js obtenida
antes de que existieran las cabeceras de aislamiento. Las respuestas en caché conservan las cabeceras con las que se guardaron, y esa entrada tenía un max-age de cuatro horas. Una página con aislamiento de origen cruzado se niega a ejecutar un worker cuyo propio script no lleva
COEP: require-corp — y se niega sin dar ningún detalle.
Así que el servidor estaba bien, la pestaña de red parecía estar bien, y la página cargaba una respuesta de hace cuatro horas que no lo estaba.
El diagnóstico que lo resolvió, y que vale la pena tener a mano:
const headers = async (url, cache) =>
[...(await fetch(url, { cache })).headers]
.filter(([k]) => k.startsWith('cross-origin'));
console.log('fresh ', await headers(url, 'no-store'));
console.log('cached', await headers(url, 'force-cache'));
Si esos dos no coinciden, estás ante una entrada de caché envenenada, no ante un problema del servidor. En mi caso, fresh tenía las tres cabeceras y cached no tenía ninguna.
Dos arreglos, porque cualquiera de ellos por separado no basta:
Cache-Control: no-cacheen los archivos del front-end. Pesan entre 13 y 31 KB; revalidarlos no cuesta nada comparado con una caída de cuatro horas que ningún cambio en el servidor puede terminar.- Un token de versión en la URL del worker (
tts-worker.js?v=…) para poder esquivar una entrada ya envenenada. Súbelo cada vez que cambien las cabeceras de aislamiento.
Hay un corolario que me costó más tiempo que el propio error: mi servidor de desarrollo local trataba cualquier URL con ?v= como immutable. Así que en cuanto añadí un token de versión, el entorno local dejó de recoger los cambios del worker y, peor aún, local y producción se comportaban distinto justo en la zona que estaba depurando.
Lo que lo hizo difícil fue la diferencia entre la caché local y la de producción, no la caché en sí.
Fallo 3: un muro de 25 MiB por archivo
Cloudflare Pages rechaza cualquier archivo individual de más de 25 MiB. El archivo de modelo más grande pesa 82,0 MiB. Otro, 37,9 MiB.
Dividirlo es lo obvio; lo que vale la pena apuntar es dónde queda la fuente de verdad. La herramienta de troceado registra la ruta, el número de bytes y el SHA-256 de cada parte en un manifiesto, y es el manifiesto —no el archivo original— lo que el cliente da por bueno:
models/flow_lm_main_delta_attn_flow_int8.onnx 82.0 MiB → 4 parts
models/mimi_encoder.onnx 37.9 MiB → 2 parts
El reensamblado en el worker no tiene nada de especial, salvo dos aserciones que llegaron a saltar durante el desarrollo:
async function fetchAsset(entry, onDelta) {
if (!entry.parts?.length) return await fetchBytes(`${BASE}${entry.path}`, onDelta);
const out = new Uint8Array(entry.bytes);
let off = 0;
for (const p of entry.parts) {
const buf = await fetchBytes(`${BASE}${p.path}`, onDelta);
if (off + buf.length > out.length) throw new Error(`${entry.path}: parts exceed the declared size`);
out.set(buf, off);
off += buf.length;
}
if (off !== entry.bytes) throw new Error(`${entry.path}: assembled ${off} of ${entry.bytes} bytes`);
return out;
}
Un modelo truncado no lanza ningún error al cargarlo. Produce un audio basura de aspecto plausible. Comprueba el número de bytes.
Fallo 4: el alojamiento no comprime los bytes que importan, y HEAD miente al respecto
Cloudflare comprime las respuestas según el tipo de contenido. .wasm, .js, .css y
.html se tratan automáticamente. .onnx, los trozos del modelo y los embeddings de voz son
application/octet-stream, y esos salen sin comprimir.
Eso es la mayor parte de la carga. Así que los bytes se comprimen de antemano al empaquetar y se guardan con su nombre original, con Content-Encoding: br declarado archivo por archivo en el _headers del despliegue.
Por archivo, no con comodines. El compresor se salta todo lo que no puede reducir de forma apreciable, y un archivo sin comprimir servido con Content-Encoding: br queda sencillamente corrupto. Una regla /models/* reclamaría archivos que nunca se comprimieron. La herramienta de empaquetado genera una regla por cada archivo que de verdad comprimió, a partir del resultado y no de la intención.
Ahora viene la parte que me hizo perder una tarde. Para comprobar si funcionaba, el comando obvio es:
curl -sI https://example.net/models/big.onnx | grep -i content-encoding
Devuelve content-encoding: br todas las veces. Está mal. Cloudflare responde a una petición HEAD con esa cabecera, mientras que el GET real de un archivo application/octet-stream
grande no se comprime en absoluto. Leer las cabeceras de respuesta te da exactamente lo contrario de la verdad.
La única comprobación fiable cuenta los bytes de un GET:
U=https://example.net/models/big.onnx
echo "wire $(curl -s -H 'Accept-Encoding: br' "$U" | wc -c) decoded $(curl -s --compressed "$U" | wc -c)"
Pasa la salida por wc -c, no uses %{size_download} — eso cuenta los bytes en la red, no los decodificados, así que te da la razón en silencio, sea cual sea la respuesta que esperabas.
--compressed necesita un curl compilado con brotli (curl -V | grep -i brotli).
Lo que vale de verdad la compresión
Medido sobre los trozos desplegados:
| bytes | ||
|---|---|---|
| Un trozo ONNX int8 de 20,5 MiB, sin comprimir | 21,507,048 | |
| El mismo trozo, brotli -q 11 | 8,752,904 | 40.7% |
| Todo el despliegue, sin comprimir | 267,6 MiB | |
| Todo el despliegue, almacenado | 184,9 MiB | ahorra 82,7 MiB |
El 40,7 % es más interesante de lo que parece. El 40 % de los bytes de ese trozo son
0x00, y su entropía de orden 0 es de 4,19 bits/byte, lo que significa que ningún codificador que mire solo las frecuencias de los símbolos puede bajar del 52,4 %. Brotli llegó al 40,7 %, cómodamente por debajo de ese suelo, lo que demuestra de forma concreta que su modelado de contexto encuentra estructura entre bytes y no se limita a contarlos. Los tensores de pesos cuantizados están llenos de esa estructura: series de ceros, patrones de exponente repetidos, bloques casi duplicados.
Si distribuyes modelos cuantizados por la red y no has medido esto, probablemente sea la mejora más barata que tienes a mano.
Uno más, sin sección propia
Durante un tiempo el worker sí estaba informando de su fallo y yo no lo veía. Los mensajes pasaban por console.debug, que Chrome oculta en su nivel de consola predeterminado. El stderr de Emscripten y todos los fallos de enrutamiento se registraban en un nivel que nadie mira. Todo aquello de lo que dependa un diagnóstico debe ir en warn o superior.
La lista de comprobación
Si vas a poner un modelo WASM con pthreads en un alojamiento estático:
- No dejes nunca que dos reglas de
_headersfijen la misma cabecera en una misma ruta. Compruébalo en tu compilación.*coincide también con la cadena vacía. - Pon
CORP: cross-originen el prefijo aislado, y solo ahí. - Sirve los archivos del front-end con
no-cache, y versiona las URL de tus workers. Una respuesta en caché arrastra sus cabeceras antiguas. - Mantén idéntica la caché en local y en producción. La diferencia entre ambas es lo que convierte un error de una hora en uno de un día.
- Comprueba el número de bytes reensamblados. Un modelo truncado produce una salida, no una excepción.
- Declara
Content-Encodingarchivo por archivo, generado a partir de lo que de verdad se comprimió. - Verifica la compresión contando los bytes de un GET. HEAD te mentirá.
- Registra los diagnósticos en
warno superior.
La herramienta de la que salió todo esto es Audiofilter — separación de voz, reducción de ruido y texto a voz en el navegador, sin que ninguna suba tu archivo. Si lo que te interesa es la parte de procesamiento de señal y no la de distribución, por qué la cancelación de fase se lleva el bajo junto con la voz lo explica.