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!

Комментарии (4)


  1. iliazeus
    04.12.2023 10:30
    +3

    Очевидно, что результат вызова нашей "горячей" функции WASM кэшируется

    Не думаю, что кешируется что-то, кроме скомпилированного кода. Похоже, оптимизатор LLVM просто догадался, что эту функцию можно переписать без цикла.


  1. konsoletyper
    04.12.2023 10:30

    Ну а бывает, что WebAssembly медленнее. Я тут недавно баловался с бенчмаркингом своего JVM -> JS/WebAssembly транслятора и написал для него на Kotlin софтверный 3D. И вот почему-то WebAssembly вариант работает медленнее.


  1. saboteur_kiev
    04.12.2023 10:30
    +7

    Эксприменты интересные, но профайлинг ужасный.

    Во-первых что такое 50 мс?
    Даже с точки зрения таймингов, в Windows timeslice выделяемый на один процесс в среднем это 20 мс. То есть 50 мс это грубо говоря два timeslice, плюс время на переключения и обработку. Хорошо, если эти timeslice между всеми вашими замерами попадали на одно и тоже ядро, которое в этот момент находилось в одном и том-же состоянии (динамическая же частота). В Линукс вроде timeslice 10-15 мс минимальный. И так как весь процесс выполняется быстро, процесс скедулеру нет смысла выдавать более длинный timeslice чтобы обработать все одним махом.

    При таких коротких нагрузках, основное время будет уходить не на алгоритм, и возможно даже не на выделение памяти под данные, а на переключение между процессами мультизадачной ОС.
    Я бы предпочел, чтобы функция выполнялась хотя бы минуту, и уже потом перетестить во всех вариантах, чтобы уменьшить эффект "грязное время", которое идет не на само выполнение.

    Или вообще хорошо закопаться в то, на что именно уходит время - парсинг, рендеринг, запуск и передача кода в wasm сама по себе может занять время... тут я не специалист.
    Но то, что ваши ваши расчеты принципиально не могут показать преимущества wasm над js из-за побочных эффектов - IMHO факт.


  1. Vest
    04.12.2023 10:30
    +3

    В старые-дедовские времена, когда вышел ИЕ11, то там кое-какие бенчмарки вырывались в топ и все удивлялись как же так. На самом деле компилятор просто выкусывал ненужные куски или просто вычислял значение заранее.

    Так вот, ваш примитивный тест JS ничего не показывает, так как вы не привели скомпилированный код. Я, например, не могу по «мс» сказать ВО ЧТО скомпилировался ваш код. Может быть там за вас сумму посчитали. Может быть цикл на четвёрки разбили.

    Источник: https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775