Разработка

WASM-модель на 130 МБ на Cloudflare Pages

Четыре сбоя при развёртывании pthreads-WASM-модели для аудио на Cloudflare Pages. Три из них не дают внятного сообщения об ошибке, а один сообщает прямо противоположное правде.

около 7 минут

Модели машинного обучения теперь работают в браузерах. И не игрушечные — модель разделения источников, которая раскладывает песню на вокал и инструментал, и движок синтеза речи с клонированием голоса, оба скомпилированы в WebAssembly и выполняются на процессоре самого посетителя. Без загрузки на сервер, без сервера инференса, без счетов за GPU.

Инференс оказался лёгкой половиной задачи. Выложить 130 МБ модели на статический хостинг и уговорить браузер её запустить — это дало четыре сбоя. Три не дают внятного сообщения об ошибке. Один сообщает прямо противоположное правде.

Все цифры ниже измерены на развёрнутых артефактах, а не оценены.

Исходные условия

Сборке WebAssembly с pthreads нужен SharedArrayBuffer. Браузеры предоставляют его только изолированной (cross-origin isolated) странице, а это значит три заголовка ответа:

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

Третий — самый интересный, и именно в нём ошибается большинство статей. Если задан COEP: require-corp, то pthread-воркеры emscripten и вложенный воркер, который запускает движок, загружают свои подресурсы из того, что браузер считает непрозрачным источником (opaque origin). 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 исправлен и задеплоен, а сбой никуда не делся. Та же страница, тот же класс ошибки, но теперь из другого места: воркер вообще не запускался. У ErrorEvent было пустое message, пустое filename, а номер строки — 0. Событие срабатывало примерно через 4 мс после создания воркера.

Причина: HTTP-кэш браузера хранил копию tts-worker.js — её загрузили до появления заголовков изоляции. Кэшированные ответы несут те заголовки, с которыми были сохранены, а у этой записи был max-age в четыре часа. Изолированная (cross-origin isolated) страница отказывается запускать воркер, у скрипта которого нет COEP: require-corp — и отказывается без каких-либо подробностей.

Итак, сервер отвечал правильно, вкладка «Сеть» выглядела правильно, а страница загружала четырёхчасовой давности ответ, который правильным не был.

Диагностика, которая всё прояснила и которую стоит держать под рукой:

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 — ни одного.

Два исправления, потому что по отдельности ни одного не хватает:

Есть следствие, которое отняло у меня больше времени, чем сам баг: мой локальный dev-сервер воспринимал любой URL с ?v= как immutable. Так что стоило мне добавить токен версии, как локальная среда перестала подхватывать правки воркера — и, что хуже, локальная среда и продакшен теперь вели себя по-разному ровно в той области, которую я отлаживал. Трудным всё это сделало расхождение в кэшировании между локальной средой и продакшеном, а не само кэширование.

Сбой 3: стена в 25 МиБ на файл

Cloudflare Pages отклоняет любой файл больше 25 МиБ. Самый большой файл модели — 82,0 МиБ. Ещё один — 37,9 МиБ.

Разбить файл — очевидное решение; записать стоит другое: куда переходит источник истины. Инструмент нарезки записывает путь, размер в байтах и 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

Сборка частей в воркере ничем не примечательна, кроме двух проверок, и обе срабатывали во время разработки:

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-запрос с этим заголовком, хотя настоящий GET большого файла application/octet-stream не сжат вовсе. Заголовки ответа говорят вам прямо противоположное истине.

Единственная надёжная проверка — посчитать байты из 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 нужен curl, собранный с поддержкой brotli (curl -V | grep -i brotli).

Сколько на самом деле даёт сжатие

Измерено на развёрнутых нарезках:

байты
Одна нарезка int8 ONNX на 20,5 МиБ, без сжатия 21,507,048
Та же нарезка, brotli -q 11 8,752,904 40.7%
Весь деплой, без сжатия 267,6 МиБ
Весь деплой, как хранится 184,9 МиБ экономия 82,7 МиБ

Эти 40,7 % интереснее, чем кажется. 40 % байтов в этой нарезке — это 0x00, а её энтропия нулевого порядка — 4,19 бит/байт; это значит, что ни один кодер, который смотрит только на частоты символов, не опустится ниже 52,4 %. Brotli достиг 40,7 % — с запасом ниже этого порога, что наглядно показывает: его контекстное моделирование находит структуру между байтами, а не просто их подсчитывает. Квантованные тензоры весов полны такой структуры: серии нулей, повторяющиеся паттерны экспоненты, почти дублирующиеся блоки.

Если вы передаете квантованные модели по сети и еще не измеряли это, скорее всего, это самый дешевый выигрыш, который вам доступен.

И еще одно, без отдельного раздела

Какое-то время воркер на самом деле сообщал о своей ошибке, а я этого не видел. Сообщения шли через console.debug, который Chrome скрывает на уровне консоли по умолчанию. stderr Emscripten и все ошибки маршрутизации писались на уровень, на который никто не смотрит. Всё, от чего зависит диагностика, должно выводиться на уровне warn или выше.

Чек-лист

Если вы размещаете WASM-модель с pthreads на статическом хостинге:

  1. Никогда не допускайте, чтобы два правила _headers задавали один и тот же заголовок для одного пути. Проверяйте это при сборке. * совпадает и с пустой строкой.
  2. Задавайте CORP: cross-origin на изолированном префиксе, и только там.
  3. Отдавайте исходники фронтенда с no-cache, а URL воркеров версионируйте. Закэшированный ответ несёт с собой старые заголовки.
  4. Держите кэширование локально и в продакшене одинаковым. Именно расхождение превращает баг на час в баг на день.
  5. Проверяйте размер собранного файла в байтах. Обрезанная модель выдаёт результат, а не исключение.
  6. Указывайте Content-Encoding для каждого файла, формируя его по тому, что реально было сжато.
  7. Проверяйте сжатие, считая байты ответа на GET. HEAD вас обманет.
  8. Пишите диагностику на уровне warn или выше.

Всё это выросло из работы над Audiofilter — браузерным инструментом для разделения вокала, шумоподавления и синтеза речи, который никуда не загружает ваш файл. Если вас интересует не доставка, а DSP, читайте статью «Почему фазовое вычитание забирает бас вместе с вокалом» — там всё разобрано подробно.