機械学習モデルは、いまやブラウザの中で動きます。おもちゃではありません——曲をボーカルと伴奏に分ける音源分離モデルと、声のクローン機能を持つテキスト読み上げエンジンで、どちらも WebAssembly にコンパイルされ、訪問者自身のプロセッサーで実行されます。アップロードも、推論サーバーも、GPU の請求書もありません。
推論そのものは、簡単なほうの半分でした。130 MB のモデルを静的ホスティングに載せ、ブラウザにそれを実行させるまでに、4つの失敗がありました。3つは役に立つエラーメッセージを出しません。1つは事実とまるで逆のことを告げます。
以下の数値はすべて、デプロイした成果物で実測したもので、推定ではありません。
前提
pthreads 版の WebAssembly ビルドには SharedArrayBufferが必要です。ブラウザがそれを渡すのは クロスオリジン分離 されたページだけで、そのためにはレスポンスヘッダーが3つ必要です:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
面白いのは3つ目で、ほとんどの記事が間違えているのもここです。 COEP: require-corpのもとでは、Emscripten の pthreads Worker と、エンジンがさらに起動する入れ子の Worker が、ブラウザが不透明なオリジンとして扱う場所から、それぞれのサブリソースを取得します。 Cross-Origin-Resource-Policy: same-origin はそれをブロックします。値は cross-originでなければなりません。これは直感よりも 緩い 値で、サイト全体に適用するのではなく、それを必要とするパスのプレフィックス1つだけに絞ります。というのも cross-origin は、どのサイトにもそれらのリソースの埋め込みを許してしまうからです。
Cloudflare Pages では、これを _headers ファイルに書きます。そして最初の失敗は、まさにそこにあります。
失敗 1:Pages は同名のヘッダーを結合し、COOP は単一トークンしか受け付けない
念のため、ディレクトリそのものと、その下のすべてを両方とも宣言しました:
/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
どちらのルールもこのドキュメントに一致します。Pages は、より具体的なルールを優先させることは しません ——同名のヘッダーを、カンマでつないだ1つの値に結合するのです:
cross-origin-opener-policy: same-origin, same-origin
cross-origin-embedder-policy: require-corp, require-corp
COOP と COEP は単一トークンの構造化ヘッダーです。この値は解析に失敗し、ブラウザは解析できないヘッダーに対して考えうる最悪のことをします。つまり unsafe-none に黙ってフォールバックするのです。ヘッダーについてのコンソール警告は一切ありません。代わりに、まったく別の場所から届くのがこれです:
DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated
このメッセージのどこにも、レスポンスヘッダーを指すものはありません。ましてや重複したヘッダーなど。
覚えておくべきことは2つ:
*は空文字列にも一致します。/prefix/*はすでに/prefix/自体に一致します。完全一致のパスを追加したルールは念のための二重の備えではなく、それこそが不具合の原因でした。- 結合は
Cache-Controlにとっては無害です。これはディレクティブのリストだからです。しかし、文法が1トークンしかないヘッダーにとっては致命的です。
この重複こそが、1つ目の点を証明した方法でもあります。すべてのヘッダーがちょうど2回ずつ現れるのは、両方のパターンが一致した場合だけです。
今では、同じヘッダーを設定する2つのルールに一致するファイルがあると、デプロイスクリプトがビルドを失敗させます。このチェックは20行で書け、1日を節約できたはずのものでした。
失敗 2:HTTP キャッシュはボディだけでなく、レスポンスヘッダーも保存する
修正した _headers ファイルをデプロイしても、まだ失敗しました。同じページ、同じ種類のエラーですが、今度は別の場所からでした。Worker がまったく起動しないのです。 ErrorEvent は messageが空、 filenameも空で、行番号は 0 でした。Worker を生成してから約 4 ms 後に発火していました。
原因:ブラウザの HTTP キャッシュが保持していた tts-worker.js のコピーは、分離用ヘッダーが存在する
前 に取得されたものでした。キャッシュされたレスポンスは保存されたときのヘッダーをそのまま持ち続け、そのエントリーの max-age は4時間でした。クロスオリジン分離されたページは、自身のスクリプトに
COEP: require-corp がない Worker の実行を拒否します——しかも一切の詳細を示さずに。
つまり、サーバーは正しく、ネットワークタブも正しく見え、それなのにページが読み込んでいたのは、正しくない4時間前のレスポンスだったのです。
決め手になった診断方法で、手元に置いておく価値があります:
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'));
この2つが食い違うなら、見ているのは汚染されたキャッシュエントリーであって、サーバーの問題ではありません。私の場合、 fresh には3つのヘッダーがすべてあり、 cached には1つもありませんでした。
修正は2つ。どちらか一方だけでは足りないからです:
Cache-Control: no-cacheをフロントエンドのソースに付けます。サイズは 13〜31 KB なので、再検証のコストは、サーバー側のどんな変更でも終わらせられない4時間の障害に比べれば無いに等しいものです。- Worker の URL にバージョントークンを付ける(
tts-worker.js?v=…)ことで、すでに汚染されたエントリーを回避できるようにします。 分離用ヘッダーを変えるたびに、これを上げてください。
この不具合そのものより時間を食った副作用があります。私のローカル開発サーバーは、 ?v= を含む URL をすべて immutableとして扱っていました。そのため、バージョントークンを付けた瞬間からローカルでは Worker の変更が反映されなくなり、さらに悪いことに、まさにデバッグしていた部分でローカルと本番の挙動が食い違うようになったのです。
これを難しくしたのは、ローカルと本番のキャッシュの食い違いであって、キャッシュそのものではありません。
失敗 3:1ファイル 25 MiB の壁
Cloudflare Pages は 25 MiB を超えるファイルを1つも受け付けません。最大のモデルファイルは 82.0 MiB、もう1つは 37.9 MiB あります。
分割するのは当然として、書き留めておく価値があるのは「何を正とするか」です。分割ツールは各パートのパス、バイト数、SHA-256 をマニフェストに記録し、クライアントが信頼するのは元のファイルではなく、そのマニフェストになります:
models/flow_lm_main_delta_attn_flow_int8.onnx 82.0 MiB → 4 parts
models/mimi_encoder.onnx 37.9 MiB → 2 parts
Worker 内での再結合はとくに変わったところはありませんが、開発中にどちらも発火した2つのアサーションだけは別です:
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;
}
切り詰められたモデルは、読み込んでも例外を投げません。それらしく聞こえるゴミ音声を出力するだけです。バイト数をアサートしてください。
失敗 4:ホストは肝心なバイトを圧縮せず、HEAD はそのことについて嘘をつく
Cloudflare はコンテンツタイプに応じてレスポンスを圧縮します。 .wasm, .js, .css と
.html は自動的に処理されます。 .onnx、モデルのスライス、音声埋め込みは
application/octet-streamで、これらは 圧縮されずに送られます。
これがペイロードの大半です。そこで、パッケージ化の時点でバイトをあらかじめ圧縮し、元のファイル名のまま保存して、 Content-Encoding: br をファイルごとに _headers ファイルへ宣言します。
ファイルごとに、ワイルドカードではなく。 圧縮器は、意味のある縮小ができないものはスキップします。そして圧縮していない生のファイルを Content-Encoding: br 付きで配信すれば、それは単に壊れたファイルになります。 /models/* のようなルールは、一度も圧縮されていないファイルまで対象にしてしまいます。パッケージツールは、実際に圧縮したファイル1つにつき1つのルールを、意図ではなく結果から生成します。
ここからが、午後をまるまる無駄にした部分です。うまくいったか確かめるのに、真っ先に思いつくコマンドはこれです:
curl -sI https://example.net/models/big.onnx | grep -i content-encoding
返ってくるのは毎回 content-encoding: br です。 これは間違いです。 Cloudflare は HEAD リクエストにはこのヘッダーを付けて応答しますが、大きな application/octet-stream
のファイルに対する実際の GET はまったく圧縮されていません。レスポンスヘッダーを読むと、事実とまるで逆の結論にたどり着きます。
唯一信頼できる確認方法は、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)"
出力は wc -cにパイプし、 %{size_download} は使わないでください——これは回線上のバイト数を数えるもので、デコード後のバイト数ではないため、あなたが期待していたどちらの答えにも黙って同調してしまいます。
--compressed には brotli 対応でビルドされた curl が必要です (curl -V | grep -i brotli).
圧縮は実際どれほど効くのか
デプロイしたスライスでの実測値:
| バイト | ||
|---|---|---|
| 20.5 MiB の int8 ONNX スライス1つ、非圧縮 | 21,507,048 | |
| 同じスライス、brotli -q 11 | 8,752,904 | 40.7% |
| デプロイ全体、非圧縮 | 267.6 MiB | |
| デプロイ全体、保存時 | 184.9 MiB | 82.7 MiB 削減 |
この 40.7% は、見た目以上に興味深い数字です。このスライスのバイトの 40% は
0x00で、0次エントロピーは 4.19 ビット/バイト——つまり 記号の出現頻度だけを見る符号化器では 52.4% を下回ることはできないということです。Brotli はこの下限を余裕で下回る 40.7% に達しました。これは、そのコンテキストモデリングが単にバイトを数えているのではなく、バイトをまたいだ構造を見つけていることの具体的な証拠です。量子化された重みテンソルはそうした構造に満ちています。ゼロの連続、繰り返される指数パターン、ほぼ重複したブロック。
量子化モデルをネットワーク越しに配布していて、まだこれを測っていないなら、おそらく手の届くいちばん安い改善です。
もう1つ、節を立てるほどではないもの
しばらくの間、Worker は ちゃんと 自分の失敗を報告していたのに、私にはそれが見えていませんでした。メッセージは console.debugを通っていて、Chrome は既定のコンソールレベルではこれを非表示にします。Emscripten の標準エラー出力も、ルーティングの失敗もすべて、誰も見ないレベルに記録されていたのです。診断が頼るものは、 warn 以上のレベルで出すべきです。
チェックリスト
pthreads の WASM モデルを静的ホスティングに載せるなら:
- 同じパスに対して、2つの
_headersルールが同じヘッダーを設定しないようにする。 ビルドでアサートすること。*は空文字列にも一致します。 - 設定する
CORP: cross-originは分離したプレフィックスだけに。それ以外の場所には付けない。 - フロントエンドのソースは
no-cacheで配信し、Worker の URL にはバージョンを付ける。 キャッシュされたレスポンスは、古いヘッダーを一緒に持ち続けます。 - ローカルと本番のキャッシュを同一に保つ。 1時間で済む不具合を1日がかりにするのは、その食い違いです。
- 再結合後のバイト数をアサートする。 切り詰められたモデルは、例外ではなく出力を返します。
- 宣言する
Content-Encodingはファイルごとに、実際に圧縮したものから生成する。 - 圧縮の確認は、GET のバイト数を数えて行う。 HEAD は嘘をつきます。
- 診断のログは
warn以上のレベルで出す。
この記事のもとになったツールは Audiofilter です——ブラウザ内でボーカル分離、ノイズ除去、テキスト読み上げを行い、どれもファイルをアップロードしません。配信側ではなく信号処理側の話が知りたいなら、 位相キャンセルが低音までボーカルと一緒に持っていってしまう理由 がそれを扱っています。