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:
*também corresponde à string vazia./prefix/*já corresponde a/prefix/em si. A regra extra de caminho exato não era precaução em dobro; era o bug.- A mesclagem é inofensiva para
Cache-Control, que é uma lista de diretivas. É fatal para qualquer cabeçalho cuja gramática seja um único token.
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:
Cache-Control: no-cachenos arquivos do front-end. Eles têm de 13 a 31 KB; revalidá-los não custa nada perto de uma queda de quatro horas que nenhuma mudança no servidor consegue encerrar.- Um token de versão na URL do worker (
tts-worker.js?v=…) para contornar uma entrada já envenenada. Incremente-o sempre que os cabeçalhos de isolamento mudarem.
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:
- Nunca deixe duas regras de
_headersdefinirem o mesmo cabeçalho num mesmo caminho. Verifique isso no build.*também corresponde à string vazia. - Defina
CORP: cross-originno prefixo isolado, e só ali. - 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. - 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.
- Verifique a contagem de bytes remontados. Um modelo truncado produz saída, não uma exceção.
- Declare
Content-Encodingpor arquivo, gerado a partir do que foi realmente comprimido. - Verifique a compressão contando os bytes de um GET. O HEAD vai mentir para você.
- Registre diagnósticos em
warnou 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.