О JavaScript и WebAssembly
- вторник, 5 декабря 2023 г. в 00:00:17
 

Hello world!
На днях я баловался с WebAssembly и получил довольно неожиданные результаты, которыми и хочу с вами поделиться в этой небольшой заметке.
Хорошо, если вы знаете JS/Node.js и хотя бы слышали о WASM и Rust.
Я использовал следующие инструменты:
Начнем с создания Node.js-проекта:
# основная директория
mkdir js-wasm
cd js-wasm
# директория с JS-кодом
mkdir js-code
cd js-code
# инициализируем Node.js-проект
# это не обязательно, но может пригодиться
npm init -ypСоздаем файл index.js. Напишем какую-нибудь медленную функцию, например:
function longRunningFunction(n) {
  let result = 0
  for (let i = 0; i < n; i++) {
    result += i
  }
  return result
}Измерим время ее выполнения с аргументом 100_000_000:
function main() {
  const startJS = performance.now()
  const resultJS = longRunningFunction(100_000_000)
  console.log('[JS] Результат:', resultJS)
  const timeJS = performance.now() - startJS
  console.log('[JS] Время:', timeJS)
}
main()Находясь в директории js-code, выполняем команду node index.js:

Для выполнения кода в браузере я использовал расширение для VSCode Live Server:

Средний результат — 50 мс.
Что если мы хотим, чтобы наша функция выполнялась быстрее? Что если в последнее время мы много слышали о производительности WebAssembly? Что если мы перепишем функцию longRunningFunction() на WASM?
Отличная идея, но как это сделать? Я знаю 2 способа:
Еще есть такая штука, как AssemblyScript, вроде бы позволяющая писать код на TypeScript-подобном языке и компилировать его в WASM, но я ее не тестил.
Начнем с WAT.
WAT — это текстовый формат WebAssembly (текстовое представление двоичных данных), который позволяет читать и писать код на WebAssembly. Парочка ссылок для желающих погрузиться в тему:
Для работы с WAT могут пригодиться эти расширения для VSCode:
Создаем в директории js-code файл fn.wat следующего содержания:
(module
  (func (export "long_running_function") (param $n i64) (result i64)
    (local $i i64)
    (local $result i64)
    (local.set $i (i64.const 0))
    (local.set $result (i64.const 0))
    (block $my_block
      (loop $my_loop
        (i64.ge_u (local.get $i) (local.get $n))
        (br_if $my_block)
        (local.set $result (i64.add (local.get $result) (local.get $i)))
        (local.set $i (i64.add (local.get $i) (i64.const 1)))
        (br $my_loop)
      )
    )
    (local.get $result)
  )
)Это реализация нашей функции на WAT (довольно наивная, как мы вскоре увидим).
Для преобразования WAT в WASM используется инструмент под названием wat2wasm из WABT: The WebAssembly Binary Toolkit.
Находясь в директории js-code, выполняем команду wat2wasm fn.wat. Это приводит к генерации файла fn.wasm. Возвращаемся в index.js и дописываем функцию main():
function main() {
  // ...
  let startWasm = performance.now()
  // https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiateStreaming_static
  WebAssembly.instantiateStreaming(
    fetch('http://localhost:3000/fn.wasm'),
  ).then((obj) => {
    // `export "long_running_function"` в WAT
    const { long_running_function } = obj.instance.exports
    // обратите внимание, что аргумент должен передаваться в виде BigInt
    const resultWasm = long_running_function(BigInt(100_000_000))
    console.log('[WASM] Результат:', Number(resultWasm))
    const timeWasm = performance.now() - startWasm
    console.log('[WASM] Время:', timeWasm)
    // предполагаем, что WASM будет быстрее
    console.log('Разница:', (timeJS / timeWasm).toFixed(2))
  })
}
main()Обратите внимание, что файлы WASM должны обслуживаться сервером (из локальной файловой системы они недоступны). Для этой цели я использовал пакет NPM serve:
npm i -g serve
# в директории `js-code`
serve -C
# -C или --cors отключает CORS
# Live Server запускается на порту 5000, а serve - на порту 3000: разные источники (origins)Выполняем команду node index.js:

Chrome:

В Chrome WASM выполняется почти в 2 раза медленнее JS, а в Node.js — в 3 раза медленнее.
Напишем функцию для вычисления среднего времени выполнения другой функции:
function calculateAverageTime(fn, n) {
  let totalTime = 0
  for (let i = 0; i < n; i++) {
    const start = performance.now()
    fn()
    const time = performance.now() - start
    totalTime += time
  }
  const averageTime = totalTime / n
  return averageTime
}И допишем main():
function main() {
  // ...
  WebAssembly.instantiateStreaming(fetch('http://localhost:3000/fn.wasm')).then(
    (obj) => {
      // ...
      const averageTimeJS = calculateAverageTime(function () {
        longRunningFunction(100_000_000)
      }, 100)
      const averageTimeWasm = calculateAverageTime(function () {
        long_running_function(BigInt(100_000_000))
      }, 100)
      console.log('[JS] Среднее время:', averageTimeJS)
      console.log('[WASM] Среднее время:', averageTimeWasm)
      console.log('Разница:', (averageTimeJS / averageTimeWasm).toFixed(2))
    },
  )
}
main()Node.js:

Chrome:

В Chrome WASM выполняется на треть медленнее JS, а в Node.js — в 2 раза быстрее. Становится интереснее.
Двигаемся дальше и напишем нашу функцию на Rust.
Поднимаемся в директорию js-wasm и создаем новый проект Rust с помощью cargo:
# rust-code - название проекта/директории
# --lib - флаг создания библиотечного крейта/проекта
cargo new rust-code --libДля работы с Rust может пригодиться следующее расширение для VSCode:
Для компиляции Rust в WASM используется 2 инструмента:
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh)Редактируем файл Cargo.toml:
[dependencies]
wasm-bindgen = "0.2"
[lib]
crate-type = ["cdylib", "rlib"]Пишем функцию в файле src/lib.rs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn long_running_function(n: u64) -> u64 {
    let mut result = 0;
    for i in 0..n {
        result += i;
    }
    result
}Протестируем ее. Создаем файл src/main.rs следующего содержания:
use std::time::Instant;
use rust_code::long_running_function;
fn main() {
    let start = Instant::now();
    let result = long_running_function(100_000_000);
    println!("[RUST] Результат: {result}");
    let time = start.elapsed();
    println!("[RUST] Время: {:?}", time);
}Находясь в директории rust-code, выполняем команду cargo run:

Результат — 300 мс. Не спешим делать выводы и собираем производственную сборку с помощью команды cargo build --release. Получаем исполняемый файл target/release/rust-code.exe (.exe на Windows, в других ОС у файла не будет никакого расширения). Запускаем этот файл:

Результат — 0,3 мс. Не спешим радоваться и компилируем Rust в WASM с помощью команды wasm-pack build --target web. Это приводит к генерации директории pkg с кучей разных файлов (готовый пакет NPM), из которых нас интересует только rust_code_bg.wasm. Переносим этот файл в директорию js-code и меняем путь к файлу в fetch() в функции main():
WebAssembly.instantiateStreaming(
    fetch('http://localhost:3000/rust_code_bg.wasm'),
  )Node.js:

Chrome:

В Node.js WASM выполняется в 2 раза медленнее JS при однократном вызове функции, но среднее время выполнения в 44 000 раз быстрее, в Chrome при однократном вызове функции WASM быстрее JS почти в 3 раза, а среднее время выполнения в 43 000 раз быстрее. Иногда в Chrome можно получить такой забавный результат:

WASM бесконечно быстрее JS! Шутка, просто нельзя делить на 0, но, согласитесь, 0 о многом говорит.
Очевидно, что результат вызова нашей "горячей" функции WASM кэшируется, а результат функции JS каждый раз вычисляется заново (или я что-то делаю не так).
Означает ли это, что мы должны в срочном порядке переписывать весь JS на WASM? Конечно, нет. Во-первых, не получится, потому что у WASM пока много ограничений (нет доступа к DOM, доступ к файловой системе экспериментальный (см. WASI) etc.). Во-вторых, как мы видели, WASM вполне может проигрывать JS в производительности в отдельный случаях (и довольно сильно). Так что все зависит от задач и потребностей конкретного приложения, что также подтверждают многочисленные сравнения производительности JS и WASM другими исследователями.
Чуть не забыл — для того, чтобы увидеть, какой такой волшебный код сгенерировал wasm-pack необходимо выполнить команду wasm2wat rust_code_bg.wasm > rust_code_bg.wat (wasm2wat является частью комплекта wabt).
Код "проекта" можно найти здесь.
Happy coding!