О 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 способа:
- Написать функцию на WAT.
- Написать функцию на одном из языков, компилируемых в 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!