Ingénierie

Un modèle WASM de 130 Mo sur Cloudflare Pages

Quatre échecs rencontrés en déployant un modèle audio WASM avec pthreads sur Cloudflare Pages. Trois d'entre eux ne donnent aucun message d'erreur exploitable, et un donne exactement le contraire de la vérité.

environ 7 minutes

Les modèles d'apprentissage automatique tournent désormais dans le navigateur. Pas des jouets : un modèle de séparation de sources qui scinde une chanson en voix et instrumental, et un moteur de synthèse vocale avec clonage de voix, tous deux compilés en WebAssembly et exécutés sur le processeur du visiteur. Pas d'envoi, pas de serveur d'inférence, pas de facture GPU.

L'inférence s'est révélée être la moitié facile. Mettre 130 Mo de modèle sur un hébergement statique, et obtenir que le navigateur accepte de l'exécuter, a produit quatre échecs. Trois ne donnent aucun message d'erreur exploitable. Un donne exactement le contraire de la vérité.

Tous les chiffres ci-dessous sont mesurés sur les artefacts déployés, pas estimés.

Le contexte

Une compilation WebAssembly avec pthreads a besoin de SharedArrayBuffer. Les navigateurs ne l'exposent qu'à une page isolée cross-origin — autrement dit, trois en-têtes de réponse :

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

Le troisième est le plus intéressant, et c'est là que la plupart des articles se trompent. Avec COEP: require-corp, les workers pthreads d'Emscripten et le worker imbriqué que lance le moteur récupèrent leurs propres sous-ressources depuis ce que le navigateur considère comme une origine opaque. Cross-Origin-Resource-Policy: same-origin les bloque. Il faut cross-origin, qui est une valeur plus permissive que ce que suggère l'instinct, limitée au seul préfixe de chemin qui en a besoin plutôt qu'appliquée à tout le site, car cross-origin permet aussi à n'importe quel site d'intégrer ces ressources.

Sur Cloudflare Pages, cela se règle dans un fichier _headers à la racine du déploiement. C'est là que se trouve le premier échec.

Échec 1 : Pages fusionne les en-têtes de même nom, et COOP n'accepte qu'un seul jeton

Par prudence, j'ai déclaré à la fois le répertoire et tout ce qu'il contient :

/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

Les deux règles correspondent au document. Pages ne laisse pas gagner la règle la plus précise — il fusionne les en-têtes de même nom en une seule valeur séparée par des virgules :

cross-origin-opener-policy: same-origin, same-origin
cross-origin-embedder-policy: require-corp, require-corp

COOP et COEP sont des en-têtes structurés à jeton unique. Cette valeur ne peut pas être analysée, et le navigateur fait la pire chose possible avec un en-tête qu'il ne sait pas analyser : il se replie sur unsafe-none en silence. Aucun avertissement dans la console à propos de l'en-tête. Ce que vous obtenez à la place, depuis un tout autre endroit, c'est :

DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated

Rien dans ce message ne pointe vers un en-tête de réponse, encore moins vers un en-tête en double.

Deux choses à retenir :

La duplication est aussi ce qui m'a permis de prouver le premier point : voir chaque en-tête apparaître exactement deux fois n'est possible que si les deux motifs ont correspondu.

Mon script de déploiement fait désormais échouer le build si un fichier est concerné par deux règles qui définissent le même en-tête. Cette vérification coûte vingt lignes et m'aurait fait gagner une journée.

Échec 2 : le cache HTTP stocke les en-têtes de réponse, pas seulement les corps

Une fois le fichier _headers corrigé et déployé, l'échec persistait. Même page, même classe d'erreur, mais venant désormais d'ailleurs : le worker ne démarrait même pas. L'objet ErrorEvent avait un message vide, un filename vide et un numéro de ligne à 0. Il se déclenchait environ 4 ms après la construction du worker.

La cause : le cache HTTP du navigateur conservait une copie de tts-worker.js récupérée avant que les en-têtes d'isolation n'existent. Les réponses en cache emportent les en-têtes avec lesquels elles ont été stockées, et cette entrée avait un max-age de quatre heures. Une page isolée cross-origin refuse d'exécuter un worker dont le propre script n'a pas COEP: require-corp — et elle refuse sans donner le moindre détail.

Le serveur était donc correct, l'onglet réseau semblait correct, et la page chargeait une réponse vieille de quatre heures qui, elle, ne l'était pas.

Le diagnostic qui a tranché, et qu'il vaut la peine de garder sous la main :

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 les deux divergent, vous avez affaire à une entrée de cache empoisonnée, pas à un problème de serveur. Dans mon cas, fresh avait les trois en-têtes et cached n'en avait aucun.

Deux correctifs, car l'un sans l'autre ne suffit pas :

Il y a un corollaire qui m'a coûté plus de temps que le bug lui-même : mon serveur de développement local traitait toute URL contenant ?v= comme immutable. Si bien qu'au moment où j'ai ajouté un jeton de version, le local a cessé de prendre en compte les modifications du worker — et, pire, le local et la production se comportaient désormais différemment précisément dans la zone que je déboguais. C'est l'écart entre le cache local et celui de production qui a rendu cela difficile, pas le cache en lui-même.

Échec 3 : un mur de 25 Mio par fichier

Cloudflare Pages refuse tout fichier de plus de 25 Mio. Le plus gros fichier de modèle fait 82,0 Mio. Un autre, 37,9 Mio.

Découper est évident ; ce qui mérite d'être noté, c'est où se trouve la source de vérité. L'outil de découpage consigne le chemin, le nombre d'octets et le SHA-256 de chaque partie dans un manifeste, et c'est ce manifeste — pas le fichier d'origine — auquel le client fait confiance :

models/flow_lm_main_delta_attn_flow_int8.onnx   82.0 MiB → 4 parts
models/mimi_encoder.onnx                        37.9 MiB → 2 parts

Le réassemblage dans le worker n'a rien de remarquable, hormis deux assertions qui se sont toutes deux déclenchées pendant le développement :

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 modèle tronqué ne lève pas d'exception au chargement. Il produit un audio poubelle d'apparence plausible. Vérifiez le nombre d'octets.

Échec 4 : l'hébergeur ne compresse pas les octets qui comptent, et HEAD ment à ce sujet

Cloudflare compresse les réponses selon le type de contenu. .wasm, .js, .css et .html sont gérés automatiquement. .onnx, les tranches de modèle et les embeddings de voix sont en application/octet-stream, et ceux-là partent non compressés.

C'est l'essentiel de la charge. Les octets sont donc précompressés au moment du paquetage et stockés sous leur nom d'origine, avec Content-Encoding: br déclaré fichier par fichier dans le _headers du déploiement.

Fichier par fichier, pas par joker. Le compresseur ignore tout ce qu'il ne peut pas réduire de manière notable, et un fichier brut servi avec Content-Encoding: br est tout simplement corrompu. Une règle /models/* revendiquerait des fichiers qui n'ont jamais été compressés. L'outil de paquetage génère une règle par fichier effectivement compressé, à partir du résultat plutôt que de l'intention.

Vient maintenant la partie qui m'a fait perdre un après-midi. Pour vérifier que cela fonctionnait, la commande évidente est :

curl -sI https://example.net/models/big.onnx | grep -i content-encoding

Elle renvoie content-encoding: br à chaque fois. C'est faux. Cloudflare répond à une requête HEAD avec cet en-tête alors que le vrai GET d'un gros fichier application/octet-stream n'est pas compressé du tout. Lire les en-têtes de réponse vous donne exactement le contraire de la vérité.

La seule vérification fiable compte les octets d'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)"

Redirigez vers wc -c, n'utilisez pas %{size_download} — cela compte les octets transférés, pas les octets décodés, et vous donne donc raison en silence, quelle que soit la réponse que vous espériez. --compressed nécessite un curl compilé avec brotli (curl -V | grep -i brotli).

Ce que la compression rapporte vraiment

Mesuré sur les tranches déployées :

octets
Une tranche ONNX int8 de 20,5 Mio, brute 21,507,048
La même tranche, brotli -q 11 8,752,904 40.7%
Tout le déploiement, brut 267,6 Mio
Tout le déploiement, stocké 184,9 Mio économise 82,7 Mio

Les 40,7 % sont plus intéressants qu'ils n'en ont l'air. 40 % des octets de cette tranche valent 0x00, et son entropie d'ordre 0 est de 4,19 bit/octet — ce qui signifie qu'aucun codeur ne regardant que la fréquence des symboles ne peut descendre sous 52,4 %. Brotli atteint 40,7 %, confortablement sous ce plancher, ce qui démontre concrètement que sa modélisation de contexte trouve de la structure d'un octet à l'autre, au lieu de se contenter de les compter. Les tenseurs de poids quantifiés regorgent de ce genre de structure : suites de zéros, motifs d'exposants répétés, blocs quasi dupliqués.

Si vous distribuez des modèles quantifiés sur le réseau sans avoir mesuré cela, c'est probablement le gain le moins cher à votre portée.

Un dernier, sans section à lui

Pendant un temps, le worker signalait bel et bien son échec, et je ne le voyais pas. Les messages passaient par console.debug, que Chrome masque à son niveau de console par défaut. Le stderr d'Emscripten et chaque échec de routage étaient consignés à un niveau que personne ne regarde. Tout ce dont dépend un diagnostic doit être au niveau warn ou au-dessus.

La liste de contrôle

Si vous mettez un modèle WASM avec pthreads sur un hébergement statique :

  1. Ne laissez jamais deux règles _headers définir le même en-tête sur un même chemin. Vérifiez-le dans votre build. * correspond aussi à la chaîne vide.
  2. Définissez CORP: cross-origin sur le préfixe isolé, et seulement là.
  3. Servez les sources front-end en no-cache, et versionnez les URL de vos workers. Une réponse en cache emporte ses anciens en-têtes avec elle.
  4. Gardez un cache identique en local et en production. C'est l'écart qui transforme un bug d'une heure en bug d'une journée.
  5. Vérifiez le nombre d'octets réassemblés. Un modèle tronqué produit une sortie, pas une exception.
  6. Déclarez Content-Encoding fichier par fichier, en le générant à partir de ce qui a réellement été compressé.
  7. Vérifiez la compression en comptant les octets d'un GET. HEAD vous mentira.
  8. Consignez les diagnostics au niveau warn ou au-dessus.

L'outil dont tout cela est issu, c'est Audiofilter — séparation de voix, réduction de bruit et synthèse vocale dans le navigateur, sans qu'aucun n'envoie votre fichier. Si c'est le côté traitement du signal qui vous intéresse plutôt que la distribution, pourquoi l'annulation de phase emporte la basse avec la voix en parle.