Привет, друзья!


В 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
}

Кода стало больше, но:




Результат вычисляется моментально.


Пожалуй, это все, чем я хотел поделиться с вами в данной заметке.


Основные источники:



Парочка инструментов:


  • webm-wasm — инструмент для создания видео в формате WebM с помощью JS через WA
  • wasm-pdf — инструмент (пример) генерации PDF-файлов в браузере с помощью JS и WA

Благодарю за внимание и хорошего дня!




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


  1. MAXH0
    18.11.2021 09:29

    Большое спасибо за такое пособие по WebAssembly для самых маленьких. Это именно то, что нужно мне для моих учеников...


  1. Bigata
    18.11.2021 09:33

    Автору респект. Но когда же наконец можно будет wa юзать без костыльного привязывания сервера?


    1. MAXH0
      18.11.2021 09:41

      Я думаю это не имеет смысла спрашивать у автора, и даже на Хабре. Разве что в блогах команд Браузеров.


    1. mayorovp
      18.11.2021 10:46
      +3

      Но ведь в любом проекте на javascript так или иначе сервер будет использоваться...


  1. gameplayer55055
    18.11.2021 10:41

    Есть где-то хороший ман по ассемблеру именно?

    Гуглил, выдаёт только компиляторы с плюсов на wasm. Хочу пощупать его как нормальный ассемблер но не могу найти


    1. LEXA_JA
      18.11.2021 11:20

      Наверное нужно смотреть в сторону WASM Text Format. Есть Спека. Не уверен, есть ли человекочитаемый гайд по нему, но вроде можно бинарный WASM перевести в текстовый формат.


  1. trokhymchuk
    18.11.2021 11:06

    Интересно, спасибо.

    Полагаю, гипотеза о более высокой производительности WA по сравнению с JS подтверждена.

    Это не гипотеза, а логичный факт. Кому интересны ещё бенчмарки: https://takahirox.github.io/WebAssembly-benchmark/.


    1. faiwer
      18.11.2021 13:23
      +4

      Честно говоря как-то грустно смотреть на эти бенчмарки. Ожидаешь прирост в десятки разов, а оно пишет 1.5, 1.3, 2.5. Было бы интересно ещё сравнить нативное исполнение (хотя очевидно что это невозможно сделать в браузере). Думаю тогда картинка была бы уже впечатляющей. На наших тестах нативное приложение было в 10 раз быстрее WASM.


      1. trokhymchuk
        18.11.2021 23:09

        А в некоторых бенчмарках васм ещё и сливает.

        Ожидаешь прирост в десятки разов, а оно пишет 1.5, 1.3, 2.5.

        Это ещё поправят, думаю, компилятор ещё неплохо так оптимизируют. Но так-то V8 --- монстр по производительности.

        На наших тестах нативное приложение было в 10 раз быстрее WASM.

        Внушающе, ничего не скажешь. Ни о какой native-like производительности и речь не идёт.


        1. konsoletyper
          21.11.2021 11:56
          +3

          Это ещё поправят, думаю, компилятор ещё неплохо так оптимизируют

          Увы, но нет. Пока с самой спекой wasm не сделают чего-то, никакой оптимизатор погоды не сделает. Есть множество причин, почему код wasm работает медленно, например:

          1. При любом доступе к куче делается bound check. Это в какой-то степени компенсируется оптимизатором, но очень часто у оптимизатора просто не хватает информации, чтобы понять, что проверку можно убрать

          2. В Wasm нельзя получить указатели на локальные переменные. Это значит, что если вы пишете код: "int a = 0; foo(&a);", то, кроме небольшого числа тривиальных кейсов, которые может распознать оптимизатор, копилятору придётся запрятать a в shadow stack, что и само по себе не быстро, а учитывая, что shadow stack находится в куче, доступ к которой проверяется, выходит совсем уж печально

          3. В Wasm нельзя походить по стеку. Это серьёзно ограничивает возможности по эффективной реализации GC и исключений. Что GC, что исключения, уже давно обещают, но воз и ныне там. И судя по черновикам, тот же GC (точнее, модель "кортежей") получится куцым и малопригодным для реальных нужд. С исключениями ситуация вроде получше (мне и черновик больше нравится, и шансов у него добраться до финальной спеки выше), так что возможно в каком-то обозримом будущем для кода на C++, активно использующем исключения, мы действительно увидим существенный прирост производительности.

          4. В Wasm нельзя делать трюки с memory protection, которые так же можно использовать, чтобы генерировать segfault при разыменовании нулевого указателя или переполнении стека.

          Ну и т.д.


  1. faiwer
    18.11.2021 13:18

    Интересно было бы сравнить ещё asm.js версию с wasm. У нас опыт с wasm получился довольно грустным:


    • нативная версия написанная на C работает в 10 раз быстрее, чем WASM (emscripten)
    • asm.js версия работает столь же быстро, как и wasm. Но не в Firefox
    • в Firefox начиная с какой-то версии asm.js стал работать в 10 раз медленнее, и мы заменили то решение на wasm. Wasm в нём работает медленнее, чем asm.js решение в Chrome
    • interoperability между wasm и JS оставляло желать лучшего. Даже просто прокинуть строку на сторону C-WASM было болью. А стандартная JS обвязка emscripten это какая-то лютая жесть

    Для себя запомнил, что при прочих равных, если алгоритм простой, лучше попробовать задействовать asm.js.


    1. aleks_raiden
      19.11.2021 11:41

      Тут есть стратегический момент - производительность С/С++ кода можно принять как константу (пусть и большого значения) и улучшаться она будет крайне медленно (улучшения компилятора, что небыстро или новые процессоры, что еще медленнее). А задел улучшения производительности WA - прям часто от версия к версии (и браузера и V8 и других компонент)


  1. konsoletyper
    21.11.2021 11:42
    +2

    Ваша методика бенчмаркинга не выдерживает никакой критики. Движкам с JIT необходимо прогреться, собрав статистику по выполнению кода и затем оптимизировав его. Соответственно, перед замерами тестируемый код надо погонять в цикле. Далее, сам замеряемый код так же необходимо прогнать несколько раз. То же самое желательно делать и с Wasm, т.к. никто не гарантирует, что конкретная среда не делает JIT. Далее, сам код - нарочито неоптимальный. По сути вы тут тестируете как быстро среда делает вызовы функций. Было бы интереснее что-то повнушительнее: рейтрейсинг, deflate, компиляция C, физический движок. Я понимаю, что для вводной статьи это слишком страшные вещи, так что хотя бы можно было потестировать на трёх версиях fib: вашей (рекурсивная, экспоненциальная сложность), рекурсивная с мемоизацией, итеративная.


  1. baldrs
    22.11.2021 12:08
    +1

    Если написать extern "C" перед объявлением функции то компилятор не будет mangle-ить имя функции


  1. tabtre
    25.11.2021 21:51

    Было бы интересно посмотреть как это отлаживать