Почему про WebAssembly вспоминают
Повод сесть за эту тему всегда конкретный, и поводов, по сути, два.
Первый: страница тормозит, и профайлер показывает, что время уходит не в отрисовку и не в сеть, а в сам JavaScript — в длинные циклы, пересборку объектов, сборщик мусора, который включается ровно тогда, когда нужен плавный кадр.
Второй: нужен код, которого в экосистеме JavaScript нет — кодек, парсер редкого формата, криптография, физический движок, — а на C или Rust он давно написан и обкатан.
Обе проблемы закрываются одним инструментом, но по-разному, и перепутать эти два пути — самая дорогая ошибка в теме. Поэтому сначала — устройство инструмента, потом — тупик, в который упираются почти все.
Как устроен выигрыш — и где спрятана цена
Модуль WebAssembly приходит в браузер уже скомпилированным: движок проверяет его и превращает в машинный код. Не надо парсить исходник до старта, нет лестницы «интерпретатор → оптимизирующий компилятор», по которой горячий JavaScript сперва работает медленно, а потом внезапно ускоряется. Для длинных вычислений это означает предсказуемость: скорость не зависит от того, разогрелся ли код, и движку не приходится выбрасывать готовую оптимизацию из-за того, что объект сменил форму.
Вторая половина выигрыша — память. Линейная память модуля — сплошной буфер, которым Rust или C распоряжаются сами: внутри нет сборщика мусора, а значит, нет пауз посреди кадра. Есть языки со сборкой мусора, которые тоже собираются в модули, но классический сценарий — Rust и C, где память держит сам код. Плюс модуль получает SIMD-инструкции и потоки через SharedArrayBuffer — то, что JavaScript-движок своему коду отдаёт неохотно.
Цена в том, что это два мира с двумя несвязанными памятями. Объекты JavaScript живут в куче движка, модуль видит только свою линейную память, DOM достижим только из JavaScript. Всё, что переезжает между мирами, надо либо копировать, либо отдавать под общий типизированный массив — и сразу решать, кто владеет буфером и кто его освобождает. Строки конвертируются, вложенные структуры сериализуются, и за каждым вызовом стоит сгенерированная связка: инструменты вроде wasm-bindgen снимают рутину с разработчика, но не отменяют работу — связка тоже исполняется и тоже копирует. Добавьте цену владения: кросс-компиляция в сборке, отладка поперёк двух миров, связки в бандле.
Короче: WebAssembly в браузере — не «быстрый JavaScript», а отдельный сопроцессор с собственной памятью, к которому ходят через шину. Решает не выбор языка, а частота поездок по шине. Теперь видно, почему первый инстинкт ведёт в тупик.
Тупик: переписать самую тяжёлую функцию
Первое, что пробуют почти все: открыть профайлер, найти самую прожорливую функцию, переписать её на Rust, скомпилировать и вызывать из старого кода. Логика железная: функция ест больше всего времени — заменим её скомпилированной, и всё ускорится.
Не ускоряется. Нередко становится медленнее. И это не кривизна конкретного инструмента, а устройство самого хода: он не помогает в принципе, по трём причинам, каждая из которых следует из предыдущего раздела.
Первая — из чего состоит горячая функция. Если она листовая, то по своему устройству это короткое арифметическое ядро: несколько операций над числами и возврат. А горячий числовой код — лучший случай для JIT: именно такие места движок компилирует в машинные инструкции раньше и охотнее всего, и чем дольше цикл греется, тем ближе результат к тому, что выдал бы компилятор заранее. На стороне вычислений обе части исполняют однотипные инструкции, и разрыв на один вызов невелик. Чем короче функция, тем большую долю её стоимости составляет всё вокруг неё.
Вторая — граница. Цикл остался в JavaScript, в модуль уехала только листовая операция, значит, граница пересекается на каждом элементе. Сам вызов движки давно сделали дешёвым; дорого не вызов, а переезд данных: аргументы уложить, строки перекодировать, результат обернуть обратно в объект. На одной стороне границы — пара умножений, на другой — упаковка и распаковка. Перевозка выходит дороже груза.
Третья — исходная медлительность часто вообще не в вычислениях. Профайлер меряет включённое время, и в нём сидят аллокации и сборка мусора от пересоздания объектов. Связующий слой эту картину воспроизводит честно: под каждый результат он создаёт JavaScript-объект, и сборщик мусора никуда не девается.
Схематично, без претензии на рабочий код:
// граница пересекается на каждом элементе
for (const p of points) {
total += module.transform(p.x, p.y);
}
// граница пересекается один раз, вся работа — внутри
// flat — типизированный массив, собранный заранее
const result = module.transformAll(flat);
Важно, что из этого тупика нельзя выбраться усердием внутри него. Лучший генератор связок, ручная упаковка аргументов, самый аккуратный Rust — всё это полирует третью причину и не трогает вторую: пока цикл живёт в JavaScript, пересечения остаются на каждом элементе, и их число не зависит от качества кода по обе стороны. Единственный выход — другой ход: переносить не функцию, а весь цикл вместе с массивом. Тогда граница пересекается дважды за прогон — значения вошли, результат вышел, — а вся работа идёт внутри. Прикидка перед стартом простая: если между двумя пересечениями границы модуль успевает сделать пару умножений, он проиграет; если прогоняет весь цикл — выиграет.
И порядок перед этим: сначала выжать из JavaScript то, что в нём есть — типизированные массивы вместо массивов объектов, ноль аллокаций в горячем цикле, вынос цикла в воркер, — потом снова профайлер. Если время по-прежнему уходит в чистые вычисления, теперь WebAssembly уместен.
Где уже окупается
Порт готового кода. Здесь сравнение не «модуль против JavaScript», а «модуль против переписывания». Если кодек, парсер или криптобиблиотека давно существуют на C, альтернатива у переноса одна: экспедиция по переписыванию и вылавливание ошибок, которых в оригинале уже нет. Перенос дешевле, и корректность переезжает вместе с кодом. Бонус, который легко упустить: кодовая база остаётся одной — модуль работает и в браузере, и в консольной утилите, и на сервере, правите в одном месте. Характерный профиль нагрузки: большой буфер на входе, длинная обработка внутри, буфер на выходе. Вход и выход — вот и все пересечения границы; идеальный случай. Сюда же — эмуляторы, браузерные инструменты для языков, обработка медиа прямо на клиенте: файл не надо гонять по сети, и граница с сервером исчезает как класс.
Долгие вычисления, где важен худший кадр. Геометрия для CAD, процедурная генерация, симуляции. Выигрыш сидит в хвосте распределения, а не в среднем: нет пауз сборщика мусора, нет прогрева. И проверять надо соответственно — мерить худшие кадры и сравнивать именно их: рывок на экране виден глазу, среднее — нет.
Когда вычислений больше, чем интерфейса. Игры, редакторы с тяжёлым ядром, эмуляция. Архитектуру здесь переворачивают: состояние живёт в модуле, а JavaScript остаётся тонкой оболочкой — ввод, холст, события. Граница пересекается по событию, а не на каждый элемент, и её цена размазывается по всему кадру. Это зеркальный ответ тупику из предыдущего раздела: не приложение зовёт модуль на каждом шаге, а модуль зовёт приложение, когда ему нужен внешний мир.
Песочница для чужого кода. Единственный случай, где окупаемость вообще не про скорость: запуск плагинов и пользовательских расширений. У модуля нет прав по умолчанию — всё внешнее приходит через импорты, память изолирована. Это модель безопасности, которой у динамически загружаемого JavaScript нет. Граница, которая во всех историях выше была врагом, здесь работает на вас.
Где не окупается — коротко: DOM-логика и формы, типовое состояние SPA, мелкие утилиты — всё, где значения рождаются в JavaScript-объектах по клику и сразу туда возвращаются. Массив перебирается один раз на каждое событие — границу не амортизировать, модуль будет чистым убытком.
Одна арифметика для всех границ
Мы уже разбирали, как проводить границу между клиентом и сервером. Здесь та же арифметика, только цена пересечения другая — и её можно вывести из устройства, ни у кого не занимая замеров. Пересечение сети — это сериализовать значения, прогнать их по проводу и ждать ответа, пока обе стороны простаивают. Пересечение воркера — копия между потоками внутри одной машины, без транзита. Пересечение модуля — вызов внутри одного процесса: разложить аргументы в памяти и передать управление. Из конструкции следует порядок: сеть дороже всех, воркер дешевле, модуль дешевле остальных.
Правило одно на все случаи: стоимость пересечения, умноженная на число пересечений, сравнивается с работой между пересечениями.
Практически: возьмите свою задачу и посчитайте три вещи — как часто придётся пересекать границу, сколько значений переезжает за раз, сколько работы делается между пересечениями. Первые два числа дают цену, третье — выигрыш. Если доминирует работа, выносите её за границу — в модуль, в воркер или на сервер, по цене пересечения. Если доминируют пересечения, перестраивайте поток: никакой инструмент этого не чинит.
Позиция редакции
Мы считаем, что WebAssembly во фронтенде окупается ровно в двух случаях: когда переносят уже написанный код, который иначе пришлось бы переписывать, и когда есть длинные вычисления, значения для которых могут жить на стороне модуля. Как «ускоритель JavaScript» он не окупается: если после подключения модуля приложение стало быстрее, то по той же арифметике выиграл не модуль, а попутно перестроенный поток — а его можно было перестроить и без модуля.
Отсюда порядок: сначала поток, потом профайлер, потом худшие кадры — и только потом решение про модуль.
И условие, при котором мы неправы: если ваши вычисления короткие, живут по событиям интерфейса и значения каждый раз собираются в JavaScript, но при этом настолько тяжёлые, что даже с ценой границы на каждое событие модуль выигрывает, — наша позиция для вашего случая неверна, переносите смело. Проверка на месте: вынесите значения в линейную память, перенесите цикл целиком и сравните худшие кадры до и после. Эти два числа — ваши, не наши — скажут больше любой нашей позиции.