Разгоняем JS-парсер с помощью WebAssembly (часть 1: базовые возможности)

от автора

В прошлой статье, посвященной выяснению победителя в состязании JS-парсеров строки buffers-атрибута узла плана PostgreSQL, мы дошли до факта, что самый эффективный вариант — реализовать примитивный конечный автомат и никогда не трогать регулярные выражения и любые операции над строками сложнее .charCodeAt, если из строки вида 'Buffers: shared hit=123 read=456, local hit=789' мы хотим как можно быстрее получить JSON формата:

{   "shared-hit"  : 123 , "shared-read" : 456 , "local-hit"   : 789 }

Такой код на тестовом нормализованном наборе показывает время порядка 48ms на 6.3MB или около 130MB/s, что примерно в 11 раз быстрее наивного варианта со .split.

Hardcore State Machine
const buffersKeys = ['shared-hit', 'shared-read', 'shared-dirtied', 'shared-written', 'local-hit', 'local-read', 'local-dirtied', 'local-written', 'temp-hit', 'temp-read', 'temp-dirtied', 'temp-written'];  const parseBuffers = line => {   let rv = {};    let state;   let key;    _word: for (let off = 9 /*'Buffers: '*/, ln = line.length; off < ln; ) {     switch (line.charCodeAt(off)) {       case 0x73: // s = shared         state = 0;         off += 7;         continue _word;       case 0x6c: // l = local         state = 4;         off += 6;         continue _word;       case 0x74: // t = temp         state = 8;         off += 5;         continue _word;        case 0x68: // h = hit         key = state;         off += 4;         break;       case 0x72: // r = read         key = state + 1;         off += 5;         break;       case 0x64: // d = dirtied         key = state + 2;         off += 8;         break;       case 0x77: // w = written         key = state + 3;         off += 8;         break;     }      let val = 0;     _digit: for (; off < ln; off++) {       let ch = line.charCodeAt(off);       switch (ch) {         case 0x20: // ' '           off++;           break _digit;         case 0x2c: // ','           off += 2;           break _digit;         default:           val = val * 10 + ch - 0x30; // '0'       }     }      rv[buffersKeys[key]] = val;   }   return rv; }; 

Но всегда остается вопрос: «А еще быстрее — можно?»

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

Готовим трамплин

Поскольку из кода HSM-примера уже понятно, что хардкор мы любим, поэтому продолжим в том же духе — и писать ассемблерный код будем вручную, без использования Emscripten.

Для этого, в минимальном варианте, нам понадобится компилятор WAT-файлов (WebAssembly Text Format):

npm install wabt

И, прямо по примерам, соберем из него свой собственный «компилятор» compile-test.js:

const { readFileSync, writeFileSync } = require('fs');  const fn = 'buffers-test';  require('wabt')().then(wabt => {   const inputWat = `${fn}.wat`;   const outputWasm = `${fn}.wasm`;    const wasmModule = wabt.parseWat(inputWat, readFileSync(inputWat, 'utf8'));   const { buffer } = wasmModule.toBinary({});    writeFileSync(outputWasm, Buffer.from(buffer)); }); 

Заметим, что он генерирует buffers-test.wasm из buffers-test.wat, который пока выглядит у нас примитивной заглушкой:

(module   (func (result i32)     (i32.const 42)   )   (export "helloWorld" (func 0)) )

Для стартового понимания «что это вообще такое?» хорошо подойдут статья из MDN Описание текстового формата WebAssembly и полный перечень доступных WASM-инструкций.

А вызывать скомпилированное мы будем из buffers-test.js:

const { readFileSync } = require('fs');  const fn = 'buffers-test';  const run = async () => {   const buffer = readFileSync(`${fn}.wasm`);   const module = await WebAssembly.compile(buffer);      const instance = await WebAssembly.instantiate(module);   console.log(instance.exports.helloWorld()); };  run();

Возврат результатов из WASM в JS

В данном примере использован самый простой вариант получения результата работы WASM-кода — возврат значения функции. Но он не единственный — и раз мы хотим достичь наилучшего результата, нам стоит их сравнить.

Результат функции

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

// ...     const hw = instance.exports.helloWorld;    const hrb = process.hrtime();   // -- 8< --   for (let i = 1; i < 1000000; i++) {     let v = hw(); // простое получение результата   }   // -- 8< --   const hre = process.hrtime(hrb);   const usec = hre[0] * 1e+9 + hre[1];   console.log(usec); // ...
(module   ;; это глобальная переменная модуля, доступ к которой имеют все его функции   ;; let val = 0 - то есть изменяемое, не-const, целое 32-bit число   (global $val (mut i32)     (i32.const 0)   )    (func (export "helloWorld") (result i32) ;; сразу экспортируем с нужным именем     ;; val++ - да, вот настолько сложно     (global.set $val       (i32.add         (global.get $val)         (i32.const 1)       )     )     ;; return val     (global.get $val)   ) )

Для NodeJS 15.9.0, на которой будем проводить все наши тесты, время выполнения этого кода составляет от 10мс — в зависимости от мгновенной загрузки CPU.

Глобальная переменная

Глобальная переменная позволяет как передавать значения из JS в WASM-код, так и получать их обратно как результат какой-то работы.

// ...   // глобальная WASM-переменная с начальным значением 0   let val = new WebAssembly.Global({value : 'i32', mutable : true}, 0);   // этот объект мы используем для передачи информации "внутрь" WASM   const importObject = {     js : {       val     }   };   const instance = await WebAssembly.instantiate(module, importObject); // ...   for (let i = 1; i < 1000000; i++) {     hw();     let v = val.value; // .value получает реальное значение WebAssembly.Global   }

Изменение же в ассемблерном коде — минимальное:

(module   ;; это глобальная переменная, переданная из JS-кода как importObject.js.val   (global $val (import "js" "val") (mut i32))    (func (export "helloWorld") ;; функция теперь ничего не возвращает     (global.set $val       (i32.add         (global.get $val)         (i32.const 1)       )     )   ) )

Этот код делает ровно то же самое… но уже за 48ms. Почти в 5 раз медленнее, однако!

Замечу, что если вместо val.value использовать val.valueOf(), время выполнения возрастает и вовсе до 58ms.

Разделяемая память

Предыдущий результат выглядит совсем грустно, ведь мы хотим и передавать и получать достаточно большое количество значений. Но отчаиваться рано — на помощь придет возможность создать сегмент разделяемой памяти, содержащей общее адресное пространство между JS и WASM:

// ...   // сегмент разделяемой памяти с начальным размером в 1 страницу   const memory = new WebAssembly.Memory({initial : 1});   // проекция этой памяти в качестве массива 32-bit-чисел   const prj32 = new Uint32Array(memory.buffer);    const importObject = {     js : {       memory     }   }; // ...   for (let i = 1; i < 1000000; i++) {     hw();     let v = prj32[0]; // получение значения из проекции   } // ...
(module   ;; это сегмент разделяемой памяти размером в одну 64KB-страницу importObject.js.memory   (import "js" "memory" (memory 1))    (func (export "helloWorld")     ;; memory[0] = memory[0] + 1     (i32.store       (i32.const 0) ;; offset       (i32.add      ;; data         (i32.load           (i32.const 0) ;; offset         )         (i32.const 1)       )     )   ) )

И результат уже вполне достойный — от 12ms. То есть совсем немного медленнее, чем возврат результата функции, зато вернуть можно сразу несколько значений — надо только согласовать смещения данных между JS и WASM-кодом.

Стоит обратить внимание, что WASM оперирует смещениями памяти «в байтах», а JS — «в ячейках». То есть если нам нужно прочитать prj32[2], то писать i32.store придется по смещению 8 = 2 * 4 («байтовый» размер элемента проекции можно узнать как prj32.BYTES_PER_ELEMENT).

Заметим, что нам ничто не мешает иметь одновременно несколько «разнотипных» проекций на один и тот же сегмент памяти.

Поскольку мы находимся все-таки не в браузере, а на серверсайде в Node.js-окружении, у нас есть больше возможностей — например, использовать чтение числовых значений непосредственно из Buffer-проекции того же сегмента памяти:

// ...   const buf = Buffer.from(memory.buffer); // еще одна 1-байтовая проекция // ...   for (let i = 1; i < 1000000; i++) {     hw();     let v = buf.readUInt32LE(0); // тут смещение тоже побайтовое   } // ...

Преимущество этого подхода в возможности читать данные блоками нужной размерности, но по произвольному байтовому смещению, без выравнивания по BYTES_PER_ELEMENT. Увы, за все приходится платить — и результат получается чуть хуже — 16ms.

Вызов JS-функции из WASM

Тут возникает резонный вопрос: если мы имеем потери времени при обращении за значением из JS в WASM — так не эффективнее ли будет это значение передать прямо из WASM-кода в качестве аргумента нужной JS-функции?

// ...   const func = v => {     // эта функция будет вызываться из WASM     // console.log(v)   };    const importObject = {     js : {       memory     , func     }   }; // ...   for (let i = 1; i < 1000000; i++) {     hw();   } // ...
(module   ;; объявляем внутреннее имя и сигнатуру JS-функции   (import "js" "func" (func $js_func (param i32)))   (import "js" "memory" (memory 1))    (func (export "helloWorld")     ;; memory[0] = memory[0] + 1     (i32.store       (i32.const 0)       (i32.add         (i32.load           (i32.const 0)         )         (i32.const 1)       )     )     ;; вызов js.func(memory[0])     (call $js_func       (i32.load         (i32.const 0)       )     )   ) )

Такой вариант показывает время еще чуть хуже — порядка 20ms, поскольку все-таки требует операций сохранения/восстановления стека при вызове функции.

Подведем промежуточные итоги:

Возврат единственного значения

Результат функции

10ms

Глобальная переменная .value

48ms

Глобальная переменная .valueOf()

58ms

Вызов JS-функции

20ms

Возврат набора

Разделяемая память через ArrayBuffer

12ms

Разделяемая память через Buffer

16ms

BigInt vs умножение

Пока что мы протестировали варианты возврата 32bit-значений, но случается, что приходится оперировать и числами, выходящими за рамки этого диапазона. В этом случае у нас есть два варианта: использовать тип BigInt или воспользоваться умножением пары 32bit-значений.

const buf = Buffer.from(memory.buffer);

let v = buf.readBigUInt64LE(0);

136ms

const prj64 = new BigUint64Array(memory.buffer);

let v = prj64[0];

24ms

const prj32 = new Uint32Array(memory.buffer);

let v = prj32[0] + prj32[1] * 0x100000000;

14ms

В общем, выбор достаточно очевиден — по возможности стоит всегда пользоваться 32bit-проекцией.

Передача данных из JS в WASM

Воспользуемся возможностью иметь несколько проекций, чтобы сделать доступным в WASM-коде сразу весь файл, зарезервировав первую страницу памяти под обмен дополнительными значениями:

// ...   const prj32 = new Uint32Array(memory.buffer); // 4-байтовая проекция   const prj8 = new Uint8Array(memory.buffer);   // 1-байтовая проекция    const offset = 1 << 16; // размер первой 64KB-страницы    const data = readFileSync('buffers.txt');   // _на_ столько страниц нам надо расширить разделяемую память   memory.grow((data.byteLength >> 16) + 1);    // заливаем все прочитанные данные, начиная со 2-й страницы   data.copy(prj8, offset); // ...

Так даже может какое-то время работать, но недолго и нестабильно, потому что memory.grow порождает уже новый сегмент памяти, поэтому все проекции надо создавать уже после наращивания памяти:

// ...   memory.grow((data.byteLength >> 16) + 1);   const prj32 = new Uint32Array(memory.buffer);   const prj8 = new Uint8Array(memory.buffer); // ...

В следующей части мы перейдем к написанию реального прикладного кода.


Материалы:

ссылка на оригинал статьи https://habr.com/ru/company/tensor/blog/544824/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *