工程

Cloudflare Pages 上的 130 MB WASM 模型

把一个 pthreads WASM 音频模型部署到 Cloudflare Pages 时踩到的四个坑。其中三个不产生任何可用的报错信息,还有一个给出的结论与事实正好相反。

约 7 分钟

机器学习模型现在已经能在浏览器里运行了。而且不是玩具——一个把歌曲拆成人声和伴奏的音源分离模型,一个带声音克隆的文字转语音引擎,两者都编译成 WebAssembly,在访客自己的处理器上执行。不用上传,没有推理服务器,也没有 GPU 账单。

推理本身反倒是容易的那一半。把 130 MB 的模型放上静态托管、再让浏览器同意运行它,前后踩了四个坑。其中三个不给你任何可用的报错信息,一个给你的结论与事实正好相反。

下面所有数字都是在已部署的产物上实测的,不是估算。

背景

pthreads 版的 WebAssembly 构建需要 SharedArrayBuffer。而浏览器只把它开放给 跨源隔离 的页面,也就是说需要三个响应头:

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

有意思的是第三个,大多数文章也正是在这里写错的。在 COEP: require-corp下,Emscripten 的 pthreads 工作线程,以及引擎再派生出来的嵌套 Worker,会从浏览器视为不透明来源的地方去拉取各自的子资源。 Cross-Origin-Resource-Policy: same-origin 会把这些请求挡掉。它必须设成 cross-origin,这是一个比直觉所想 更宽松 的取值,而且只作用在需要它的那一个路径前缀上、不要整站套用,因为 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 不会 让更具体的那条规则胜出——它会把同名响应头合并成一个用逗号连接的值:

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:HTTP 缓存存的不只是响应体,还有响应头

修好 _headers 文件并部署之后,还是失败。同一个页面,同一类错误,只是换了个地方:Worker 根本起不来。那个 ErrorEvent 的 message为空, filename也为空,行号是 0。它在 Worker 创建后约 4 ms 就触发了。

原因是:浏览器的 HTTP 缓存里存着一份 tts-worker.js ,是在隔离响应头出现 之前 拉取的。缓存的响应会带着它被存下时的响应头,而那条缓存的 max-age 是四个小时。跨源隔离的页面会拒绝运行自身脚本缺少 COEP: require-corp 的 Worker——而且拒绝时不给任何细节。

所以服务器是对的,网络面板看起来也是对的,而页面加载的却是一份四小时前的、并不对的响应。

最终定位问题的诊断方法,值得留着备用:

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

如果这两者对不上,你面对的是一条被污染的缓存,而不是服务器的问题。我这里 fresh 带着全部三个响应头,而 cached 一个都没有。

两个修法都要做,单做任何一个都不够:

还有一个衍生问题,花掉的时间比这个故障本身还多:我的本地开发服务器会把任何带 ?v= 的 URL 当作 immutable来缓存。于是我一加版本号,本地就再也拿不到 Worker 的修改了——更糟的是,本地和线上恰好在我正在排查的这一块表现不一致了。 让这件事变难的,是本地与线上缓存行为的不一致,而不是缓存本身。

坑 3:单文件 25 MiB 的墙

Cloudflare Pages 会拒绝任何超过 25 MiB 的单个文件。最大的模型文件有 82.0 MiB,另一个有 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 里的重组过程本身平平无奇,只有两条断言值得一提——开发过程中它们都触发过:

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/* 规则会把从没压缩过的文件也算进去。打包工具按实际压缩了的文件逐个生成规则——依据的是结果,而不是意图。

接下来是浪费了一个下午的部分。要检查它有没有生效,最顺手的命令是:

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 分片,原始 21,507,048
同一分片,brotli -q 11 8,752,904 40.7%
整个部署,原始 267.6 MiB
整个部署,实际存储 184.9 MiB 省下 82.7 MiB

40.7% 这个数比看上去有意思。那个分片里 40% 的字节都是 0x00,它的零阶熵是每字节 4.19 比特——这意味着 任何只看符号频率的编码器都不可能压到 52.4% 以下。Brotli 压到了 40.7%,稳稳低于这条下限,这具体证明了它的上下文建模是在跨字节地发现结构,而不仅仅是在数频率。量化后的权重张量里满是这种结构:成串的零、重复的指数模式、近乎重复的数据块。

如果你正在通过网络分发量化模型、却还没测过这个,这很可能是你能拿到的最便宜的收益。

还有一个,不单列一节

有一段时间,Worker 其实 一直在 报告自己的失败,只是我看不到。这些消息走的是 console.debug,而 Chrome 在默认的控制台级别下会把它隐藏。Emscripten 的标准错误输出和每一次路由失败,都被记到了一个没人会看的级别里。诊断所依赖的任何东西,都应该打在 warn 或更高的级别上。

检查清单

如果你要把一个 pthreads WASM 模型放上静态托管:

  1. 永远不要让两条 _headers 规则在同一路径上设置同一个响应头。 在构建里断言这一点。 * 也会匹配空字符串。
  2. 设置 CORP: cross-origin 只用在需要隔离的前缀上,别处都不要。
  3. 前端源文件用 no-cache,并给 Worker 的 URL 加版本号。 缓存下来的响应会带着它当初的响应头。
  4. 保持本地和线上的缓存行为一致。 把一个小时的问题拖成一天的,正是两者的不一致。
  5. 断言重组后的字节数。 截断的模型会产出结果,而不是抛出异常。
  6. 声明 Content-Encoding 要逐个文件来,依据是实际压缩了哪些文件。
  7. 通过数 GET 拿到的字节数来验证压缩。 HEAD 会对你撒谎。
  8. 诊断日志至少要打在 warn 或更高级别。

这些经验来自的工具是 Audiofilter ——在浏览器端完成人声分离、降噪和文字转语音,没有一个会上传你的文件。如果你想看的是信号处理这一侧、而不是分发这一侧, 为什么相位抵消会把低音和人声一起带走 讲的就是这个。