Ingeniería

Un modelo WASM de 130 MB en Cloudflare Pages

Cuatro fallos al desplegar un modelo de audio WASM con pthreads en Cloudflare Pages. Tres de ellos no dan ningún mensaje de error útil, y uno da justo lo contrario de la verdad.

unos 7 minutos

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:

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:

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:

  1. No dejes nunca que dos reglas de _headers fijen la misma cabecera en una misma ruta. Compruébalo en tu compilación. * coincide también con la cadena vacía.
  2. Pon CORP: cross-origin en el prefijo aislado, y solo ahí.
  3. Sirve los archivos del front-end con no-cache, y versiona las URL de tus workers. Una respuesta en caché arrastra sus cabeceras antiguas.
  4. 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.
  5. Comprueba el número de bytes reensamblados. Un modelo truncado produce una salida, no una excepción.
  6. Declara Content-Encoding archivo por archivo, generado a partir de lo que de verdad se comprimió.
  7. Verifica la compresión contando los bytes de un GET. HEAD te mentirá.
  8. Registra los diagnósticos en warn o 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.