Engenharia

Um modelo WASM de 130 MB no Cloudflare Pages

Quatro falhas ao publicar um modelo de áudio WASM com pthreads no Cloudflare Pages. Três delas não dão nenhuma mensagem de erro útil, e uma dá exatamente o contrário da verdade.

cerca de 7 minutos

Modelos de aprendizado de máquina já rodam no navegador. E não são de brinquedo: um modelo de separação de fontes que divide uma música em voz e instrumental, e um motor de texto para fala com clonagem de voz, ambos compilados para WebAssembly e executados no próprio processador do visitante. Sem upload, sem servidor de inferência, sem conta de GPU.

A inferência acabou sendo a metade fácil. Colocar 130 MB de modelo num host estático, e fazer o navegador aceitar executá-lo, produziu quatro falhas. Três não dão nenhuma mensagem de erro útil. Uma dá exatamente o contrário da verdade.

Todos os números abaixo foram medidos nos artefatos publicados, não estimados.

O ponto de partida

Um build de WebAssembly com pthreads precisa de SharedArrayBuffer. Os navegadores só expõem isso a páginas com isolamento de origem cruzada ativado, o que significa três cabeçalhos de resposta:

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

O terceiro é o interessante, e é onde a maioria dos textos erra. Com COEP: require-corp, os workers de pthreads do Emscripten e o worker aninhado que o motor cria buscam os próprios sub-recursos a partir do que o navegador trata como uma origem opaca. Cross-Origin-Resource-Policy: same-origin os bloqueia. Tem de ser cross-origin, que é um valor mais permissivo do que o instinto sugere, restrito ao único prefixo de caminho que precisa dele em vez de aplicado ao site inteiro, porque cross-origin também permite que qualquer site incorpore esses recursos.

No Cloudflare Pages isso é configurado num arquivo _headers na raiz do deploy. E é aí que mora a primeira falha.

Falha 1: o Pages mescla cabeçalhos de mesmo nome, e o COOP é um token único

Por segurança, declarei tanto o diretório quanto tudo o que está abaixo dele:

/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

As duas regras correspondem ao documento. O Pages não deixa a regra mais específica vencer — ele mescla os cabeçalhos de mesmo nome num único valor separado por vírgulas:

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

COOP e COEP são cabeçalhos estruturados de token único. Esse valor não pode ser interpretado, e o navegador faz a pior coisa possível com um cabeçalho que não consegue interpretar: recorre a unsafe-none em silêncio. Nenhum aviso no console sobre o cabeçalho. O que você recebe em vez disso, de um lugar totalmente diferente, é:

DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated

Nada nessa mensagem aponta para um cabeçalho de resposta, muito menos para um duplicado.

Duas lições:

A duplicação também foi como provei o primeiro ponto: ver cada cabeçalho aparecer exatamente duas vezes só é possível se os dois padrões corresponderam.

Meu script de deploy agora faz o build falhar se algum arquivo corresponder a duas regras que definem o mesmo cabeçalho. Essa verificação custa vinte linhas e teria me poupado um dia.

Falha 2: o cache HTTP guarda os cabeçalhos de resposta, não só os corpos

Com o arquivo _headers corrigido e publicado, continuou falhando. A mesma página, a mesma classe de erro, mas agora vindo de outro lugar: o worker nem sequer iniciava. O ErrorEvent tinha um message vazio, um filename vazio e número de linha 0. Disparava cerca de 4 ms depois de o worker ser criado.

A causa: o cache HTTP do navegador guardava uma cópia de tts-worker.js obtida antes de os cabeçalhos de isolamento existirem. Respostas em cache carregam os cabeçalhos com que foram guardadas, e essa entrada tinha um max-age de quatro horas. Uma página com isolamento de origem cruzada se recusa a executar um worker cujo próprio script não tem COEP: require-corp — e se recusa sem dar detalhe nenhum.

Então o servidor estava certo, a aba de rede parecia certa, e a página carregava uma resposta de quatro horas atrás que não estava.

O diagnóstico que resolveu a questão, e que vale a pena guardar:

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

Se os dois divergirem, você está diante de uma entrada de cache envenenada, não de um problema no servidor. No meu caso, fresh tinha os três cabeçalhos e cached não tinha nenhum.

Duas correções, porque qualquer uma sozinha é insuficiente:

Há um corolário que me custou mais tempo do que o próprio bug: meu servidor de desenvolvimento local tratava qualquer URL com ?v= como immutable. Então, no momento em que adicionei um token de versão, o ambiente local parou de pegar as edições do worker — e, pior, local e produção passaram a se comportar de forma diferente exatamente na área que eu estava depurando. O que tornou isso difícil foi a divergência entre o cache local e o de produção, não o cache em si.

Falha 3: um muro de 25 MiB por arquivo

O Cloudflare Pages rejeita qualquer arquivo com mais de 25 MiB. O maior arquivo de modelo tem 82,0 MiB. Outro tem 37,9 MiB.

Dividir é o óbvio; o que vale anotar é onde fica a fonte da verdade. A ferramenta de fatiamento registra o caminho, a contagem de bytes e o SHA-256 de cada parte num manifesto, e é nesse manifesto — não no arquivo original — que o cliente passa a confiar:

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

A remontagem no worker não tem nada de especial, exceto por duas asserções que chegaram a disparar durante o desenvolvimento:

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

Um modelo truncado não lança erro ao ser carregado. Ele produz um áudio lixo com aparência plausível. Verifique a contagem de bytes.

Falha 4: o host não comprime os bytes que importam, e o HEAD mente sobre isso

O Cloudflare comprime as respostas conforme o tipo de conteúdo. .wasm, .js, .css e .html são tratados automaticamente. .onnx, fatias de modelo e embeddings de voz são application/octet-stream, e esses saem sem compressão.

Isso é a maior parte da carga. Então os bytes são pré-comprimidos na hora do empacotamento e guardados com o nome original, com Content-Encoding: br declarado arquivo por arquivo no _headers do deploy.

Por arquivo, não por curinga. O compressor pula tudo o que não consegue encolher de forma relevante, e um arquivo sem compressão servido com Content-Encoding: br fica simplesmente corrompido. Uma regra /models/* reivindicaria arquivos que nunca foram comprimidos. A ferramenta de empacotamento gera uma regra por arquivo que ela de fato comprimiu, a partir do resultado e não da intenção.

Agora vem a parte que me fez perder uma tarde. Para verificar se funcionou, o comando óbvio é:

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

Ele retorna content-encoding: br toda vez. Está errado. O Cloudflare responde a uma requisição HEAD com esse cabeçalho, enquanto o GET real de um arquivo application/octet-stream grande não é comprimido de forma alguma. Ler os cabeçalhos de resposta te dá exatamente o contrário da verdade.

A única verificação confiável conta os bytes de um 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)"

Encaminhe para wc -c, não use %{size_download} — isso conta bytes na rede, não bytes decodificados, então concorda em silêncio com qualquer resposta que você esperava. --compressed precisa de um curl compilado com brotli (curl -V | grep -i brotli).

Quanto a compressão realmente vale

Medido nas fatias publicadas:

bytes
Uma fatia ONNX int8 de 20,5 MiB, sem compressão 21,507,048
A mesma fatia, brotli -q 11 8,752,904 40.7%
Deploy inteiro, sem compressão 267,6 MiB
Deploy inteiro, armazenado 184,9 MiB economiza 82,7 MiB

Os 40,7% são mais interessantes do que parecem. 40% dos bytes dessa fatia são 0x00, e a entropia de ordem 0 dela é de 4,19 bits/byte — o que significa que nenhum codificador que olhe só para as frequências dos símbolos consegue ficar abaixo de 52,4%. O Brotli chegou a 40,7%, com folga abaixo desse piso, o que demonstra concretamente que a modelagem de contexto dele encontra estrutura entre bytes em vez de só contá-los. Tensores de pesos quantizados estão cheios desse tipo de estrutura: sequências de zeros, padrões de expoente repetidos, blocos quase duplicados.

Se você distribui modelos quantizados pela rede e ainda não mediu isso, provavelmente é o ganho mais barato ao seu alcance.

Mais uma, sem seção própria

Por um tempo, o worker estava relatando a própria falha e eu não conseguia ver. As mensagens passavam por console.debug, que o Chrome esconde no nível de console padrão. O stderr do Emscripten e todas as falhas de roteamento estavam sendo registrados num nível que ninguém olha. Tudo aquilo de que um diagnóstico depende deve ficar em warn ou acima.

A lista de verificação

Se você vai colocar um modelo WASM com pthreads num host estático:

  1. Nunca deixe duas regras de _headers definirem o mesmo cabeçalho num mesmo caminho. Verifique isso no build. * também corresponde à string vazia.
  2. Defina CORP: cross-origin no prefixo isolado, e só ali.
  3. Sirva os arquivos do front-end com no-cache, e versione as URLs dos seus workers. Uma resposta em cache leva junto os cabeçalhos antigos.
  4. Mantenha o cache local e o de produção idênticos. A divergência é o que transforma um bug de uma hora num bug de um dia.
  5. Verifique a contagem de bytes remontados. Um modelo truncado produz saída, não uma exceção.
  6. Declare Content-Encoding por arquivo, gerado a partir do que foi realmente comprimido.
  7. Verifique a compressão contando os bytes de um GET. O HEAD vai mentir para você.
  8. Registre diagnósticos em warn ou acima.

A ferramenta de onde isso saiu é o Audiofilter — separação de voz, redução de ruído e texto para fala no navegador, sem que nenhum deles envie seu arquivo. Se você quer o lado de processamento de sinal e não o de distribuição, por que o cancelamento de fase leva o grave junto com a voz trata disso.