TL;DR

  • WASM (WebAssembly) отлично подходит как для фронтенда, так и для бэкенда, а не только для ускорения JavaScript в браузере.
  • WASM на бэкенде работает иначе, чем FFI (Foreign Function Interface — интерфейс внешних функций). WASM обеспечивает более быструю и эффективную работу.
  • Скорость работы WASM обусловлена его низкоуровневым двоичным форматом, простой моделью памяти и опережающей компиляцией. Это минимизирует накладные расходы, обеспечивая производительность, близкую к производительности нативного кода.
  • Результат использования Rust и WASM для оптимизации генерации ULID в wa-ulid — 40-кратное ускорение по сравнению с JavaScript-версией.
  • В настоящее время файлы WASM больше, чем JavaScript, что может быть проблематично. Но по мере совершенствования инструментальных цепочек и методов оптимизации WASM будет становиться более полезным как для бэкенд-, так и для фронтенд-приложений.

Введение

Обычно я осваиваю новые технологии в области разработки, проходя этапы, аналогичные стадиям цикла зрелости технологии, определенным исследователями Gartner. Данный цикл демонстрирует типичный путь каждой новой технологии — от запуска к принятию. В этой статье я расскажу, как при использовании технологии WASM перешел от скепсиса к восторгу, особенно впечатлившись повышением производительности бэкенд-систем. 

Стадии цикла зрелости технологии: запуск — пик завышенных ожиданий — нижняя точка разочарования — склон усовершенствования — плато производительности 

WASM — это низкоуровневый формат инструкций. Он разработан как целевая платформа компиляции для таких языков, как C, C++ и Rust. Основное его назначение — обеспечение высокопроизводительными веб-приложениями. При этом он все чаще используется на стороне сервера, когда производительность имеет решающее значение.

В моем исследовании WASM были взлеты и падения. Оно началось с завышенных ожиданий, сменившихся разочарованием, но закончилось прочным пониманием и практическим применением.

Первоначальные заблуждения

Едва ознакомившись с WASM, я возлагал на него большие надежды. Мне казалось, что WASM позволит плавно интегрировать сложные вычисления в веб-браузеры подобно тому, как FFI позволяет высокоуровневым языкам выполнять машинный код.

Что такое FFI?

FFI — это механизм, с помощью которого код, написанный на одном языке, напрямую вызывает код, написанный на другом языке. FFI используется, когда производительность имеет решающее значение и определенная логика реализована на низкоуровневом языке, таком как C или Rust. Затем этот низкоуровневый код вызывается из языков более высокого уровня, таких как Python или JavaScript.

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

Сравнение WASM с FFI

Рассматривая WASM как FFI, я упустил то, что отличает WASM от традиционного машинного кода. В FFI часто возникают большие накладные расходы при переключении между основным языком и внешней функцией. Перемещение данных между различными схемами памяти также требует больших затрат.

Проверка реальных возможностей WASM

По мере изучения WASM я начал замечать разрыв между своими первоначальными ожиданиями и результатами его реального применения.

Первые шаги с WASM и Rust

Я начал экспериментировать с WASM, используя wasm-bindgen — инструмент, который помогает модулям WASM и JavaScript работать вместе. Мой первый опыт был прост:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
  a + b
}

При использовании wasm-pack с LTO (link-time optimization — оптимизацией времени соединения) базовая соединяющая функция add скомпилировалась в крошечный 214-байтный WASM-модуль. Изначально это казалось подтверждением того, что WASM может обеспечить компактный и эффективный код.

Ознакомление с WAT-форматом

Чтобы лучше понять, как работает такой маленький фрагмент кода, я обратился к версии WAT (WebAssembly Text Format — текстовой формат WebAssembly). WAT — это читаемая версия двоичных форматов WASM. Она необходима для отладки и оптимизации WASM-приложений. Вот WAT для функции add:

(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func (;0;) (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  (memory (;0;) 17)
  (export "memory" (memory 0))
  (export "add" (func 0)))

Такой лаконичный формат показывает эффективность WASM для решения простых вычислительных задач — никаких лишних накладных расходов, только основные операции для выполнения функции.

Влияние дополнительной сложности на размер WASM-модуля

Затем я усложнил эксперимент, добавив строковые операции, чтобы посмотреть, как это повлияет на размер модуля:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
    let a = a.to_string().parse::<u32>().unwrap();
    let b = b.to_string().parse::<u32>().unwrap();
    return a + b;
}

Несмотря на те же вычисления, эта версия создала гораздо больший модуль WASM размером 14,5 КБ. Файл WAT вырос до более чем 7 126 строк, что отражает дополнительную сложность и накладные расходы, связанные с обработкой строк.

1/3 файла WAT после добавления манипуляций со строками

Конструктор WebAssembly.Instance может синхронно компилировать только модули размером менее 4 КБ. Модули большего размера должны компилироваться асинхронно. Но мне показалось невозможным сохранить WASM-файлы, чтобы они не превышали такого лимита.

Разочарование

Основной проблемой стало резкое увеличение размера модуля WASM при добавлении таких функций, как манипуляции со строками. Увеличение размера файла противоречило представлению о WASM как о легком и эффективном формате.

Оптимизация размера модуля WASM

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

  • Избегание паник. Обработка паники (panic) в Rust приводит к дополнительным накладным расходам. Использование типов Option и Result эффективно справляется с ошибками и позволяет избежать раздувания паники.
  • Ограничение использования строк. Динамические операции со строками значительно увеличивают размер модуля WASM. Использование целочисленных типов или типов данных фиксированного размера позволяет сохранить компактность модулей.
  • Оптимизация времени соединения (LTO). Включение LTO в компиляторе Rust уменьшает размер скомпилированного WASM за счет удаления неиспользуемого кода и оптимизации по границам ячеек.
  • Ручная встряска дерева. В то время как автоматическая встряска дерева в конвейере Rust → WASM ограничена, ручное включение только необходимых функций и зависимостей уменьшает раздутость.

Несмотря на все эти усилия, порой возникали непреодолимые трудности, особенно при работе со сложными типами данных и операциями, характерными для задач программирования более высокого уровня.

Динамические языки в WASM

Трудности с WASM характерны не только для Rust. Другие языки, особенно динамические, такие как Python, сталкиваются с еще большими проблемами. Чтобы понять причины этого, рассмотрим компиляцию динамического языка в WASM.

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

Даже для Go, статически типизированного компилируемого языка, минимальный размер WASM-файла составляет 2 МБ, согласно Go Programming Language Wiki.

Проблемы, общие для всего комьюнити

В своем разочаровании я не был одинок. Его разделяло все сообщество разработчиков. Во многих статьях обсуждались похожие проблемы.

  • Анализ причин неудачи Zaplib: подробное описание того, как стартап Zaplib пришел к решению отказаться от WASM из-за отсутствия повышения производительности и сложности разработки.

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

Переломный момент

Стараясь преодолеть разочарование в WASM, я обнаружил библиотеку h3, разработанную компанией Uber. Это и стало переломным моментом в моем отношении к WASM. Библиотека h3, включающая реализации на нескольких языках (C, Python, Java, JavaScript) и h3-js, использует Emscripten для соединения JavaScript и WASM, скомпилированного из C.

Что такое h3?

Библиотека h3 предназначена для геопространственного индексирования. Она позволяет индексировать координаты в гексагональной сетке. Эта система особенно полезна для приложений с большими наборами геопространственных данных. Одна из часто используемых мной функций — latLngToCell — преобразует координаты широты и долготы в идентификатор ячейки гексагональной сетки.

Сравнение производительности h3 и h3-js

Чтобы оценить производительность h3-js, я сравнил реализацию на C с выполнением JavaScript-версии с помощью WASM. К счастью, в их репозиториях уже есть программы для бенчмаркинга. Вот результаты, полученные на моем локальном M2 MacBookPro:

Изучение возможностей WASM

Воодушевленный неожиданными результатами h3-js, я решил продолжить изучение возможностей WASM. Я начал сравнивать его производительность с JavaScript и FFI с помощью вычислительной задачи — гипотезы Коллатца.

Что такое гипотеза Коллатца?

Гипотеза Коллатца (также известная как “проблема 3n + 1”) — это математическая гипотеза о последовательности, определяемой следующим образом.

  1. Начните с любого положительного целого числа n.
  1. Если n четное, разделите его на 2.
  1. Если n нечетное, умножьте его на 3 и добавьте 1.
  1. Повторяйте этот процесс до тех пор, пока n не станет равным 1.
Гипотеза Коллатца с начальным n=3

Эта гипотеза утверждает, что независимо от начального значения n, последовательность всегда в конечном итоге достигнет 1.

Гипотеза Коллатца в отношении JS, FFI и WASM

Для сравнения производительности я реализовал гипотезу на обычном JavaScript, используя FFI для вызова функции Rust, и непосредственно в WASM. В качестве входных данных передал n = 670617279, что требует 986 шагов для достижения 1.

  • JavaScript:
function collatzSteps(n) {
let counter = 0;
while (n !== 1) {
if (n % 2 === 0) {
n /= 2;
} else {
n = 3 * n + 1;
}
counter++;
}
return counter;
}
  • Rust (FFI) и Rust (WASM):
pub fn collatz_steps(mut n: u64) -> u64 {
    let mut counter = 0;
    while n != 1 {
      if n % 2 == 0 {
        n /= 2;
      } else {
        n = 3 * n + 1;
      }
      counter += 1;
    }
    return counter;
  }

Для получения более подробной информации, можете заглянуть в репозиторий. Вот бенчмарки с M2 MacBookPro:

Эти результаты показали, что WASM может превзойти как нативный JavaScript, так и FFI, особенно в задачах с интенсивными вычислениями.

Дальнейшее исследование производительности WASM

Впечатляющие результаты работы WASM в h3-js и в гипотезе Коллатца ясно показали, что потенциал WASM гораздо больше, чем я предполагал вначале.

Чем WASM отличается от FFI

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

  • линейное и унифицированное управление памятью: WASM использует один непрерывный блок памяти, что упрощает интерфейс с хост-средой, снижая затраты, связанные с управлением памятью в традиционных установках FFI;
  • двоичный формат, оптимизированный для выполнения: двоичный формат WASM разработан для эффективного декодирования и выполнения современными компиляторами JIT (Just-In-Time), что позволяет достичь производительности, близкой к скорости нативного машинного кода, без типичных штрафов, связанных с интерпретацией во время выполнения.

Применение WASM в бэкенде

Результаты использования библиотеки h3-js и эксперименты с гипотезой Коллатца изменили мое представление о ландшафте WASM-приложений.

  • Потенциал бэкенда превосходит возможности фронтенда. Хотя изначально WASM превозносили как потенциал для веб-приложений, его сильные стороны особенно ярко проявляются в бэкенде и других небраузерных средах с общими интенсивными вычислительными процессами, такими как обработка данных, научные вычисления и кодирование/декодирование мультимедиа в реальном времени.
  • Пограничные вычисления. WASM идеально подходит для приложений периферийных вычислений, где выполнение кода ближе к источнику данных может значительно улучшить время отклика и сократить использование полосы пропускания.

Оптимизация генерации ULID с помощью WASM

Одним из практических случаев применения производительности WASM стала генерация ULID (universally unique lexicographically sortable identifiers — универсальные уникальные лексикографически сортируемые идентификаторы). Идентификаторы ULID служат для тех же целей, что и UUID (universally unique identifier — универсальные уникальные идентификаторы), но ULID являются сортируемыми идентификаторами. Они состоят из временной метки и компонента случайности, закодированных для обеспечения уникальности и лексической сортируемости. Это делает их особенно полезными для распределенных систем, где порядок сортировки и уникальность имеют решающее значение.

40-кратное увеличение производительности

Переведя существующую реализацию генерации ULID на JavaScript в Rust, скомпилированный в WASM, я добился значительного увеличения производительности — примерно в 40 раз по сравнению с исходной JavaScript-версией.

Этот первоначальный перевод был простым, но за ним последовал более тонкий подход к дальнейшей оптимизации производительности.

Последующая оптимизация

Изначально производительность была выше примерно в 10 раз. Однако благодаря нескольким оптимизациям в реализации Rust я увеличил этот показатель до 40. Вот ключевые приемы, которые способствовали такому значительному росту производительности, хотя они не являются специфическими для WASM.

1. Использование эффективных структур данных

Оптимизация используемых в реализации структур данных, например векторов с заранее выделенной емкостью вместо динамического изменения их размера, позволила свести к минимуму выделение памяти и не снижать производительность за счет частых операций с памятью.

// До
String::new();
// После
String::with_capacity(len);

2. Избегание ненужных преобразований и выделений памяти

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

// До
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
...
let mut chars = Vec::with_capacity(len);
for index in 0..len {
    chars.push(ENCODING.chars().nth(index).unwrap());
}

// После
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ENCODING_BYTES: &[u8] = ENCODING.as_bytes();
...
let mut chars = Vec::with_capacity(len);
for index in 0..len {
    chars.push(ENCODING_BYTES[index] as char);
}

3. Предварительные вычисления и их кэширование

Предварительное вычисление значений, которые будут использоваться многократно при различных вызовах функций, таких как значения длины кодировки, и их кэширование значительно снижали вычислительную нагрузку. Это было особенно эффективно для таких функций, как decode_time, где операции повторяются и предсказуемы.

// До
const ENCODING_LEN: usize = 32;
const TIME_LEN: usize = 10;
...
for i in 0..TIME_LEN {
time += i as f64 * (ENCODING_LEN as u64).pow(index as u32) as f64;
}

// После
const ENCODING_LEN: usize = 32;
const POWERS: [f64; 10] = [1.0, 32.0, ..., 35184372088832.0];
...
for i in 0..TIME_LEN {
time += i as f64 * POWERS[index];
}

Заключение

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

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Yuji Isobe: I was understanding WASM all wrong!

Предыдущая статьяСериализация с Kotlin Serialization
Следующая статьяЛучшие библиотеки и пакеты Next.js 2024 года для любых потребностей