
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!
