Techniek

Een WASM-model van 130 MB op Cloudflare Pages

Vier mislukkingen bij het uitleveren van een pthreads-WASM-audiomodel via Cloudflare Pages. Drie ervan leveren geen bruikbare foutmelding op, en één levert precies het tegendeel van de waarheid.

ongeveer 7 minuten

Machine-learningmodellen draaien tegenwoordig in de browser. Geen speelgoed — een bronscheidingsmodel dat een nummer splitst in zang en instrumentaal, en een tekst-naar-spraakengine met stemklonen, allebei gecompileerd naar WebAssembly en uitgevoerd op de eigen processor van de bezoeker. Geen upload, geen inferentieserver, geen GPU-rekening.

De inferentie bleek de makkelijke helft. 130 MB aan model op een statische host krijgen, en de browser zover krijgen dat hij het wil draaien, leverde vier mislukkingen op. Drie geven je geen bruikbare foutmelding. Eén geeft je precies het tegendeel van de waarheid.

Alle cijfers hieronder zijn gemeten aan de uitgeleverde artefacten, niet geschat.

De opzet

Een pthreads-build van WebAssembly heeft SharedArrayBuffer nodig. Browsers stellen dat alleen beschikbaar aan een cross-origin geïsoleerde pagina, en dat betekent drie response-headers:

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

De derde is de interessante, en daar zitten de meeste artikelen fout. Onder COEP: require-corp halen de pthread-workers van Emscripten en de geneste worker die de engine start hun eigen subresources op vanaf wat de browser als een opake oorsprong behandelt. Cross-Origin-Resource-Policy: same-origin blokkeert die. Het moet cross-origin zijn, en dat is een ruimere waarde dan je intuïtie zegt, beperkt tot het ene padvoorvoegsel dat hem nodig heeft in plaats van voor de hele site, omdat cross-origin ook elke site toestaat die resources in te sluiten.

Op Cloudflare Pages zet je dat in het bestand _headers in de root van de deploy. En daar zit de eerste mislukking.

Mislukking 1: Pages voegt gelijknamige headers samen, en COOP is één token

Voor de zekerheid declareerde ik zowel de map als alles eronder:

/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 regels matchen het document. Pages laat niet de specifiekere regel winnen — het voegt gelijknamige headers samen tot één waarde met komma's:

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

COOP en COEP zijn gestructureerde headers met één token. Die waarde valt niet te parsen, en de browser doet het slechtst denkbare met een header die hij niet kan parsen: hij valt terug op unsafe-none zonder enig geluid. Geen consolewaarschuwing over de header. Wat je in plaats daarvan krijgt, van een heel andere plek, is:

DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated

Niets in die melding wijst naar een response-header, laat staan naar een dubbele.

Twee dingen om te onthouden:

De verdubbeling is ook hoe ik het eerste punt bewees: dat elke header precies twee keer verschijnt, kan alleen als beide patronen matchten.

Mijn deployscript laat de build nu mislukken als een bestand door twee regels wordt gematcht die dezelfde header zetten. Die controle kost twintig regels en had me een dag bespaard.

Mislukking 2: de HTTP-cache bewaart response-headers, niet alleen bodies

Nadat het bestand _headers gerepareerd en uitgeleverd was, mislukte het nog steeds. Dezelfde pagina, dezelfde foutklasse, maar nu van een andere plek: de worker startte helemaal niet. De ErrorEvent had een lege message, een lege filename en regelnummer 0. Hij vuurde ongeveer 4 ms nadat de worker was aangemaakt.

De oorzaak: de HTTP-cache van de browser had een kopie van tts-worker.js die was opgehaald voordat de isolatieheaders bestonden. Gecachte responses dragen de headers mee waarmee ze zijn opgeslagen, en die entry had een max-age van vier uur. Een cross-origin geïsoleerde pagina weigert een worker te draaien waarvan het eigen script COEP: require-corp mist — en ze weigert zonder enig detail.

De server was dus correct, het netwerktabblad zag er correct uit, en de pagina laadde een vier uur oude response die dat niet was.

De diagnose die het beslechtte, en die het waard is om bij de hand te houden:

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

Als die twee het oneens zijn, kijk je naar een vergiftigde cache-entry, niet naar een serverprobleem. In mijn geval had fresh alle drie de headers en cached geen enkele.

Twee oplossingen, omdat één van beide alleen niet volstaat:

Er is een gevolg dat me meer tijd kostte dan de bug zelf: mijn lokale devserver behandelde elke URL met ?v= als immutable. Zodra ik een versietoken toevoegde, pikte lokaal dus geen workerwijzigingen meer op — en, erger, lokaal en productie gedroegen zich nu verschillend in precies het gebied dat ik aan het debuggen was. Wat dit moeilijk maakte, was het verschil tussen caching lokaal en in productie, niet de caching zelf.

Mislukking 3: een muur van 25 MiB per bestand

Cloudflare Pages weigert elk afzonderlijk bestand boven 25 MiB. Het grootste modelbestand is 82,0 MiB. Een ander is 37,9 MiB.

Opsplitsen ligt voor de hand; wat het opschrijven waard is, is waar de bron van waarheid terechtkomt. De splittool legt het pad, het aantal bytes en de SHA-256 van elk deel vast in een manifest, en dat manifest — niet het oorspronkelijke bestand — wordt wat de client vertrouwt:

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

Het weer samenvoegen in de worker is niet bijzonder, op twee asserties na die tijdens de ontwikkeling allebei zijn afgegaan:

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

Een afgekapt model gooit geen fout als je het laadt. Het produceert geloofwaardig ogend audio-afval. Controleer het aantal bytes.

Mislukking 4: de host comprimeert de bytes die ertoe doen niet, en HEAD liegt erover

Cloudflare comprimeert responses op basis van het contenttype. .wasm, .js, .css en .html worden automatisch afgehandeld. .onnx, modelslices en stem-embeddings zijn application/octet-stream, en die gaan ongecomprimeerd de deur uit.

Dat is het grootste deel van de lading. Dus worden de bytes tijdens het inpakken vooraf gecomprimeerd en onder hun oorspronkelijke naam opgeslagen, met Content-Encoding: br per bestand gedeclareerd in _headers (het headersbestand).

Per bestand, niet met een wildcard. De compressor slaat alles over wat hij niet merkbaar kleiner kan maken, en een rauw bestand dat met Content-Encoding: br wordt geserveerd, is gewoon corrupt. Een regel als /models/* zou bestanden claimen die nooit gecomprimeerd zijn. De pakketool genereert één regel per bestand dat echt is gecomprimeerd, op basis van het resultaat en niet van de bedoeling.

Nu het deel dat een middag kostte. Om te controleren of het werkte, is het voor de hand liggende commando:

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

Het meldt content-encoding: br elke keer. Het klopt niet. Cloudflare beantwoordt een HEAD-request met die header, terwijl de echte GET voor een groot bestand van het type application/octet-stream helemaal niet gecomprimeerd is. Response-headers lezen geeft je precies het tegendeel van de waarheid.

De enige betrouwbare controle telt de bytes van een 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)"

Pipe naar wc -c, gebruik niet %{size_download} — dat telt bytes over de lijn, geen gedecodeerde bytes, dus het geeft je stilletjes gelijk, welk antwoord je ook hoopte. --compressed vereist een curl die met brotli is gebouwd (curl -V | grep -i brotli).

Wat de compressie echt oplevert

Gemeten aan de uitgeleverde slices:

bytes
Eén int8-ONNX-slice van 20,5 MiB, rauw 21,507,048
Dezelfde slice, brotli -q 11 8,752,904 40.7%
Hele deploy, rauw 267,6 MiB
Hele deploy, opgeslagen 184,9 MiB bespaart 82,7 MiB

Die 40,7% is interessanter dan het lijkt. 40% van de bytes in die slice is 0x00, en de entropie van nulde orde is 4,19 bit/byte — wat betekent dat geen enkele coder die alleen naar symboolfrequenties kijkt onder 52,4% kan komen. Brotli haalde 40,7%, ruim onder die ondergrens, en dat laat concreet zien dat zijn contextmodellering structuur over bytes heen vindt en ze niet alleen telt. Gekwantiseerde gewichtstensoren zitten vol met zulke structuur: reeksen nullen, herhaalde exponentpatronen, bijna gedupliceerde blokken.

Als je gekwantiseerde modellen over het netwerk uitlevert en dit nog niet hebt gemeten, is het waarschijnlijk de goedkoopste winst die je kunt halen.

Nog één, zonder eigen sectie

Een tijdlang meldde de worker zijn fout wel degelijk, en ik zag het niet. De berichten liepen via console.debug, dat Chrome op zijn standaard consoleniveau verbergt. De stderr van Emscripten en elke routeringsfout werden gelogd op een niveau waar niemand kijkt. Alles waar een diagnose van afhangt, hoort op warn of hoger.

De checklist

Als je een pthreads-WASM-model op een statische host zet:

  1. Laat nooit twee regels in _headers dezelfde header op één pad zetten. Controleer het in je build. * matcht ook de lege string.
  2. Zet CORP: cross-origin op het geïsoleerde voorvoegsel, en alleen daar.
  3. Serveer frontendbronnen met no-cache en versioneer je worker-URL's. Een gecachte response draagt zijn oude headers met zich mee.
  4. Houd caching lokaal en in productie identiek. Het verschil is wat een bug van een uur verandert in een bug van een dag.
  5. Controleer het aantal bytes na het samenvoegen. Een afgekapt model produceert output, geen exceptie.
  6. Declareer Content-Encoding per bestand, gegenereerd uit wat echt is gecomprimeerd.
  7. Controleer compressie door de bytes van een GET te tellen. HEAD zal tegen je liegen.
  8. Log diagnostiek op warn of hoger.

De tool waar dit uit voortkwam is Audiofilter — zangscheiding, ruisonderdrukking en tekst naar spraak in de browser, zonder dat er één je bestand uploadt. Wil je liever de DSP-kant dan de uitleveringskant, waarom fase-uitdoving de bas meeneemt met de zang legt dat uit.