WebAssembly (wasm) — это переносимый бинарный формат инструкций. Один и тот же код wasm-код может выполняться в любой среде. Для того чтобы поддержать данное утверждение, каждый язык, платформа и система должны быть в состоянии выполнять такой код, делая это как можно быстрее и безопаснее.
Wasmer — это среда для выполнения wasm-кода, написанная на Rust. Совершенно очевидно то, что wasmer можно использовать в любом Rust-приложении. Автор материала, перевод которого мы сегодня публикуем, говорит, что он и другие участники проекта Wasmer успешно внедрили эту среду выполнения wasm-кода в другие языки:
- В C и C++ это реализовано посредством привязок.
- В PHP — это расширение php-ext-wasm.
- В Python — это пакет wasmer, опубликованный в PyPi, работа над которым идёт в репозитории python-ext-wasm.
- В Ruby — это гем wasmer, который можно найти на RubyGems. Его код находится в репозитории ruby-ext-wasm.
Здесь речь пойдёт о новом проекте — go-ext-wasm, который представляет собой библиотеку для Go, предназначенную для выполнения бинарного wasm-кода. Как оказалось, проект go-ext-wasm гораздо быстрее, чем другие подобные решения. Но не будем забегать вперёд. Начнём с рассказа о том, как с ним работать.
Вызов wasm-функций из Go
Для начала установим wasmer в окружении Go (с поддержкой cgo).
export CGO_ENABLED=1; export CC=gcc; go install github.com/wasmerio/go-ext-wasm/wasmer
Проект go-ext-wasm представляет собой обычную библиотеку Go. При работе с этой библиотекой используется конструкция import "github.com/wasmerio/go-ext-wasm/wasmer"
.
Теперь приступим к практике. Напишем простую программу, которая компилируется в wasm. Воспользуемся для этого, например, Rust:
#[no_mangle]
pub extern fn sum(x: i32, y: i32) -> i32 {
x + y
}
Файл с программой назовём simple.rs
, в результате компиляции этой программы получится файл simple.wasm.
Следующая программа, написанная на Go, выполняет функцию sum
из wasm-файла, передавая ей в виде аргументов числа 5 и 37:
package main
import (
"fmt"
wasm "github.com/wasmerio/go-ext-wasm/wasmer"
)
func main() {
// Чтение модуля WebAssembly.
bytes, _ := wasm.ReadBytes("simple.wasm")
// Создание экземпляра модуля WebAssembly.
instance, _ := wasm.NewInstance(bytes)
defer instance.Close()
// Получение экспортированной функции `sum` из экземпляра WebAssembly.
sum := instance.Exports["sum"]
// Вызов экспортированной функции с использовании стандартных значений Go.
// Преобразование типов данных, передаваемых функции и получаемых из неё, выполняется автоматически.
result, _ := sum(5, 37)
fmt.Println(result) // 42!
}
Здесь программа, написанная на Go, вызывает функцию из wasm-файла, который был получен в результате компиляции кода, написанного на Rust.
Итак, эксперимент удался, мы успешно выполнили WebAssembly-код в Go. Надо отметить, что преобразование типов данных автоматизировано. Те значения Go, которые передаются в wasm-код, приводятся к типам WebAssembly. То, что возвращает wasm-функция, приводится к типам Go. В результате работа с функциями из wasm-файлов в Go выглядит так же, как работа с обычными функциями Go.
Вызов функций Go из WebAssembly-кода
Как мы видели в предыдущем примере, WebAssembly-модули способны экспортировать функции, которые можно вызвать извне. Это — тот механизм, который позволяет выполнять wasm-код в различных средах.
При этом WebAssembly-модули и сами могут работать с импортированными функциями. Рассмотрим следующую программу, написанную на Rust.
extern {
fn sum(x: i32, y: i32) -> i32;
}
#[no_mangle]
pub extern fn add1(x: i32, y: i32) -> i32 {
unsafe { sum(x, y) } + 1
}
Назовём файл с ней import.rs
. В результате его компиляции в WebAssembly получится код, который можно найти здесь.
Экспортированная функция add1
вызывает функцию sum
. Тут нет реализации данной функции, в файле определена лишь её сигнатура. Это — так называемая extern-функция. Для WebAssembly это — импортированная функция. Её реализацию необходимо импортировать.
Реализуем функцию sum
средствами Go. Для этого нам понадобится использовать cgo. Вот получившийся в результате код. Некоторые комментарии, представляющие собой описание основных фрагментов кода, снабжены номерами. Ниже мы поговорим о них подробнее.
package main
// // 1. Объявляем сигнатуру функции `sum` (обратите внимание на cgo).
//
// #include <stdlib.h>
//
// extern int32_t sum(void *context, int32_t x, int32_t y);
import "C"
import (
"fmt"
wasm "github.com/wasmerio/go-ext-wasm/wasmer"
"unsafe"
)
// 2. Пишем реализацию функции `sum` и экспортируем её (для cgo).
//export sum
func sum(context unsafe.Pointer, x int32, y int32) int32 {
return x + y
}
func main() {
// Чтение модуля WebAssembly.
bytes, _ := wasm.ReadBytes("import.wasm")
// 3. Объявление импортированной функции для WebAssembly.
imports, _ := wasm.NewImports().Append("sum", sum, C.sum)
// 4. Создание экземпляра модуля WebAssembly с импортами.
instance, _ := wasm.NewInstanceWithImports(bytes, imports)
// Позже закроем экземпляр WebAssembly.
defer instance.Close()
// Получение экспортированной функции `add1` из экземпляра WebAssembly.
add1 := instance.Exports["add1"]
// Вызов экспортированной функции.
result, _ := add1(1, 2)
fmt.Println(result)
// add1(1, 2)
// = sum(1 + 2) + 1
// = 1 + 2 + 1
// = 4
// QED
}
Разберём этот код:
- Сигнатура функции
sum
определяется в C (смотрите комментарий над командойimport "C"
). - Реализация функции
sum
определена в Go (обратите внимание на строку//export
— такой механизм cgo использует для установления связи кода, написанного на Go, с кодом, написанным на C). NewImports
— это API, используемый для создания импортов WebAssembly. В данном коде"sum"
— это имя функции, импортированной WebAssembly,sum
— это указатель на функцию Go, аC.sum
— указатель на функцию cgo.- И, наконец,
NewInstanceWithImports
— это конструктор, предназначенный для инициализации модуля WebAssembly с импортами.
Чтение данных из памяти
Экземпляр WebAssembly имеет линейную память. Поговорим о том, как читать из неё данные. Начнём, как обычно, с Rust-кода, который назовём memory.rs
.
#[no_mangle]
pub extern fn return_hello() -> *const u8 {
b"Hello, World!".as_ptr()
}
Результат компиляции этого кода оказывается в файле memory.wasm
, который используется ниже.
Функция return_hello
возвращает указатель на строку. Строка завершается, как в C, нулевым символом.
Теперь переходим на сторону Go:
bytes, _ := wasm.ReadBytes("memory.wasm")
instance, _ := wasm.NewInstance(bytes)
defer instance.Close()
// Вызов экспортированной функции `return_hello`.
// Эта функция возвращает указатель на строку.
result, _ := instance.Exports["return_hello"]()
// Значение указателя рассматривается как целое число.
pointer := result.ToI32()
// Чтение данных из памяти.
memory := instance.Memory.Data()
fmt.Println(string(memory[pointer : pointer+13])) // Hello, World!
Функция return_hello
возвращает указатель в виде значения i32
. Мы получаем это значение, вызывая ToI32
. Затем мы получаем данные из памяти с помощью instance.Memory.Data()
.
Эта функция возвращает слайс памяти экземпляра WebAssembly. Им можно пользоваться как любым слайсом Go.
Мы, к счастью, знаем длину строки, которую хотим прочесть, поэтому для чтения нужной информации достаточно воспользоваться конструкцией memory[pointer : pointer+13]
. Затем прочитанные данные конвертируются в строку.
Вот пример, в котором показаны более продвинутые механизмы работы с памятью при использовании WebAssembly-кода в Go.
Бенчмарки
Проект go-ext-wasm, в чём мы только что убедились, обладает удобным API. Теперь пришло время поговорить о его производительности.
В отличие от PHP или Ruby, в мире Go уже имеются решения для работы с wasm-кодом. В частности, речь идёт о следующих проектах:
- Life от Perlin Network — интерпретатор WebAssembly.
- Wagon от Go Interpreter — интерпретатор WebAssembly и набор инструментов.
В материале о проекте php-ext-wasm для исследования производительности использовался алгоритм n-body. Существует и множество других алгоритмов, подходящих для исследования производительности сред выполнения кода. Например, это алгоритм Фибоначчи (рекурсивная версия) и ρ-алгоритм Полларда, используемые в Life. Это и алгоритм сжатия Snappy. Последний успешно работает с go-ext-wasm, но не с Life или Wagon. В результате он был убран из набора испытаний. Код тестов можно найти здесь.
В ходе испытаний использовались самые свежие версии исследуемых проектов. А именно, это Life 20190521143330–57f3819c2df0 и Wagon 0.4.0.
Числа, представленные на диаграмме, отражают усреднённые значения, полученные после 10 запусков теста. В исследовании использовался MacBook Pro 15" 2016 года с процессором Intel Core i7 2.9 ГГц и с 16 Гб памяти.
Результаты тестов сгруппированы по оси X в соответствии с видами тестов. Ось Y показывает время в миллисекундах, необходимое на выполнение теста. Чем показатель меньше — тем лучше.
Сравнение производительности Wasmer, Wagon и Life с помощью реализаций различных алгоритмов
Платформы Life и Wagon, в среднем, дают примерно одинаковые результаты. Wasmer же, в среднем, в 72 раза быстрее.
Важно отметить то, что Wasmer поддерживает три бэкенда: Singlepass, Cranelift и LLVM. Бэкенд, используемый по умолчанию в Go-библиотеке — это Cranelift (тут можно узнать подробности о нём). Использование LLVM даст производительность, близкую к нативной, но решено было начать с Cranelift, так как это бэкенд даёт лучшее соотношение между временем компиляции и временем выполнения программы.
Тут можно почитать о разных бэкендах, об их плюсах и минусах, о том, в каких ситуациях их лучше использовать.
Итоги
Опенсорсный проект go-ext-wasm — это новая Go-библиотека, предназначенная для выполнения бинарного wasm-кода. Она включает в себя среду выполнения Wasmer. Её первая версия включает в себя API, необходимость в которых возникает чаще всего.
Проведённые испытания производительности показали, что Wasmer, в среднем, в 72 раза быстрее чем Life и Wagon.
Уважаемые читатели! Планируете ли вы пользоваться возможностями по выполнению wasm-кода в Go с использованием go-ext-wasm?
Автор: ru_vds