Модели машинного обучения теперь работают в браузерах. И не игрушечные — модель разделения источников, которая раскладывает песню на вокал и инструментал, и движок синтеза речи с клонированием голоса, оба скомпилированы в 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
Ничто в этом сообщении не указывает на заголовок ответа, не говоря уже о продублированном.
Два вывода:
*совпадает и с пустой строкой./prefix/*уже покрывает и путь/prefix/как таковой. Дополнительное правило для точного пути было не подстраховкой — оно и было багом.- Объединение безвредно для
Cache-Control, который является списком директив. Но оно фатально для любого заголовка, грамматика которого — один токен.
Дублирование заодно доказало первый пункт: каждый заголовок мог появиться ровно дважды, только если совпали оба шаблона.
Теперь мой скрипт деплоя роняет сборку, если какой-либо файл попадает под два правила, задающих один и тот же заголовок. Эта проверка — двадцать строк кода, и она сэкономила бы мне день.
Сбой 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 — ни одного.
Два исправления, потому что по отдельности ни одного не хватает:
Cache-Control: no-cacheдля исходников фронтенда. Они весят 13–31 КБ; их повторная валидация ничего не стоит по сравнению с четырёхчасовым сбоем, который никакое изменение на сервере не может прекратить.- Токен версии в URL воркера (
tts-worker.js?v=…), чтобы обойти уже отравленную запись. Меняйте его каждый раз, когда меняются заголовки изоляции.
Есть следствие, которое отняло у меня больше времени, чем сам баг: мой локальный 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 на статическом хостинге:
- Никогда не допускайте, чтобы два правила
_headersзадавали один и тот же заголовок для одного пути. Проверяйте это при сборке.*совпадает и с пустой строкой. - Задавайте
CORP: cross-originна изолированном префиксе, и только там. - Отдавайте исходники фронтенда с
no-cache, а URL воркеров версионируйте. Закэшированный ответ несёт с собой старые заголовки. - Держите кэширование локально и в продакшене одинаковым. Именно расхождение превращает баг на час в баг на день.
- Проверяйте размер собранного файла в байтах. Обрезанная модель выдаёт результат, а не исключение.
- Указывайте
Content-Encodingдля каждого файла, формируя его по тому, что реально было сжато. - Проверяйте сжатие, считая байты ответа на GET. HEAD вас обманет.
- Пишите диагностику на уровне
warnили выше.
Всё это выросло из работы над Audiofilter — браузерным инструментом для разделения вокала, шумоподавления и синтеза речи, который никуда не загружает ваш файл. Если вас интересует не доставка, а DSP, читайте статью «Почему фазовое вычитание забирает бас вместе с вокалом» — там всё разобрано подробно.