Интеграция WebAssembly с JavaScript BigInt
Функция JS-BigInt-Integration упрощает передачу 64-битных целых чисел между JavaScript и WebAssembly. В этом посте объясняется, что это значит и почему это полезно, включая упрощение работы для разработчиков, ускорение выполнения кода и уменьшение времени сборки.
64-битные целые числа
Числа JavaScript представлены в виде doubles, то есть 64-битных чисел с плавающей точкой. Такое значение может содержать любое 32-битное целое число с полной точностью, но не все 64-битные числа. WebAssembly, с другой стороны, полностью поддерживает 64-битные целые числа типа i64
. Проблема возникает при их соединении: если функция Wasm возвращает i64, например, то VM выбрасывает исключение при вызове из JavaScript, что-то вроде:
TypeError: Wasm function signature contains illegal type
Как говорит ошибка, i64
не является легальным типом для JavaScript.
Исторически лучшим решением этой проблемы была «легализация» Wasm. Легализация означает преобразование импорта и экспорта Wasm для использования допустимых типов для JavaScript. На практике это сделало два действия:
- Замена параметра 64-битного целого числа на два 32-битных параметра, представляющих младшие и старшие биты соответственно.
- Замена возвращаемого значения 64-битного целого числа на 32-битное значение, представляющее младшие биты, и использование 32-битного значения сбоку для старших битов.
Например, рассмотрим этот модуль Wasm:
(module
(func $send_i64 (param $x i64)
..))
Легализация преобразует его в это:
(module
(func $send_i64 (param $x_low i32) (param $x_high i32)
(local $x i64) ;; реальное значение, которое будет использоваться остальным кодом
;; код для объединения $x_low и $x_high в $x
..))
Легализация выполняется со стороны инструментов до того, как она достигает VM, который её запускает. Например, библиотека инструментов Binaryen имеет проход под названием LegalizeJSInterface, который выполняет это преобразование, которое выполняется автоматически в Emscripten, когда это необходимо.
Недостатки легализации
Легализация достаточно хорошо работает во многих случаях, но она имеет недостатки, такие как дополнительная работа по объединению или разделению 32-битных частей в 64-битные значения. Хотя это редко случается на критическом пути, когда это происходит, замедление может быть заметным - позже мы увидим некоторые цифры.
Еще одним неудобством является то, что легализация заметна пользователям, поскольку она изменяет интерфейс между JavaScript и Wasm. Вот пример:
// example.c
#include <stdint.h>
extern void send_i64_to_js(int64_t);
int main() {
send_i64_to_js(0xABCD12345678ULL);
}
// example.js
mergeInto(LibraryManager.library, {
send_i64_to_js: function(value) {
console.log("JS получено: 0x" + value.toString(16));
}
});
Это небольшой C-программа, которая вызывает функцию JavaScript-библиотеки (то есть мы определяем внешнюю функцию C в C и реализуем её в JavaScript как простой и низкоуровневый способ вызова между Wasm и JavaScript). Всё, что делает эта программа, это отправляет i64
в JavaScript, где мы пытаемся напечатать его.
Мы можем собрать это с помощью
emcc example.c --js-library example.js -o out.js
Когда мы запускаем его, мы не получаем то, что ожидали:
node out.js
JS получено: 0x12345678
Мы отправили 0xABCD12345678
, но получили только 0x12345678
😔. Что происходит здесь, так это легализация превращает этот i64
в два i32
, и наш код просто получил младшие 32 бита, и проигнорировал другой отправленный параметр. Чтобы правильно обработать это, нам нужно сделать что-то вроде:
// i64 разбито на два 32-битных параметра, “младший” и “старший”.
send_i64_to_js: function(low, high) {
console.log("JS получено: 0x" + high.toString(16) + low.toString(16));
}
Запустив это сейчас, мы получаем
JS получено: 0xabcd12345678
Как видите, с легализацией можно жить. Но это может быть довольно раздражающим!
Решение: BigInt в JavaScript
JavaScript теперь имеет значения BigInt, которые представляют целые числа произвольного размера, так что они могут корректно представлять 64-битные целые числа. Естественно захотеть использовать их для представления i64
из Wasm. Именно это делает функция интеграции JS-BigInt!
Emscripten поддерживает интеграцию Wasm BigInt, которую мы можем использовать для компиляции исходного примера (без каких-либо изменений для легализации), просто добавив -s WASM_BIGINT
:
emcc example.c --js-library example.js -o out.js -s WASM_BIGINT
После этого можно запустить (обратите внимание, что в настоящее время нам нужно передать Node.js флаг для включения интеграции BigInt):
node --experimental-wasm-bigint a.out.js
JS получил: 0xabcd12345678
Отлично, именно то, что мы хотели!
И это не только проще, но и быстрее. Как упоминалось ранее, на практике редко встречается, чтобы преобразования i64
происходили в горячей области, но если это происходит, то замедление может быть заметным. Если превратить приведенный выше пример в тест производительности, вызывая send_i64_to_js
множество раз, версия с BigInt оказывается на 18% быстрее.
Еще одно преимущество интеграции BigInt заключается в том, что инструментальная цепочка может избежать легализации. Если Emscripten не нужно легализовать, то ему может не понадобиться выполнять какую-либо работу с Wasm, который излучает LLVM, что ускоряет время сборки. Вы можете получить ускорение, если соберете с -s WASM_BIGINT
и не предоставите никаких других флагов, требующих изменений. Например, -O0 -s WASM_BIGINT
работает (но оптимизированные сборки запускают оптимизатор Binaryen, что важно для размера).
Заключение
Интеграция WebAssembly BigInt была реализована в нескольких браузерах, включая Chrome 85 (выпущен 25-08-2020), так что вы можете попробовать уже сегодня!