Привет, друзья!
В 2019 году WebAssembly
(далее — WA
или wasm
) стал четвертым "языком" веба. Первые три — это, разумеется, HTML
, CSS
и JavaScript
. Сегодня wasm
поддерживается 94% браузеров. Он, как утверждается, обеспечивает скорость выполнения кода, близкую к нативной (естественной, т.е. максимально возможной для браузера), позволяя портировать в веб десктопные приложения и видеоигры.
Что не так с JS
?
JS
— это интерпретируемый язык программирования с динамической типизацией. Динамическая типизация означает, что тип переменной проверяется (определяется) во время выполнения кода. И что с того? — спросите вы. Вот как определяется переменная в C++
:
int n = 42
Такое определение сообщает компилятору тип переменной n
и ее локацию в памяти. И все это в одной строке. А в случае с определением аналогичной переменной в JS
(const n = 42
), движку сначала приходится определять, что переменная является числом, затем, что число является целым и т.д. при каждом выполнении программы. На определение и (часто) приведение (преобразование) типов каждой инструкции уходит какое-то время.
Процесс выполнения кода в JS
выглядит примерно так:
Разбор (парсинг) -> Компиляция и оптимизация -> Повторная (дополнительная) оптимизация или деоптимизация -> Выполнение -> Сборка мусора
А в WA
так:
Расшифровка (декодирование) -> Компиляция и оптимизация -> Выполнение
Это делает WA
более производительным, чем JS
. В защиту JS
можно сказать, что он разрабатывался для придания "легкой" интерактивности веб-страницам, а не для создания высокопроизводительных приложений, выполняющих сложные вычисления.
Что такое WA
?
Формальное определение гласит, что WA
— это открытый формат байт-кода, позволяющий переносить код, написанный на таких языках как C
, C++
, C#
, Rust
и Go
в низкоуровневые ассемблерные инструкции, выполняемые браузером. По сути, это виртуальный микропроцессор, преобразующий высокоуровневый язык в машинный код.
На изображении ниже представлен процесс преобразования функции для сложения чисел (add
), написанной на C++
, в бинарный (двоичный) формат:
Обратите внимание: WA
— это не язык программирования. Это технология (инструмент), позволяющая конвертировать код на указанных выше языках в понятный для браузеров машинный код.
Как WA
работает?
WA
— это веб-ассемблер. Но что такое ассемблер?
Если очень простыми словами, то
- Каждый процессор имеет определенную архитектуру, например,
x86
илиARM
. Процессор понимает только машинный код. - Писать машинный код, сами понимаете, сложно и утомительно. Для облегчения этого процесса существуют языки ассемблера.
- Ассемблер конвертирует инструкции на языке ассемблера в машинный код, понятный для процессора.
На изображении ниже представлен процесс выполнения программы на C
на компьютере:
Пример использования WA
Что нужно сделать, чтобы использовать WA
в браузере (или на сервере в Node.js
)? И действительно ли WA-код
является более производительным, чем JS-код
? Давайте это выясним.
Предположим, что у нас имеется такая функция на C++
:
int fib(int n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
int ... int
означает, что функция принимает целое число и возвращает целое число. Как видите, наша функция вычисляет сумму чисел из последовательности Фибоначчи (далее — фибонача :)).
Сначала эту функцию необходимо конвертировать в wasm-модуль
. Для этого существуют разные способы и инструменты. В нашем случае для этого вполне подойдет WasmExplorer
.
Вставляем код в первую колонку, нажимаем Compile
для компиляции кода в Wat
(текстовое представление двоичного формата wasm
) и Download
для преобразования .wat
в .wasm
и скачивания файла (test.wasm
). Переименуем этот файл в fib.wasm
.
Подготовим проект. Нам потребуется сервер. Зачем? Об этом чуть позже.
# создаем директорию и переходим в нее
mkdir wasm-test
cd wasm-test
# инициализируем Node.js-проект
yarn init -yp
# устанавливаем зависимости для продакшна
yarn add express cors
# и для разработки
yarn add -D nodemon
Структура проекта:
- public
- fib.wasm
- index.html
- script.js
- server.mjs
- ...
Обратите внимание на расширение файла server
.
Добавляем в package.json
команду для запуска сервера для разработки:
"scripts": {
"dev": "nodemon server.mjs"
}
Код сервера (server.mjs
):
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import express from 'express'
import cors from 'cors'
const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(cors())
app.use(express.static('public'))
app.get('*', (req, res) => {
res.sendFile(resolve(`${__dirname}/${decodeURIComponent(req.url)}`))
})
app.listen(5000, () => {
console.log('?')
})
Сервер, запущенный по адресу http://localhost:5000
, возвращает файлы из директории public
по запросам клиента, без CORS
.
Зачем нам сервер? Потому что для загрузки (импорта) wasm-модулей
в JS-код
используется либо XHR
, либо fetch
, которые заблокируют получение файла из источника file://
(это связано с безопасностью). Немного забегая вперед, скажу, что для импорта wasm-модулей
существует специальное API
, предоставляющее несколько методов. Эти методы условно можно разделить на старые и новые. Мы рассмотрим и те, и другие. Суть в том, что при импорте модуля с помощью старых методов можно обойтись расширением для VSCode
типа Live Server
. Однако новые методы требуют наличия в ответе заголовка Content-Type: application/wasm
(судя по всему, express
добавляет такой заголовок автоматически на основе названия файла или его содержимого, а расширение нет).
Еще один момент: мы рассмотрим только импорт wasm-модуля
в JS
и использование экспортируемой из экземпляра модуля фибоначи. Однако у нас также имеется возможность передавать функции и переменные в wasm-модуль
из JS
.
Глянем на разметку (index.html
):
<h1>Wasm Test</h1>
<p class="log-c"></p>
<p class="log-js"></p>
<p class="log-comparison"></p>
<script src="script.js" type="module"></script>
У нас имеется 3 параграфа для вывода результатов функций, а также результатов их сравнения. Мы также подключаем основной скрипт клиента в виде модуля.
Перейдем непосредственно к клиентскому скрипту (script.js
):
const logC = document.querySelector('.log-c')
const logJS = document.querySelector('.log-js')
const logComparison = document.querySelector('.log-comparison')
let fibC
Получаем ссылки на DOM-элементы
и создаем глобальную (в пределах модуля) переменную для С++-фибоначи
.
async function loadWasmOld(url) {
const response = await fetch(url)
const buffer = await response.arrayBuffer()
const module = await WebAssembly.compile(buffer)
return new WebAssembly.Instance(module)
}
Это старый (условно) способ загрузки wasm-модулей
:
- получаем ответ (файл) от сервера
- конвертируем ответ в массив двоичных данных
- компилируем массив с помощью
WebAssembly API
- и возвращаем экземпляр модуля
async function initFibC() {
const instance = await loadWasmOld('http://localhost:5000/fib.wasm')
fibC = instance.exports._Z3fibi
}
Функция инициализации переменной fibC
:
- получаем экземпляр
wasm-модуля
- присваиваем экспортируемую функцию переменной
Откуда мы знаем название экспортируемой функции _Z3fibi
? Отсюда:
function fibJS(n) {
if (n < 2) return n
return fibJS(n - 1) + fibJS(n - 2)
}
JS-фибонача
. К слову, на TypeScript
это будет выглядеть так:
function fibJS(n: number): number {
if (n < 2) return n
return fibJS(n - 1) + fibJS(n - 2)
}
Здесь мы явно указываем, что функция принимает и возвращает числа. Но, во-первых, в этом нет необходимости, поскольку TS
в состоянии сам это определить (предположение типов), во-вторых, это не решает проблем JS
, о которых говорилось выше. TS
— это некий компромисс между статическими и динамическими (с точки зрения типов) языками.
Выполним код фибонач:
async function run() {
// инициализируем переменную `fibC`
await initFibC()
// выполняем `fibC`
const resultC = fibC(24)
logC.innerHTML = `Результат выполнения функции "fibC" - <b>${resultC}</b>`
// выполняем `fibJS`
const resultJS = fibJS(24)
logJS.innerHTML = `Результат выполнения функции "fibJS" - <b>${resultJS}</b>`
}
run()
Запускам сервер с помощью команды yarn dev
и переходим по адресу http://localhost:5000
.
Отлично, код работает. Но как определить, какой код выполняется быстрее? Легко.
function howLong(fn, ...args) {
const start = performance.now()
fn(...args)
const timeTaken = ~~(performance.now() - start)
return timeTaken
}
Данная функция возвращает время выполнения функции, переданной в качестве аргумента, в мс (округленных в меньшую сторону: ~~
— это сокращение для Math.floor
).
Перед применением этой функции, перепишем код для загрузки wasm-модуля
. Новый способ выглядит следующим образом:
async function loadWasmNew(url, exportedFn) {
const { module, instance } = await WebAssembly.instantiateStreaming(
fetch(url)
)
return instance.exports[exportedFn]
}
Функция loadWasmNew
принимает адрес wasm-модуля
и название экспортируемой функции. Метод instantiateStreaming
принимает промис, возвращаемый вызовом fetch
, и возвращает объект, содержащий модуль и экземпляр WA
. Модуль можно, например, кешировать и в дальнейшем использовать для создания других экземпляров:
const otherInstance = await WebAssembly.instantiate(module)
async function run() {
const fibC = await loadWasmNew('http://localhost:5000/fib.wasm', '_Z3fibi')
const fibCTime = howLong(fibC, 42)
logC.innerHTML = `На выполнение C++-кода потребовалось <b>${fibCTime}</b> мс`
const fibJSTime = howLong(fibJS, 42)
logJS.innerHTML = `На выполнение JS-кода потребовалось <b>${fibJSTime}</b> мс`
}
run()
Мы видим, что C++-фибонача
почти в 2 раза (sic) производительнее JS-фибоначи
. Получим точные цифры.
async function run() {
const fibC = await loadWasmNew('http://localhost:5000/fib.wasm', '_Z3fibi')
const fibCTime = howLong(fibC, 42)
logC.innerHTML = `На выполнение C++-кода потребовалось <b>${fibCTime}</b> мс`
const fibJSTime = howLong(fibJS, 42)
logJS.innerHTML = `На выполнение JS-кода потребовалось <b>${fibJSTime}</b> мс`
const differenceInMs = fibJSTime - fibCTime
const performancePercent = ~~(100 - (fibCTime / fibJSTime) * 100)
logComparison.innerHTML = `Код на С++ выполнился быстрее кода на JS на <i>${differenceInMs}</i> мс,<br /> что дает прирост в производительности в размере <b>${performancePercent}%</b>`
}
run()
Полагаю, гипотеза о более высокой производительности WA
по сравнению с JS
подтверждена. Означает ли это, что веб-разработчикам нужно срочно изучать один из языков, компилируемых в WA
, с целью написания wasm-модулей
и их использования в скриптах? Не думаю. По крайней мере, сейчас ;)
Во-первых, экосистема JS
содержит огромное количество готовых решений на все случаи жизни. Каждый день появляется что-то новое, в том числе, более производительное. Пройдет немало времени, прежде чем сформируется более-менее серьезная инфраструктура wasm-модулей
для веба. Тот, кто организует реестр таких модулей наподобие npm
(или внутри него), будет большим молодцом :). Учитесь у Boris Yankov
— субреестр можно, например, назвать @wasm
.
Думаете, почему Deno
не "взлетает"? Потому что есть "готовый" Node.js
. Или GraphQL
? Потому что точечную выборку и обновление данных можно делать и через REST
. Про RPC
(gRPC
) ничего не буду говорить, поскольку не знаком с ним от слова "совсем", но var
ы и колбеки в Quick Start
для Node.js
— это несерьезно. Небольшое лирическое отступление. Обратите внимание: это просто мысли вслух, а не приглашение к дискуссии.
Но кто знает, что будет завтра? Ситуация может резко измениться, когда появится возможность импортировать wasm-модули
напрямую подобно JS-модулям
— import { _Z3fibi as fibC } from './fib.wasm'
. Или когда WA
сможет манипулировать DOM
.
Во-вторых, работающий медленно JS-код
почти всегда можно сделать лучше. На примере той же фибоначи:
function fibJS(n) {
let a = 1
let b = 1
for (let i = 3; i <= n; i++) {
let c = a + b
a = b
b = c
}
return b
}
Кода стало больше, но:
Результат вычисляется моментально.
Пожалуй, это все, чем я хотел поделиться с вами в данной заметке.
Основные источники:
- WebAssembly
- Loading WebAssembly modules efficiently
- WebAssembly | An Introduction
- Introduction to WebAssembly (WASM)
Парочка инструментов:
- webm-wasm — инструмент для создания видео в формате
WebM
с помощьюJS
черезWA
- wasm-pdf — инструмент (пример) генерации
PDF-файлов
в браузере с помощьюJS
иWA
Благодарю за внимание и хорошего дня!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩