Подробные статьи о технологиях, определяющих будущее.

Когда WebAssembly медленнее JavaScript

WebAssembly не всегда быстрее JavaScript. Замеры показывают, когда TypeScript опережает WASM и почему переходы между JS и WASM съедают производительность.

Бегун, застрявший на пограничном контрольно-пропускном пункте, пока другой свободно пробегает мимо

В прошлую субботу я потратил день на то, чтобы переписать парсер метаданных изображений с Rust-в-WASM на TypeScript. WASM-версия работала в продакшене восемь месяцев. Всё было нормально, никто не жаловался. Но я смотрел на трейсы производительности, и что-то не давало покоя: WASM-парсер тратил больше времени на перетаскивание данных через границу JS-WASM, чем на сам парсинг. Поэтому я переписал его. Чистый TypeScript, без WASM. Результат оказался на 38% быстрее для нашего медианного payload. Я не придумал более удачных алгоритмов. Я просто перестал платить налог за пересечение границы.

Этот опыт перевернул что-то у меня в голове. Я был из тех разработчиков, кто считал WebAssembly быстрым вариантом по умолчанию, и точка. Оказывается, это предположение ошибочно чаще, чем большинство из нас думает.

Миф «WASM всегда быстрее» пора похоронить

Вот модель, которую держит в голове большинство разработчиков: скомпилированный язык идёт в WASM, WASM работает почти на нативной скорости, значит WASM быстрее JavaScript. Каждое звено этой цепочки более-менее верно. Но вывод из них не следует, потому что он игнорирует стоимость всего, что окружает вычисления: передачу данных внутрь, получение результатов обратно и накладные расходы на работу двух рантаймов бок о бок.

Движки JavaScript невероятно хороши. V8, SpiderMonkey и JavaScriptCore — это десятилетия оптимизаций, на которые тратили деньги одни из самых богатых компаний планеты. Они используют спекулятивную JIT-компиляцию, inline caching, escape analysis и переходы между hidden classes. Для многих нагрузок, особенно характерных для веб-приложений с большим количеством строк, объектов и колбэков, современный JS-движок генерирует машинный код, который удивительно близок к тому, что дал бы статический компилятор.

WASM таких оптимизаций не получает. Он компилируется заранее (AOT): что вы отправили, то и выполняется. Это плюс с точки зрения предсказуемости, но WASM не может адаптироваться к паттернам выполнения так, как это умеет JIT.

Проблема пересечения границы: смерть от тысячи вызовов

Это главная проблема. У каждого вызова из JavaScript в WASM (и обратно) есть накладные расходы. Движку нужно маршалить данные, проверять типы, переключать контексты выполнения. Один переход стоит недорого, всего несколько микросекунд. Но нагрузки, которые пересекают границу тысячи раз за одну операцию, этими расходами буквально убиваются.

Я постоянно вижу один и тот же паттерн: команда пишет парсер, трансформер или валидатор на Rust, компилирует его в WASM, а потом оборачивает JavaScript-клеем, который вызывает WASM для каждого узла, каждого токена, каждого шага. Ядро WASM может быть очень быстрым само по себе, но клей превращает его в платную дорогу.

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

Я провёл бенчмарк этого сценария на реальной кодовой базе. WASM-версия обработала пакет из 100 конфигурационных файлов за 340 мс. TypeScript-порт справился за 195 мс. Не потому, что TypeScript быстрее как язык, — он не быстрее, — а потому, что работа ни разу не покидала единый контекст выполнения.

Почему строки убивают производительность WASM

WASM работает с линейной памятью, плоским буфером байтов. Строки JavaScript — совсем другой зверь. Это объекты, которыми управляет движок, и хранятся они во внутренних специализированных форматах (Latin1, UTF-16, ropes, cons-строки). Чтобы передать строку из JS в WASM, её нужно закодировать в UTF-8, выделить место в линейной памяти и скопировать байты. Чтобы получить строку обратно, нужно снова копировать и декодировать.

Для вычислений с числами это не важно: вы передаёте TypedArray и получаете числа обратно. Но для всего, что активно работает со строками (парсеров, шаблонизаторов, валидаторов, сериализаторов), вы платите налог «закодировать, скопировать, обработать, скопировать, декодировать» за каждый фрагмент строки.

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

Я профилировал наш WASM-парсер и выяснил, что 31% общего времени выполнения уходило на сериализацию строк. Не на парсинг. Не на построение дерева. Просто на перекидывание строк туда и обратно через границу. TypeScript-версия устранила эту категорию работы целиком.

Если ваш горячий путь завязан на строки, WASM, скорее всего, делает его медленнее, а не быстрее. Накладные расходы на сериализацию реальны, и они быстро накапливаются.

JIT-компиляция: преимущество производительности, о котором никто не говорит

AOT-модель WASM даёт предсказуемую, стабильную производительность. Для некоторых сценариев это отлично. Но модель JIT в JavaScript работает иначе: V8 наблюдает за выполнением кода и генерирует оптимизированный машинный код, настроенный под реальные данные, которые через него проходят. Со временем JIT-скомпилированный код может становиться невероятно быстрым.

Возьмём специализацию по форме объекта. Если вы обрабатываете массив объектов с одинаковыми свойствами в одном и том же порядке, V8 обнаруживает этот мономорфный паттерн и генерирует код, который обращается к свойствам по фиксированному смещению в памяти, без поиска в хеш-таблице и без проверки типов. По сути, это производительность C-структур в динамическом JavaScript.

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASM так не умеет. Его характеристики производительности фиксируются на этапе компиляции. Для жёстких, предсказуемых числовых циклов это нормально: компилятор Rust уже выжал из них максимум. Но для запутанного, полиморфного, насыщенного колбэками кода, из которого состоит большинство веб-приложений, способность JIT специализироваться во время выполнения — реальное преимущество.

Размер бандла и холодный старт: метрики, которые вы забываете

Пропускная способность — не единственная метрика производительности. Пользователи замечают время загрузки, задержку взаимодействия и время до первого результата. По всем трём параметрам у WASM есть реальные издержки.

  • Модуль Rust с wasm-bindgen обычно весит 100 КБ–1,5 МБ в gzip. Эквивалентный TypeScript после минификации и gzip обычно занимает 10–40 КБ.
  • Модули WASM должны быть скомпилированы в нативный код до выполнения. Потоковая компиляция помогает, но это всё равно работа, которую браузеру приходится делать.
  • Движки JavaScript парсят лениво: функции не компилируются, пока их не вызвали. WASM платит полную цену компиляции заранее.
  • Кеш кода V8 означает, что повторные визиты полностью пропускают парсинг JS. Кеширование компиляции WASM тоже существует, но оно менее зрелое.

Вот реальное сравнение из нашего проекта. Бандл WASM-парсера весил 380 КБ в gzip, TypeScript-замена — 26 КБ. На хорошем соединении это не так важно. Но на замедленном 3G (его я симулирую в каждом ревью производительности) WASM-версия добавляла 1,8 секунды к загрузке. Это не погрешность округления, а разница между тем, останется пользователь или уйдёт.

Холодный старт тоже имел значение. На телефоне среднего класса WASM-модулю требовалось около 110 мс на компиляцию и инстанцирование. TypeScript-версия становилась интерактивной меньше чем за 20 мс. Для инструмента, к которому пользователи обращаются десятки раз в день, это складывается в реальное раздражение.

Когда WASM действительно выигрывает (и когда его стоит использовать)

Я не хочу создать впечатление, что WASM — плохая технология. Это отличная технология с конкретной зоной применения. Проблема в том, что её используют за пределами этой зоны, а потом удивляются, почему всё стало медленнее.

WASM выигрывает, когда вычисления тяжёлые, пересечений границы мало, а данные числовые. Например: фильтры изображений, работающие с буферами пикселей, криптографическое хеширование, физические симуляции, аудио DSP, видеокодеки. Вы загружаете большой кусок данных в линейную память, даёте WASM прогнать его в плотном цикле и забираете результат. Один переход туда, один обратно, а между ними огромный объём вычислений. Вот где WASM блистает.

WASM также выигрывает, когда нужна детерминированная производительность. JIT-компиляция мощна, но непредсказуема: первые несколько вызовов JS-функции выполняются интерпретатором, потом идёт базовая компиляция, а затем, возможно, оптимизирующая. Если вам нужна стабильная задержка с самого первого вызова (игровые циклы, аудио в реальном времени, финансовые расчёты), опережающая компиляция WASM даёт именно это.

И WASM, очевидно, правильный выбор, когда вы портируете существующую нативную кодовую базу. Никто не должен переписывать SQLite на JavaScript, чтобы избежать накладных расходов WASM. Это было бы безумием. Существующий C-код воплощает десятилетия оптимизаций, которые вы никогда не повторите.

Практическая схема решения: WASM или TypeScript?

Пройдя этот эксперимент на трёх проектах, я пришёл к простому чек-листу. Он не идеален, но дважды уже спас меня от неправильного выбора.

  1. Считайте пересечения границы. Если ваш модуль WASM вызывает JS (или наоборот) больше нескольких сотен раз за операцию, TypeScript, скорее всего, выиграет. Профилируйте, чтобы убедиться.
  2. Посмотрите на типы данных. В основном числа и typed arrays? WASM хорошо подходит. В основном строки, объекты и колбэки? Оставайтесь на JS.
  3. Измерьте стоимость старта. Если код выполняется при загрузке страницы или в ответ на клики пользователя, накладные расходы на компиляцию WASM имеют значение. Если он работает в долгоживущем воркере, старт амортизируется.
  4. Проверьте бюджет бандла. Если вы уже сражаетесь с размером бандла, добавление WASM-модуля на 400 КБ сделает только хуже.
  5. Учитывайте стоимость DX. WASM добавляет сборочную систему Rust или C++, сложность source map и более трудную отладку. Если выигрыш в производительности незначителен, сложность не оправдана.
  6. Бенчмаркайте на реальных данных. Микробенчмарки врут. Накладные расходы проявляются только на входных данных production-размера и в реалистичных паттернах вызовов.

Что я вынес из переписывания: до и после

Вот реальные цифры из нашей миграции парсера. Это не синтетические бенчмарки: мы обрабатывали наш настоящий корпус из 200 файлов, от 50 до 2000 строк.

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

Переписывание на TypeScript не было прямым портом. Я переделал представление AST так, чтобы оно хорошо ладило с оптимизатором V8: мономорфные формы объектов, числовые enum вместо строковых тегов, плоское хранение в массивах вместо деревьев с большим количеством указателей. Такие оптимизации в Rust никому и в голову не придут, потому что компилятор Rust сам справляется с этим уровнем деталей. В JavaScript приходится идти навстречу JIT.

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

Ключевая мысль? Самый быстрый код — не тот, что написан на самом быстром языке. Это код, который работает со своим рантаймом, а не против него. Наш Rust-код был отличным Rust. Но после компиляции в WASM и встраивания в JavaScript-приложение он на каждом шагу боролся с платформой.

Перестаньте предполагать, начните измерять

WASM я по-прежнему использую. У меня есть конвейер обработки изображений на WASM, который в 4 раза быстрее всего, что я мог бы написать на JavaScript, потому что он работает с сырыми буферами пикселей и без единого пересечения границы. Нужный инструмент для нужной задачи.

Но я перестал по умолчанию тянуться к WASM, когда нужна производительность. Мой новый подход: сначала написать TypeScript-версию, измерить её на реальных данных и обращаться к WASM только тогда, когда профилировщик говорит, что это нужно, и только для конкретного горячего пути, который действительно медленный. Не весь модуль. Не всю фичу. Только плотный вычислительный цикл, которому действительно выгодна опережающая компиляция.

Пишите на TypeScript. Измеряйте. Если работает достаточно быстро, отправляйте. Если нет, переносите горячий цикл, и только его, в WASM. В итоге у вас будет меньше кода, меньше бандл и быстрее приложения.

У веб-платформы есть две мощные модели выполнения. Используйте обе. Но используйте их там, где они реально помогают, а не там, где вам подсказывает интуиция.