О JavaScript и WebAssembly

mapolvqq4uunxfqoaviv3g9km9y.jpeg


Hello world!

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

Хорошо, если вы знаете JS/Node.js и хотя бы слышали о WASM и Rust.

Я использовал следующие инструменты:


  • Chrome 119.0.6045.199
  • Node.js 20.9.0
  • Rust 1.74.0
  • VSCode 1.80.2

Начнем с создания 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:


6nebu0ynthlp0f4qiaiodtslnki.png

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


sspra14vr0fz_4jzqddpmiivkns.png

Средний результат — 50 мс.

Что если мы хотим, чтобы наша функция выполнялась быстрее? Что если в последнее время мы много слышали о производительности WebAssembly? Что если мы перепишем функцию longRunningFunction() на WASM?

Отличная идея, но как это сделать? Я знаю 2 способа:


  1. Написать функцию на WAT.
  2. Написать функцию на одном из языков, компилируемых в WASM.

Еще есть такая штука, как 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:


aq36dfzxhwl946-tm4ohlfhi1hk.png

Chrome:


owqh-onr5drfcp1uburzldun2zo.png

В 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:


kp4bqeg_oazb1bqejuqlnrlngp0.png

Chrome:


r9bnh9pgz3j0m2ltsyotjaz5kkk.png

В Chrome WASM выполняется на треть медленнее JS, а в Node.js — в 2 раза быстрее. Становится интереснее.

Двигаемся дальше и напишем нашу функцию на Rust.

Поднимаемся в директорию js-wasm и создаем новый проект Rust с помощью cargo:

# rust-code - название проекта/директории
# --lib - флаг создания библиотечного крейта/проекта
cargo new rust-code --lib

Для работы с Rust может пригодиться следующее расширение для VSCode:

Для компиляции Rust в WASM используется 2 инструмента:


  • CLI wasm-pack (команда для установки — curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh)
  • крейт wasm-bindgen

Редактируем файл 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:


wms2rtuibhfvqxp1lcouv_lft1c.png

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


8oytb-8fi1lenrrlfv0b_ute-r4.png

Результат — 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:


jkjxmlkgfqbgvw29rs79gezxslo.png

Chrome:


pcqrgst8ne4ccz66h3v9wervblm.png

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


oolwe7litfzr3oqjyldn-kx3wqi.png

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!

© Habrahabr.ru