Technik

Ein 130-MB-WASM-Modell auf Cloudflare Pages

Vier Fehlschläge beim Ausliefern eines pthreads-WASM-Audiomodells über Cloudflare Pages. Drei davon liefern keine brauchbare Fehlermeldung, und einer liefert das genaue Gegenteil der Wahrheit.

etwa 7 Minuten

Machine-Learning-Modelle laufen inzwischen im Browser. Keine Spielzeuge — ein Quellentrennungsmodell, das einen Song in Gesang und Instrumental zerlegt, und eine Text-zu-Sprache-Engine mit Stimmklonen, beide nach WebAssembly kompiliert und auf dem Prozessor des Besuchers ausgeführt. Kein Upload, kein Inferenzserver, keine GPU-Rechnung.

Die Inferenz erwies sich als die leichte Hälfte. 130 MB Modell auf einen statischen Host zu bringen und den Browser dazu, es auszuführen, hat vier Fehlschläge produziert. Drei geben dir keine brauchbare Fehlermeldung. Einer gibt dir das genaue Gegenteil der Wahrheit.

Alle Zahlen unten sind an den ausgelieferten Artefakten gemessen, nicht geschätzt.

Die Ausgangslage

Ein pthreads-Build von WebAssembly braucht SharedArrayBuffer. Browser stellen das nur einer cross-origin-isolierten Seite zur Verfügung, und das heißt drei Response-Header:

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

Der dritte ist der interessante, und genau hier liegen die meisten Artikel falsch. Unter COEP: require-corp holen die pthread-Worker von Emscripten und der verschachtelte Worker, den die Engine startet, ihre eigenen Subressourcen von etwas, das der Browser als opaken Ursprung behandelt. Cross-Origin-Resource-Policy: same-origin blockiert sie. Es muss cross-origin sein, also ein laxerer Wert, als das Bauchgefühl nahelegt, beschränkt auf das eine Pfadpräfix, das ihn braucht, statt seitenweit gesetzt, denn cross-origin erlaubt auch jeder Website, diese Ressourcen einzubetten.

Auf Cloudflare Pages legst du das in der Datei _headers fest. Und genau dort sitzt der erste Fehlschlag.

Fehlschlag 1: Pages führt gleichnamige Header zusammen, und COOP ist ein einzelnes Token

Zur Sicherheit habe ich sowohl das Verzeichnis als auch alles darunter deklariert:

/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

Beide Regeln treffen auf das Dokument zu. Pages lässt nicht die spezifischere Regel gewinnen — es führt gleichnamige Header zu einem kommagetrennten Wert zusammen:

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

COOP und COEP sind strukturierte Header mit genau einem Token. Dieser Wert lässt sich nicht parsen, und der Browser tut mit einem Header, den er nicht parsen kann, das Schlimmste, was möglich ist: Er fällt auf unsafe-none stillschweigend zurück. Keine Konsolenwarnung zum Header. Was du stattdessen bekommst, von ganz anderer Stelle, ist:

DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated

Nichts in dieser Meldung verweist auf einen Response-Header, geschweige denn auf einen doppelten.

Zwei Dinge zum Mitnehmen:

Über die Verdopplung habe ich auch den ersten Punkt bewiesen: Dass jeder Header genau zweimal erscheint, ist nur möglich, wenn beide Muster gegriffen haben.

Mein Deploy-Skript lässt den Build jetzt scheitern, wenn eine Datei von zwei Regeln getroffen wird, die denselben Header setzen. Diese Prüfung kostet zwanzig Zeilen und hätte mir einen Tag gespart.

Fehlschlag 2: Der HTTP-Cache speichert Response-Header, nicht nur Bodies

Nachdem die Datei _headers korrigiert und ausgeliefert war, schlug es trotzdem fehl. Dieselbe Seite, dieselbe Fehlerklasse, aber jetzt von anderer Stelle: Der Worker startete überhaupt nicht. Das ErrorEvent hatte eine leere message, einen leeren filename und die Zeilennummer 0. Es feuerte etwa 4 ms, nachdem der Worker erzeugt worden war.

Die Ursache: Der HTTP-Cache des Browsers hielt eine Kopie von tts-worker.js aus der Zeit, bevor es die Isolations-Header gab. Gecachte Antworten tragen die Header, mit denen sie gespeichert wurden, und dieser Eintrag hatte ein max-age von vier Stunden. Eine cross-origin-isolierte Seite weigert sich, einen Worker auszuführen, dessen eigenem Skript COEP: require-corp fehlt — und sie weigert sich ohne jedes Detail.

Der Server war also korrekt, der Netzwerk-Tab sah korrekt aus, und die Seite lud eine vier Stunden alte Antwort, die es nicht war.

Die Diagnose, die es geklärt hat und die man griffbereit halten sollte:

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'));

Wenn die beiden nicht übereinstimmen, hast du einen vergifteten Cache-Eintrag vor dir, kein Serverproblem. In meinem Fall hatte fresh alle drei Header und cached keinen einzigen.

Zwei Korrekturen, denn eine allein reicht nicht:

Es gibt eine Folgeerscheinung, die mich mehr Zeit gekostet hat als der Fehler selbst: Mein lokaler Dev-Server behandelte jede URL mit ?v= als immutable. Sobald ich also ein Versions-Token hinzugefügt hatte, holte sich die lokale Umgebung keine Worker-Änderungen mehr — und, schlimmer, lokal und Produktion verhielten sich nun genau in dem Bereich unterschiedlich, den ich gerade debuggte. Was das schwierig gemacht hat, war der Unterschied zwischen lokalem und produktivem Caching, nicht das Caching selbst.

Fehlschlag 3: eine Mauer von 25 MiB pro Datei

Cloudflare Pages lehnt jede einzelne Datei über 25 MiB ab. Die größte Modelldatei hat 82,0 MiB. Eine andere 37,9 MiB.

Aufteilen ist naheliegend; festhaltenswert ist, wo die Quelle der Wahrheit landet. Das Aufteilungswerkzeug schreibt Pfad, Bytezahl und SHA-256 jedes Teils in ein Manifest, und das Manifest — nicht die Originaldatei — ist das, dem der Client vertraut:

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

Das Zusammensetzen im Worker ist unspektakulär, bis auf zwei Assertions, die beide während der Entwicklung ausgelöst haben:

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;
}

Ein abgeschnittenes Modell wirft beim Laden keinen Fehler. Es erzeugt plausibel wirkenden Audio-Müll. Prüf die Bytezahl.

Fehlschlag 4: Der Host komprimiert die Bytes nicht, auf die es ankommt, und HEAD lügt darüber

Cloudflare komprimiert Antworten nach Content-Type. .wasm, .js, .css und .html werden automatisch behandelt. .onnx, Modell-Slices und Voice-Embeddings sind application/octet-stream, und die gehen unkomprimiert raus.

Das ist der Großteil der Nutzlast. Also werden die Bytes beim Packen vorkomprimiert und unter ihrem Originalnamen abgelegt, mit Content-Encoding: br pro Datei deklariert in _headers im Deploy-Verzeichnis.

Pro Datei, nicht per Wildcard. Der Kompressor überspringt alles, was er nicht nennenswert verkleinern kann, und eine rohe Datei, die mit Content-Encoding: br ausgeliefert wird, ist schlicht kaputt. Eine Regel wie /models/* würde Dateien beanspruchen, die nie komprimiert wurden. Das Pack-Werkzeug erzeugt eine Regel pro tatsächlich komprimierter Datei, aus dem Ergebnis statt aus der Absicht.

Jetzt der Teil, der einen Nachmittag gekostet hat. Um zu prüfen, ob es funktioniert, ist der naheliegende Befehl:

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

Er meldet content-encoding: br jedes Mal. Das ist falsch. Cloudflare beantwortet eine HEAD-Anfrage mit diesem Header, während der echte GET auf eine große Datei vom Typ application/octet-stream überhaupt nicht komprimiert ist. Wer Response-Header liest, bekommt das genaue Gegenteil der Wahrheit.

Die einzige verlässliche Prüfung zählt die Bytes eines 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)"

Leite in wc -c um, nimm nicht %{size_download} — das zählt Bytes auf der Leitung, nicht dekodierte Bytes, und gibt dir deshalb stillschweigend recht, egal welche Antwort du dir erhofft hast. --compressed braucht ein curl mit brotli-Unterstützung (curl -V | grep -i brotli).

Was die Kompression tatsächlich bringt

Gemessen an den ausgelieferten Slices:

Bytes
Ein int8-ONNX-Slice mit 20,5 MiB, roh 21,507,048
Derselbe Slice, brotli -q 11 8,752,904 40.7%
Gesamtes Deploy, roh 267,6 MiB
Gesamtes Deploy, gespeichert 184,9 MiB spart 82,7 MiB

Die 40,7 % sind interessanter, als sie aussehen. 40 % der Bytes in diesem Slice sind 0x00, und seine Entropie nullter Ordnung liegt bei 4,19 Bit/Byte — das heißt, kein Coder, der nur Symbolhäufigkeiten betrachtet, kommt unter 52,4 %. Brotli erreichte 40,7 %, deutlich unter dieser Untergrenze — ein konkreter Beleg dafür, dass seine Kontextmodellierung Struktur über Bytes hinweg findet, statt sie bloß zu zählen. Quantisierte Gewichtstensoren sind voll von solcher Struktur: Folgen von Nullen, wiederkehrende Exponentenmuster, fast doppelte Blöcke.

Wenn du quantisierte Modelle über das Netz auslieferst und das noch nicht gemessen hast, ist es vermutlich der billigste Gewinn, den du haben kannst.

Noch einer, ohne eigenen Abschnitt

Eine Zeit lang meldete der Worker seinen Fehler sehr wohl, nur konnte ich es nicht sehen. Die Meldungen liefen über console.debug, die Chrome auf seiner Standard-Konsolenstufe ausblendet. Das stderr von Emscripten und jeder Routing-Fehler landeten auf einer Stufe, die niemand ansieht. Alles, wovon eine Diagnose abhängt, gehört auf warn oder höher.

Die Checkliste

Wenn du ein pthreads-WASM-Modell auf einen statischen Host bringst:

  1. Lass nie zwei Regeln in _headers denselben Header auf einem Pfad setzen. Prüf das im Build. * trifft auch auf den leeren String zu.
  2. Setz CORP: cross-origin auf das isolierte Präfix, und nur dort.
  3. Liefer Frontend-Quellen mit no-cache aus, und versioniere deine Worker-URLs. Eine gecachte Antwort trägt ihre alten Header mit sich.
  4. Halte das Caching lokal und in Produktion identisch. Der Unterschied ist es, der aus einem Ein-Stunden-Fehler einen Ein-Tages-Fehler macht.
  5. Prüf die Bytezahl nach dem Zusammensetzen. Ein abgeschnittenes Modell erzeugt Output, keine Exception.
  6. Deklariere Content-Encoding pro Datei, erzeugt aus dem, was tatsächlich komprimiert wurde.
  7. Prüf die Kompression, indem du die Bytes eines GET zählst. HEAD wird dich anlügen.
  8. Logge Diagnosen auf warn oder höher.

Das Werkzeug, aus dem das entstanden ist, ist Audiofilter — Gesangstrennung, Rauschentfernung und Text-zu-Sprache im Browser, und keins davon lädt deine Datei hoch. Wenn dich eher die DSP-Seite als die Auslieferung interessiert: warum Phasenauslöschung den Bass mit der Stimme mitnimmt erklärt das.