11/14/2022

WebAssembly на клиенте

WebAssembly для всех-всех-всех

В этой статье мы поговорим о WebAssembly – моей любимой технологии, которая позволяет принести на клиент почти все, что в голову взбредет. Пару лет назад мы с моим коллегой написали большой тьюториал по Wasm. Технология за это время осталась такой же прекрасной и в ней появилось много новых штук. Так что сейчас будет еще одна статья про Wasm :)

Как происходит вжух?

Давайте повторим капитанские основы:

Вы пишете программку на вашем любимом языке (может даже на JS!). А потому вжух и она заработала. Как происходит вжух? Нечто превращает текст, написанный человечком (source) в что-то понятное вашему компьютеру (target). Это нечто называют интерпретатором или компилятором. В современном мире достаточно сложно отличить одно от другого. Если хотите разобраться что есть что и как работает, почитайте классную книжку. Чтобы не запутаться, в нашей статье давайте считать что интерпретатор сразу выполняет исходный код, а компилятор — превращает исходный код в другой – более удобный для выполнения и более низкоуровневый. Например, компилятор может превратить ваш JS в машинные инструкции.

Вжух на самом дела вжух-вжух-вжух

Что нужно сделать с вашим исходным кодом, чтобы он превратился в машинный? Сначала его надо распарсить (убрать скучные комменты, понять где функция, где переменная и всякое такое) Потом, желательно хорошенько перетрясти. Например, вот такой код const a = 5 + 3 хочется превратить в положите вон в тот регистр число 8. И потом полученное превратить в набор инструкций, понятный процессору.

Для каждого шага компиляции нужно свое промежуточное представление исходного кода. Например V8 (движок для работы с JS и Wasm в браузере Chromium) превращает ваш JavaScript в байт-код, который выглядит примерно так:

CreateFunctionContext [0], [1]
PushContext r2
CreateClosure [1], [0], #2
Star1
LdaZero
Star0
StaCurrentContextSlot [2]
CallUndefinedReceiver0 r1, [0]
LdaCurrentContextSlot [2]
Return

Компиляция это просто превращение вашего кода из одного промежуточного представления (Intermediate representation или IR) в другое.

Так как для разных компиляторов шаги компиляции похожи, было бы здорово иметь возможность для каждого шага написать свой модуль, договориться о входных и выходных промежуточных представлениях и потом просто собирать новые компиляторы из таких модулей. Обычно так и происходит. Компилятор условно разделяется на две части – фронтенд и бекенд. Фронтенд включает себя шаги более близкие к парсингу, а бекенд – к генерации кода.

Это ментальная модель, на самом деле все сложнее

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

К чему это я? WebAssembly (или Wasm) это просто промежуточное представление. Достаточно компактное чтобы его было легко распарсить, достаточно низкоуровневое чтобы его можно было оптимизировать.

Чуть подробнее

Согласно спецификации, Wasm это набор инструкций для абстрактной машины (Virtual ISA). Эта машина работает на стеке и делает все, что обычно можно делать со стеком: закидывает данные на стек, проделывает с ними всякие арифметические операции и достает данные со стека.

Xорошо и быстро

Почему Wasm легко парсить? Во-первых потому что это бинарный формат. По крайней мере в таком виде Wasm-модуль приезжает в браузер. У WebAssembly есть и текстовое представление. Вы легко сможете сконвертировать бинарник в текст и обратно.

Вот так выглядит текстовый формат WebAssembly
(module
  (func $i (import "imports" "logger") (param i32))
  (memory (import "imports" "importedMemory") 1)
  (func (export "exportedFunc")
    i32.const 42
    call $i)
  (func (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  (data (i32.const 0) "Fifty"))

Во-вторых парсинг проще, потому что промежуточное представление Wasm ближе тому, что понимает ваша машина.

Весь модуль описывает работу со стеком. Вы можете закидывать туда значения и вынимать их. Например, вытащив два значения со стека, вы можете перемножить их и закинуть обратно. Кучи в Wasm нет, зато есть линейная память (все ячейки памяти пронумерованы по порядку). Вы можете использовать эту память чтобы что-то хранить. Эта память ограничена по размеру, но если нужно, размер можно увеличивать. Специальный вид памяти – таблицы вызовов функций. Туда вы можете положить указатель на функцию и потом ее вызвать.

Плохо и медленно Может быть медленно

От модуля который никак не взаимодействует с окружающей средой толку мало. У Wasm модуля есть возможность вызывать функции из окружения, и наоборт функции из Wasm модуля можно вызывать непосредственно из окружения. Но вот вопрос: если такие функции принимают какие-то аргументы или возвращают какие-то значения то где они хранятся? Если мы хотим запихнуть эти аргументы на Wasm, стек как нам это сделать? Система типов WebAssembly должна как-то мапиться на типы окружения. Кроме того система типов языка из которого компилируется WebAssembly модуль должна как-то мапиться на систему типов WebAssembly. И тут мы встреваем. Ибо WebAssembly умеет манипулировать только чиселками, да и то не всеми. Поэтому чтобы наш WebAssembly модуль мог как-то поработать с аргументами окружения нужно сконвертировать эти аргументы в формат понятный WebAssembly. И это далеко не всегда быстро.

Как мапятся чиселки?

WebAssembly может поддерживать вот такие форматы:

i32: 32-bit integer, i64: 64-bit integer, f32: 32-bit float, f64: 64-bit float

Что из этого можно смапить на JS тип Number? Давайте выполним вот такой код в консольке:

console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991

Это число несколько меньше чем тру i64. Если хотите, посчитайте на калькуляторе: (2 ^ 64 - 1) Получается что некоторые форматы чисел Wasm не совместимы с браузерным окружением? Да, некоторое время приходилось читерить и использовать вместо i64 пару (i32, i32), Но потом Wasm сделали совместимым с JavaScript BigInt и больше читерить не нужно.

GC хорошо или плохо?

GC (Garbage Collector) – сборщик мусора. Если вы когда-нибудь дебажили JavaScript-программы, вы могли обратить внимание, что если скушать слишком много памяти, к вам придет GC и будет сурово освобождать место. На панельке Performance при этом появляются события: Minor GC или Major GC. Давайте разберемся как это работает: Если вы будете искать ваши данные вы найдете их в одном из двух мест: На стеке или на куче. В процессе работы ваша программа работает только со стеком. При этом размер данных на стеке должен быть известен. Именно поэтому мы не можем положить на стек динамическую структуру данных. Смотрите:

const a = [1, 2, 3] // 3 (число элементов в массиве) * 8 (размер числа в байтах) = 24 байта

Можно ли положить переменную a на стек? Она весит 24 байта (это, кстати не правда :) Не можем потому что:

a.push('Прощайте 24 байта')

Поэтому мы закидываем a на кучу, а на стек кладем указатель фиксированного размера. Если место на куче закончится, то GC придет и освободит место. О том как это раньше происходило в V8 можно почитать в их замечательном блоге. Нам нужно лишь понимать что в процессе сборки мусора, нужно переносить данные в памяти. И при этом вам нужно "остановить" выполнение программы. Это значит что пока GC работает ваша чудесная JS-анимация замрет и кнопочки перестанут нажиматься. Это называется "stop-the-world" сборка мусора. Никто не любит когда кнопочки не нажимаются, поэтому V8 старается оптимизировать процесс сборки мусора и не останавливать выполнение основного потока слишком надолго: собирать мусор в несколько потоков, делать это небольшим кусочками и пробовать собирать мусор в параллельном потоке. Но даже такая крутая сборка мусора занимает время.

В WebAssembly нет никакой системы сборки мусора, однако велики шансы что она скоро появится Сейчас языки со сборкой мусора (например JS) можно спокойно компилировать в Wasm, но нужно понимать, что сборщик мусора придется либо чем-то заменить, либо скомпилировать и добавить в сборку.

Изоляция из коробки

Низкоуровневый код с бинарным форматом... Куда же его можно приспособить? Разумеется использовать в браузере. Ведь это наша любимая песочница. Он установлен на всех компьютерах, его умеет запускать даже ваш домашний котик. Однако в спеке WebAssembly никак не оговорено что ваши модули обязательно должны выполняться в браузере. Подойдет любой хост, который может скопмилировать и выполнить Wasm. Все что может код внутри модуля – читать выделенную ему область памяти. Ваш код сидит в коробке и никуда оттуда не убежит (если вы не выдадите ему соответствующий транспорт :) Разумеется это не значит что используя Wasm модули вы обеспечите безопасность своей системе. Изоляция != безопасность :)

Что положить в коробку?

Низкоуровневый код с бинарным форматом, высоким уровнем изоляции из коробки и слабой зависимостью от окружения... Вот бы его еще научить читать файлики и ходить в сеть? Но тут нам понадобится спецификация на все необходимые для этого апишки. Спроектировать API, которое будет предоставлять нужные возможности для Wasm не нарушая изоляцию – очень непростая задача, но ее уже решают :) Если мы говорим про небраузерное окружение, то посмотрите на WASI. Это набор стандартизованных апишек, которые дают Wasm множество новых возможностей.

И если все-таки браузер...

В спеке Wasm ничего не сказано о том, как браузер загружает и выполняем Wasm модули. Все это описано в отдельном документе. Для тех кто не пошел читать спеку и для тех кто уже прочитал и вернулся, предлагаю поэкспериментировать.

Ультаблюр?

Говорят WebAssembly работает быстрее чем JavaScript. Давайте проверим?) В одной из свои статей я рассказывала о том как заблюрить белочку Мы делали это при помощи кусочка JavaScript. Все было медленно и грусно. Теперь сделаем то же самое, но при помощи WebAssembly и посмотрим, насколько быстрее у нас получилось

Пишем исходник

Сначала напишем блюр на JavaScript:

const convStep = (arr1: number[], kernel: number[][]): number =>
  kernel.flat().reduce((acc, v, i) => acc + v * arr1[i], 0)

export const convolve = (
  array: Uint8ClampedArray,
  kernel: number[][],
  w: number,
  h: number,
  chInImage = 4
): Uint8ClampedArray => {
  const result = new Uint8ClampedArray(w * h * chInImage).fill(255)
  const kh = kernel.length
  const kw = kernel[0].length

  for (let i = 0; i < w - kw; i += 1) {
    for (let j = 0; j < h - kh; j += 1) {
      for (let c = 0; c < chInImage; c++) {
        const arrToConsolve: number[] = []
        for (let k = 0; k < kw; k++) {
          for (let l = 0; l < kh; l++) {
            arrToConsolve.push(
              array[
                chInImage * w * j +
                  chInImage * i +
                  c +
                  chInImage * k +
                  chInImage * l * kw
              ]
            )
          }
        }

        const convStepResult = convStep(arrToConsolve, kernel)
        result[chInImage * w * j + chInImage * i + c] = convStepResult
      }
    }
  }

  return result
}

const blurWithJs = (
  pixelData: Uint8ClampedArray,
  width: number,
  height: number
) => convolve(pixelData, blurKernel, width, height, 4)

Это достаточно длинный пример. Глубоко вникать в то что там происходит не нужно. Мы просто пишем ну ооочень неоптимальный цикл, который проходится по каждому пикселю нашей картинки и делает с ним операцию convolve Эта операция "сворачивает" блюрящее ядро с небольшой областью нашей картинки.

Теперь то же самое на Rust:


fn conv_step(arr1: &[u8], kernel: &Vec<Vec<f32>>) -> u8 {
    let mut acc = 0.0;
    for (i, v) in kernel.iter().flatten().enumerate() {
        acc += *v * arr1[i] as f32;
    }
    acc as u8
}

pub fn convolve(
    array: &[u8],
    kernel: &Vec<Vec<f32>>,
    w: u32,
    h: u32,
    ch_in_image: u32,
) -> Vec<u8> {
    let mut result = vec![255; (w * h * ch_in_image) as usize];
    let kh = kernel.len() as u32;
    let kw = kernel[0].len() as u32;

    for i in 0..w - kw {
        for j in 0..h - kh {
            for c in 0..ch_in_image {
                let mut arr_to_convolve: Vec<u8> = Vec::with_capacity((kh * kw) as usize);
                for k in 0..kw {
                    for l in 0..kh {
                        arr_to_convolve.push(
                            array[((ch_in_image * w * j
                                + ch_in_image * i
                                + c
                                + ch_in_image * k
                                + ch_in_image * l * kw)
                                as usize)],
                        );
                    }
                }

                let conv_step_result = conv_step(&arr_to_convolve, kernel);
                result[(ch_in_image * w * j + ch_in_image * i + c) as usize] = conv_step_result;
            }
        }
    }

    result
}

pub fn blur_image(array: &[u8], image_width: u32, image_height: u32) -> Vec<u8> {
    let blur_kernel: Vec<Vec<f32>> = vec![
        vec![1.0 / 16.0, 1.0 / 8.0, 1.0 / 16.0],
        vec![1.0 / 8.0, 1.0 / 4.0, 1.0 / 8.0],
        vec![1.0 / 16.0, 1.0 / 8.0, 1.0 / 16.0],
    ];

    convolve(array, &blur_kernel, image_width, image_height, 4)
}

Кстати, я читерила :)

Код на Rust из JS за меня сгенерировал Copilot. Я его даже править не стала :)

Если вы внимательно присмотритесь, то, возможно, сможете найти парочку отличий. Но в целом код 1-в-1 повторят JavaScript-реализацию. Теперь соберем наш Rust-код в Wasm модуль. Я делаю это по старинке, используя wasm-pack

Подробности

Вам нужно создать проект при помощи команды wasm-pack new [имя вашего проекта] Написать нужный код и потом собрать все вот такой командой

wasm-pack build --target=web

Дальше просто скопипастите содержимое папки pkg и усе заработает

Вот результат :)

Обратите внимание на циферки. При помощи Wasm наша белочка блюрится примерно в 10 раза быстрее. Если вы загляните во вкладку Network то обнаружите, что wasm-модуль, отвечающий за блюр, весит всего 16Кб. Это достаточно много, но надо понимать что внутрь нашего модуля пришлось зашить много всего. Функции для работы с памятью, например.

И еще чуток килобайт

Еще несколько килобайт занимает JS обвязка, которую нам генерирует wasm-pack. Она нужна чтобы корректно загрузить и выполнить наш модуль.

Подключаем Wasm в браузер

Wasm-pack создаст для вас всю необходимую обвязку чтобы спокойно вызывать wasm-модули в нужном окружении. Но давайте потренируемся делать все сами. Нам сгенерировали файл с расширением .wasm. Давайте его загрузим:

const loadModule = async () => {
  const wasmModule = await WebAssembly.instantiateStreaming(
    fetch('/wasm/squirrel_processor_bg.wasm'),
    {
      imports: {},
    }
  )

  return wasmModule.instance.exports
}

const {
  __wbindgen_add_to_stack_pointer: movePointer,
  blur_image,
  __wbindgen_malloc: malloc,
  memory,
} = await loadModule()

Функция instantiateStreaming позволяет нам загружать и одновременно выполнять наш модуль. Как только все загрузится и выполнится у нас появляется функция blur_image, которой мы и будем блюрить белочку. Еще мы получим доступ к памяти нашего модуля memory и пару странных функций с нижними подчеркиваиями.

Отправляем белочек в модули

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

Для этого нам пригодятся две замечательные браузерные апишки:

Первая – canvas. Тут все просто: берем картинку, грузим, рисуем ее на canvas, превращаем canvas в Uint8ClampedArray и закидываем получившееся в наш модуль. Но так как мы любим экспериментировать и пробовать новые технологии, мы добавим еще и загрузку файлов. Загрузим файлик, используя File System Access API

const translateSquirrel = async () => {
  const [openFileHandle] = await window.showOpenFilePicker({
          types: [
            {
              accept: {
                'image/*': ['.png', '.jpg'],
              },
            },
          ],
        })
  const file = await openFileHandle.getFile()
  const bitmap = await createImageBitmap(file)
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
  const ctx = canvas.getContext('2d')
  ctx!.drawImage(bitmap, 0, 0)
  const imageData = new Uint8Array(
    ctx!.getImageData(0, 0, bitmap.width, bitmap.height).data
  )
  return imageData
}

Воу-воу, полегче. Что тут происходит?

  • Запрашиваем у пользователя файл (showOpenFilePicker). Получаем в ответ специальный объект.
  • Из этого объекта получаем сам файл (getFile)
  • Создаем из файла картинку (createImageBitmap)
  • Рисуем ее на OffscreenCanvas. OffscreenCanvas – это магический canvas который можно закидывать на WebWorker.
  • Получаем массив нужного нам формата

Возвращаем белочек из модулей

Дальше нам понадобятся страшные функции с подчеркиваниями, которые мы притащили из Wasm модуля. Обе эти функции нужны для работы с памятью. Одна работает с указателем на стек, а вторая выделяет память в куче. Мы не можем просто закинуть наш массив в функцию, Wasm не умеет в массивы! Поэтому нам надо: выделить память в куче, скопировать туда наш массив, передать указатель на эту память в функцию. Если вы думаете что блюрящая функция что-то нам вернет, то вы ошибаетесь :) Она запишет в память получившийся результат. И наша задача его оттуда вытащить.

Я покажу вам как :) (это примерно то же что делает wasm-bindgen)

const retptr = movePointer(-16) // подвинули указатель на стеке на 16 байт
const length = imageData.length

const imagePtr = malloc(length) // выделили память в куче
const buff = new Uint8Array(memory.buffer) // получили доступ к памяти
buff.set(imageData, imagePtr) // записали белку

blur_image(retptr, imagePtr, length, bitmap.width, bitmap.height) // заблюрили

const int32View = new Int32Array(memory.buffer) // получили доступ к памяти (ага, снова)
const readStart = int32View[retptr / 4] // получили указатель на начало результата
const readLength = int32View[retptr / 4 + 1] // получили длину результата

const result = new Uint8Array(memory.buffer)
  .subarray(readStart, readStart + readLength)
  .slice() // вжух!
А почему мы все время создаем новый массив?

Внимательный читатель обратил внимание, что мы несколько раз создаем новый массив, чтобы читать память. Дело в том, что когда мы работаем с памятью внутри Wasm модуля, свойство этой памяти buffer может измениться (например, потому что он вырос) и обращаться к нему по старой ссылке уже не получится.

Осталось только нарисовать белку. Это мы уже умеем :)

const blurredImage = ctx!.createImageData(bitmap.width, bitmap.height)
blurredImage!.data.set(result)
canvas.getContext('2d')?.putImageData(blurredImage, 0, 0)

Вот что у нас получилось:

Хорошо бы освободить память

Вообще, нам нужно обязательно освободить память руками. Для этого wasm-модуль экспортирует еще одну страшную функцию с подчеркиванием __wbindgen_free.

Память это сложно.

Не беспокойтесь. Вам точно не придется писать этот код руками. Ваши инструменты сделают все за вас. Я хотела обратить ваше внимание на то как данные проходят через границу с Wasm модулем. Мы рассмотрели очень простой пример, где получилось легко преобразовать все в числа. Но что произойдет если нам, например, понадобится закинуть внутрь нашего модуля граф... или объект? Не совсем понятно как это мапить на линейную память.

До недавнего времени для этого существовал workaround - просто сохранить вашу сущность в каком-нибудь словаре и передавать Wasm-модулю ключ из словаря вместо этой сущности. Но недавно в Chromium завезли WebAssembly Reference Types, и теперь не придется писать лишний код и хранить все в словарях. Вы сможете передавать прям настоящую ссылку на свою сущность в Wasm модуль.

Подздравляю! Теперь вы можете блюрить белочек в.десять.раз.быстрее :)

Это очень-очень полезно, но мы так и не поняли почему все быстрее.

Искусство написания бенчмарков

Разумеется наш искусственный пример ни в коем случае не является доказательством того что WebAssembly всегда работает быстрее чем JavaScript. Чтобы это проверить нужно писать серьезные бенчмарки, считать всякие персентили и убедиться что в процессе выполнения ваш мак не облучили из космоса и никто не выдернул из него кабель питания.

Как я уже говорила, WebAssembly это достаточно низкоуровневое представление. Его легко и быстро компилировать. Более того, это можно сделать за один проход. Кроме этого некоторые оптимизации уже сделали в процессе компиляции исходника в WebAssembly.

Модель WebAssembly это работа с числами на стеке. Такая модель ведет себя достаточно предсказуемо в отличие от JavaScript. Чтобы эффективно оптимизировать JavaScript, движку нужно делать некоторые предположения о вашем коде, например о том с какими аргументами будет вызываться функция. Когда движок ошибается в своих предположениях, ему приходится выбрасывать весь оптимизированный код и использовать неоптимизированную версию. При выполнении Wasm никаких деоптимизаций не происходит и ничего выкидывать не нужно.

Вот поэтому все быстро :)

Беличий ускоритель

Как нам ускорить наш блюр? Конечно же все распараллелить. Для этого в Wasm уже есть несколько инструментов:

  • simd (SIMD – Single Instruction Multiple Data). Процессоры уже давно научились хитрому трюку: если нужно несколько раз проделать одну и ту же операцию (например несколько раз сложить числа), то эту операцию можно векторизовать. Для этого берем пачку чисел для сложения, записываем их в специальный регистр, фетчим только одну специальную команду и применяем сразу ко всей пачке. Wasm так тоже умеет :)

    Правда умеет не все

    Wasm поддерживает только очень ограниченный список SIMD операций – Fixed-Width 128-bit SIMD.

  • Многопоточность. Wasm умеет выполняться в несколько потоков

    Правда с разделяемой памятью есть проблемки

    Если говорить о браузере, WebAssembly использует в качестве потоков WebWorkers. Пошарить память между воркерами не так-то просто. Для этого есть крутой инструмент SharedArrayBuffer. Вот только он уязвим к особой атаке – Spectre. Поэтому в браузерах его сначала включили, потом выключили, а потом снова вроде как включили :) Есть отличная статья, где об этом очень подробно рассказано

Как и где можно применить WebAssembly?

Итак у нас есть:

  • Бинарный, достаточно низкоуровневый формат, который парсится, компилируется и выполняется быстрее чем этот наш JavaScript. В этот формат можно компилировать код на множестве языков

    Но пока в топе ...

    ...Языки без сборщика мусора. Rust и C/C++. AssemblyScript тоже замечательно компилируется в Wasm.

  • Изоляция из коробки

  • Отсутствие зависимости от окружения

Где же можно применить все эти классные штуки?

  • Быстрый cold start и изоляция делают Wasm очень привлекательной технологией для serverless. Fastly, Cloudflare и Vercel поддерживают выполнения Wasm в своих окружениях.

  • Изоляция еще очень полезна для написания всяческих плагинов.

  • Блокчейны тоже дружат с Wasm :)

  • SIMD и быстрое выполнение нужно для задач машинного обучения и всяческих графических эффектов. У JavaScript версии Tensorflow есть WebAssembly-backend. Этот бэкенд очень удобно использовать для небольших моделей.

  • Если вы хотите портировать в браузер какое-нибудь приложение вам нужно просто скомпилировать его в Wasm :)

Убедила?

WebAssembly мощная технология. Возможно у вас уже возникали задачи, где можно было бы применить WebAssembly, возможно они возникнуть у вас позже. Но, надеюсь, прочитав эту статью вы добавите в свой инструментарий новую, очень мощную штуку, которая позволит ускорить ваши приложения, сделать их более надежными и безопасными и принести в них функции, написанные на других языках.