О JavaScript и WebAssembly

от автора

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:

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

Средний результат — 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:

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 инструмента:

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/778240/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *