javascript

Как построить мост между JavaScript и C++ через WASM, или гайд для самых маленьких

  • пятница, 23 августа 2024 г. в 00:00:07
https://habr.com/ru/articles/837692/

Введение

Всем привет. Сегодня я хочу поговорить об использовании WASM с C++ и разберу, как взаимодействовать с этим всем делом через JavaScript.

Когда я начинал изучение технологии WASM, которая является довольно интересной и обсуждаемой темой в последние несколько лет. Почти сразу я столкнулся со значительным разрывом в уровнях туториалов (материалы либо очень простые и не имеют смысла, либо для совсем продвинутого уровня) и скудной документацией.

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

В нём я постарался заполнить те информационные дыры, с которыми столкнулся сам во время изучения. Я собрал знания и хочу передать им вам, чтобы вы могли немного быстрее влиться в данную, не самую простую, тему.

В рамках данной статьи не будет подниматься тема производительности и некоторых лучших реализаций, от читателя требуется минимальное понимание JavaScript или TypeScript, и C++ кода.

Что будем делать

  • Сначала установим Emscripten, чтобы, в дальнейшем, компилировать наш С++ код для использования в WASM.

  • Узнаем несколько вариантов соединения TypeScript с WASM-функциями.

  • Разберем несколько примеров функций и разберем некоторые проблемы.

  • Запустим полученный нами результат в NodeJS.

С чем будем работать

  • Для компиляции из Си будем использовать Emscripten - компилятор LLVM-байткода в код JavaScript.

  • Со стороны JavaScript будем использовать TypeScript с Node.JS.

  • Для написания С++ кода рекомендую установить IDE, я использую CLion, и добавлю в него .h файлы из Emscripten для рабочего линтера.

Установка Emscripten

С установкой дела обстоят довольно просто, следуйте документации от разработчиков.

Первый и простой пример

Для интеграции нашей С++ функции, нам, собственно, нужен С++ файл с функцией. (Про бинды мы еще не знаем!)

Наш первый С++ код будет выглядеть так:

Скрытый текст
#include <emscripten/bind.h>

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
    return a + b;
}

Разберем его подробнее:

Скрытый текст
#include <emscripten/bind.h>

Буквально как импорт библиотек в JavaScript.

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

Взял с сайта мозиллы, довольно удобный блок для экспорта.

EXTERN EMSCRIPTEN_KEEPALIVE

Обязательно используем перед каждой функцией кроме main, иначе компилятор подумает, что это - "мертвый" код и не возьмет его в итоговый файл.

int add(int a, int b) {
    return a + b;
}

Простейшая функция на Си, которая принимает a, b и возвращает их сумму в виде целого числа.

Теперь мы должны скомпилировать наш С++ код в WASM и JavaScript обёртку для управления оным:

emcc src/wasm/native.example.cpp -o build/native.example.js -s EXPORTED_FUNCTIONS='["_malloc", "_free"]' -s EXPORTED_RUNTIME_METHODS='["lengthBytesUTF8", "stringToUTF8", "setValue", "getValue", "UTF8ToString"]' -s MODULARIZE -s ENVIRONMENT='node' -Oz

Про EXPORTED_RUNTIME_METHODS и EXPORTED_FUNCTIONS станет понятнее чуть позже, но пусть будет уже сейчас.

На выходе мы получаем в папке build файлы native.example.js и бинарный файл native.example.wasm, которые мы будем использовать в нашем JavaScript коде.

Чтобы создать модуль и использовать его, можно использовать следующий код:

Скрытый текст
const WasmModule = require(path.join(__dirname, '..', 'build', 'native.example.js'));
const wasmFile = path.join(__dirname, '..', 'build', 'native.example.wasm');

const createModule = async () => {
    const wasmBinary = readFileSync(wasmFile);
    return WasmModule({
        wasmBinary
    });
};

Теперь мы можем получить модуль с функциями из этого WASM-файла и его обёртки.

Скрытый текст
const a = 2;
const b = 5;

const result = module._add(a, b); // 7;

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

add() -> _add() - Пример таких названий функций.

В общем, на этом большинство туториалов и заканчивается, оставляя программиста с дном огромного айсберга.

Пример с массивом

С данного примера, мы будем опускать пройденные детали, чтобы уменьшить количество текста

.Создадим следующую функцию:

Скрытый текст
EXTERN EMSCRIPTEN_KEEPALIVE
int sumArray(int array[], int length) {
    std::vector<int> vec(array, array + length);
    int sum = 0;
    for (int num : vec) {
        sum += num;
    }
    return sum;
}

Функция принимает массив чисел и длинну массива, чтож, компилируем и получаем сумму элементов.

Скрытый текст
const sumArray = module._sumArray;
const result = sumArray([1, 2, 3, 4, 5], 5);
console.log("Сумма массива равна: ", result);

Запускаем функцию, получаем... "Сумма массива равна: 0".

Почему ноль, ведь мы передали массив и должны получить 15?

На данное возмущение ответ прост - мы не можем (можем, но это немного позже) отдавать WASM-функциям что-то сложнее некоторых примитивов.

Тут и наступает 99% проблем при знакомстве с WASM - передача что-то сложнее интегеров.

И одним из путей решения данной проблемы будет, барабанная дробь, адресная арифметика 🙈.

Очень важно! Несмотря на кросс-платформенность, в WebAssembly используется 32-разрядная модель адресации, что означает, что указатели и индексы имеют 32 бита.

Мы должны использовать функции модуля-обёртки в виде "_malloc" для выделения памяти WASM и "_free" - для освобождения оной. В итоге наш код с шагами будет выглядеть следующим образом:

Скрытый текст
const sumArray = module._sumArray;

// Создаем массив i32;
const vec = new Int32Array([1, 2, 3, 4, 5, 6]);
 
// Выделяем под этот массив память в виде длинна массива * количество байт и получаем ссылку на его начало
const arrayPtr = module._malloc(vec.length * vec.BYTES_PER_ELEMENT);
    
vec.forEach((item, index) => {
   // Для каждого нужного адреса копируем значение.
     module.setValue(arrayPtr + index * vec.BYTES_PER_ELEMENT, item, 'i32');
})
/**
 * ИЛИ
 * Если тип входит в существующие типы куч, то тогда можно сделать так
 * в куче 32 битных чисел надо начинать с индекса, а не ссылки, а индекс это укзаатель/(32/8) = (4 бита)
 *
 * module.HEAP32.set(vec, arrayPtr >> 2);
 *
 * Либо же каждые 4 байта записываем в память значения 4-х байтного числа, как до комментария
 */

const result = sumArray(arrayPtr, vec.length); // Должны получить 21

// Освобождаем выделенную память, если не сделали в С++
module._free(arrayPtr);

console.log('Результат функции sumArray:', result); // Результат функции sumArray: 21

...Запускаем код и получаем "Сумма массива равна: 21"!

Получилось! Теперь мы знаем, что при передаче не примитивов, нужно самим передавать значение в WASM-память и передавать указатель на начало этой структуры, в данном случае, массива.

Просто про память

Не буду вдаваться в Computer Science, но предоставлю очень быструю справку.

JavaScript - язык с безопасной памятью, то есть, можно сделать что угодно, но до прямых значений памяти мы добраться не сможем.

Си и С++ - другое дело, и нам, в силу специфики работы с WASM, нужно предоставлять указатели на структуры.

Возьмем пример из sumArray. В результате подготовки к вызову функции, мы выделили память под 6 элементов, забили память WASM нашими значениями, в таком виде:

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

В этом нам как раз и помогает функция модуля - _malloc и setValue: первым мы выделяем память на определенное количество байт, а вторым мы передаем значение по адресу в память WASM, используя определенный LLVM-тип.

Скрытый текст
// Выделяем память на n число байт
const arrayPtr = module._malloc(vec.length * vec.BYTES_PER_ELEMENT);

// Устанавливаем значения в WASM-память
vec.forEach((item, index) => {
    module.setValue(arrayPtr + index * vec.BYTES_PER_ELEMENT, item, 'i32');
})    

Вместо вызова _free мы можем добавить в C++ код delete x, где x - переменная, которую мы хотим освободить.

Именно из-за нашей подготовки памяти нам и получилось передать массив через WASM в С++.

Как передавать строки

Передавать строки советую в виде указателей - будет намного меньше проблем и кода.

  1. Сначала мы выделяем память под строку и получаем ее указатель.

  2. Передаем этот указатель в качестве типа указателя "*" с фиксированным размером в 4 байта, что спасёт нас от дополнительной арифметики.

Выглядеть функция будет примерно так:

Скрытый текст
/**
 * Функция для выделения и копирования строки в память WebAssembly
 * @param str строка
 * @param module модуль wasm
 * @return {number} указатель на начало строки
 */
export function allocateString(str: string, module: ModuleNative): number {
    /**
     * Когда мы копируем строку в память WebAssembly с помощью функции module.stringToUTF8, мы должны учитывать, что в конце строки должен быть нулевой символ (нулевой терминатор). Иначе мы будем терять часть символов.
     */
    const lengthBytes = (module.lengthBytesUTF8(str) + 1);
    /**
     * Выделяем память под строку
     */
    const stringPtr = module._malloc(lengthBytes);
    /**
     *
     * Функция module.stringToUTF8 используется для копирования строки из JavaScript в память WebAssembly в формате UTF-8.
     * Функция stringToUTF8 предназначена для правильного копирования и кодирования JavaScript-строки в память WebAssembly в формате UTF-8.
     */
    module.stringToUTF8(str, stringPtr, lengthBytes);
    return stringPtr;
}

Сначала мы получаем нужное количество байтов для строки, не забываем обязательно про нулевой терминатор - конец строки, иначе будут строки съезжать влево на 1 элемент!

Затем выделяем нужное количество байт и копируем значение в память WASM, получаем ссылку на строку.

Как передавать булево значение

Тут всё просто - один байт с нулем или единицей:

Скрытый текст
const boolPtr = module._malloc(1);
module.setValue(boolPtr, 0 или 1, 'i8');

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

В нашей ситуации (опять же, про бинды мы не знаем!), нужно самим передавать данные в память и я расскажу, как это сделать.

Скрытый текст
  1. Создадим структуру в С++

EXTERN struct Person {
    const char* name;
    int age;
};
  1. Создаем функцию

Person* getTheOldestPerson(Person* persons, int length) {
    int maxAge = 0;
    int index = 0;
    for (int i = 0; i < length; i++) {
        if ( persons[i].age >= maxAge ) {
            maxAge = persons[i].age;
            index = i;
        }
    }
    return &persons[index];
}

Очень важно правильно расставлять поля, потому что при работе с объектами в функциях, память их значений будет указана как при создании структуры. name - сначала идет ссылка на строку в памяти в 4 байт, потом уже age - 4 байта числа, никак иначе!

Скрытый текст

Мы хотим передать следующий массив объектов в C++:

[
    { name: 'Alice', age: 30 },
    { name: 'Bob', age: 25 },
    { name: 'Charlie', age: 35 },
    { name: 'Shakira', age: 20 }
]

Для этого нам нужно сделать следующие шаги:

  1. Сначала выделим для каждой строки память и передадим туда её копию с помощью нашей функции allocateString

  2. Считаем общее количество нужных байтов для массива:
    4 указателя по 4 байта и 4 int32 по 4 байта = 4*(8) = 32 байт нам нужно на этот массив.

  3. Выделяем память и начинаем забивать ее значениями.

На выходе получаем такой JavaScript-код:

Скрытый текст
// Создать массив объектов
const people: People[] = [
    { name: 'Alice', age: 30 },
    { name: 'Bob', age: 45 },
    { name: 'Charlie', age: 35 },
    { name: 'Shakira', age: 20 }
];


// Выделить память для массива объектов и копируйте данные
const personSize = 8; // Размер структуры Person в байтах (2 поля: name (4 байта) и age (4 байта))
const arrayPtr1 = module._malloc(people.length * personSize);

/**
 * Для каждого объекта (в данном случае - для каждой персоны) мы копируем значения из JavaScript в память WebAssembly.
 */
people.forEach((person, index) => {
    const namePtr = allocateString(person.name, module);
    module.setValue(arrayPtr1 + index * personSize, namePtr, '*'); // Указатель на имя
    module.setValue(arrayPtr1 + index * personSize + 4, person.age, 'i32'); // Значение возраста
});

В итоге, мы заполним память следующим образом:

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

Вызываем функцию и получаем указатель на объект, разбираем его:

Скрытый текст
const resultPtr = getTheOldestPerson(arrayPtr1, people.length);
// Получаем значение указателя на строку из памяти
const namePtr = module.getValue(resultPtr, '*');
// Получаем строку из указателя имени
const name = module.UTF8ToString(namePtr);
// Получаем возраст из указателя результата + 4 из-за указателя на имя
const age = module.getValue(resultPtr + 4, 'i32');

const theOldestPerson: People = {
    name,
    age
}

// Освободить выделенную память только из результата
module._free(resultPtr);
module._free(namePtr);

console.log('Самый старый человек: ', theOldestPerson);

Получаем на выходе: "Самый старый человек: { name: 'Charlie', age: 35 }", отлично.

Что такое бинды (Emscripten bindings)?

А если я вам скажу, что мы можем убрать всю эту тему с выделением памяти и получения из нее же значений?

Что можно регистрировать вектора и передавать их в функции без проблем?

Давайте разберемся.

Регистрация векторов и функций

Emscripten предлагает создание статических обёрток над С++ кодом, выглядит это так:

Пример
EMSCRIPTEN_BINDINGS(my_module) {
    emscripten::function("Конечное название функции", &функция);
}

Теперь мы сможем пользоваться нашей функции без "_", и создадим обёртку функции от Emscripten.

module.myFunction(...) - вот такой вызов у нас будет из JavaScript-кода.

Что же до векторов? - Всё просто, допустим, у нас есть функция, которая принимает вектор.

myFunction(std::vector<int> &vec) {...} - Мы не сможем даже используя менеджмент памяти передать вектор, так что нужно тоже воспользоваться биндами:

Скрытый текст
EMSCRIPTEN_BINDINGS(my_module) {
    emscripten::function("myFunction", &myFunction);
    emscripten::register_vector<std::vector<int>>('MyVector');
}

Теперь мы можем создать вектор с нашими данными и передать в С++ функцию:

Скрытый текст
const vector = new module.MyVector();
vector.push_back(1);
vector.push_back(2);
vector.push_back(3);

module.MyFunction(vector);

Мы передали значение и С++ код его получит и корректно обработает.

А как же нам быть, если наша функция возвращает вектор и мы хотим его корректно получить? - Всё еще проще:

Скрытый текст
// Вызываем функцию
const resultVector = module.MyFunction(vector);

// Преобразуем результат обратно в массив JavaScript
const outputArray = [];
for (let i = 0; i < resultVector.size(); i++) {
  outputArray.push(resultVector.get(i));
}

// Освобождаем память, если необходимо
vector.delete();
resultVector.delete();

// Вывод результата
console.log(outputArray);

Данная обёртка над векторами хорошо работает и проблем с ней не должно возникать.

Финальный пример. Полностью передаем управление Emscripten bindings

Что делать, если мы вот вообще хотим сделать нашу интеграцию максимально удобно? - Использовать emscripten::val!

Скрытый текст
EXTERN struct User {
    std::string id;
    std::string name;
    bool isSuperUser;
};

EXTERN EMSCRIPTEN_KEEPALIVE
emscripten::val createUsers(emscripten::val userArray) {
    std::vector<User> result;
    const int length = userArray["length"].as<int>();
    for (int i = 0; i < length; i++) {
        User newUser;
        const std::string uuid = generateUUID();
        newUser.id = uuid;
        newUser.name = userArray[i]["name"].as<std::string>();
        newUser.isSuperUser = userArray[i]["isSuperUser"].as<bool>();
        result.push_back(newUser); // Создание и добавление элемента в конец вектора
    }
    return emscripten::val::array(result);
}

Очень удобно, мы можем обращаться прямо как с объектами в JavaScript.

Собираем с флагом --bind и используем в модуле:

Скрытый текст
const users: User[] = [
    {name: 'Oleg', isSuperUser: false},
    {name: 'Rurik', isSuperUser: true},
    {name: 'Alexander', isSuperUser: false}
];

const result = module.createUsers(users);
console.log('Новые пользователи:', result);

Запускаем, получаем ошибку от WASM, что нет какого-то 4User.

Смысл в том, что модулю-обёртке нужно помочь указать, что будет передавать в аргументах.

Скрытый текст
EMSCRIPTEN_BINDINGS(my_module) {
    emscripten::function("createUsers", &createUsers);
    emscripten::value_object<User>("User")
        .field("id", &User::id)
        .field("name", &User::name)
        .field("isSuperUser", &User::isSuperUser)
    ;
}

Скрытый текст
Новые пользователи: [
  {
    id: 'a2aed6b1-253e-4e7d-b9bd-6e85f6c94eaf',
    name: 'Oleg',
    isSuperUser: false
  },
  {
    id: 'a581238c-35ba-4183-bdda-7cb3355bcd1a',
    name: 'Rurik',
    isSuperUser: true
  },
  {
    id: 'fc1c3736-30da-4630-a24d-b33076c6471f',
    name: 'Alexander',
    isSuperUser: false
  }
]

И, как вы могли заметить, мы вообще ничего не сделали, чтобы получить адекватный для JavaScript результат, сразу появился приемлемый массив объектов.

Этот факт, что использование биндов Emscripten делает жизнь разработчика в сотни раз проще, не может не радовать, но, вероятно, придется пожертвовать производительностью при сериализации в val, но это уже отдельный разговор.

Доступ к JavaScript из С++

Emscripten так же позволяет получать доступ к глобальным переменным JavaScript внутри С++ через emscripten::val::global("переменная");.

Например, получим доступ к консоли и выведем там массив из нашего последнего примера, если добавим ту функцию следующую строку:Таким же образом можно получить доступ к document в браузере, что как раз кстати, да и к любой глобальной переменной.

emscripten::val::global("console").call<void>("log", userArray);

После запуска функции в JavaScript, у нас в консоли появится:

Скрытый текст
[
  { name: 'Oleg', isSuperUser: false },
  { name: 'Rurik', isSuperUser: true },
  { name: 'Alexander', isSuperUser: false }
]

Таким же образом можно получить доступ к document в браузере, что как раз кстати для работы с DOM, да и к любой глобальной переменной.

Крайне рекомендую всё же установить IDE с линтером и ознакомиться с функционалом.

Вывод

  • Мы создали несколько примеров, прояснили, как наладить общение между С++ и JavaScript через WASM.

  • Мы познакомились с выделением, копированием в память элементов и получению данных из памяти.

  • Узнали про бинды Emscripten, который очень сильно упрощают разработку.

Благодарю всех за внимание, надеюсь, что вам понравилась статья и работа с WASM будет более понятной.

Код для данной статьи и докерфайл с этапом сборки C++ и запуском примеров вы можете скачать и посмотреть на гитхабе.