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 :
*correspond aussi à la chaîne vide./prefix/*correspond déjà à/prefix/lui-même. La règle supplémentaire sur le chemin exact n'était pas une double sécurité ; c'était le bug.- La fusion est sans danger pour
Cache-Control, qui est une liste de directives. Elle est fatale pour tout en-tête dont la grammaire tient en un seul jeton.
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 :
Cache-Control: no-cachesur les sources front-end. Elles font de 13 à 31 Ko ; les revalider ne coûte rien à côté d'une panne de quatre heures qu'aucune modification côté serveur ne peut faire cesser.- Un jeton de version dans l'URL du worker (
tts-worker.js?v=…) pour contourner une entrée déjà empoisonnée. Incrémentez-le chaque fois que les en-têtes d'isolation changent.
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 :
- Ne laissez jamais deux règles
_headersdéfinir le même en-tête sur un même chemin. Vérifiez-le dans votre build.*correspond aussi à la chaîne vide. - Définissez
CORP: cross-originsur le préfixe isolé, et seulement là. - 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. - 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.
- Vérifiez le nombre d'octets réassemblés. Un modèle tronqué produit une sortie, pas une exception.
- Déclarez
Content-Encodingfichier par fichier, en le générant à partir de ce qui a réellement été compressé. - Vérifiez la compression en comptant les octets d'un GET. HEAD vous mentira.
- Consignez les diagnostics au niveau
warnou 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.