В первой части статьи мы исследовали скорость различных вариантов обмена информацией между JavaScript и WASM-кодом. В этом продолжении - наконец-то займемся написанием прикладного кода нашего парсера. Мы ведь теперь пишем "прямо на ассемблере" - значит, все будет супербыстро! Правда ведь? Начнем с самых простых действий - опишем, где-какие данные будут лежать в разделяемой памяти. первые 12 * 8 байт  используем для возврата 64bit-значений каждого из 12 потенциально возможных ключей данные в эти поля будет записывать импортируемая parseBuffersWASM в последней 32bit-ячейке первой страницы памяти  будем сохранять текущее положение "курсора" парсера  содержит разбираемый контент Переносим наш JS-код конечного автомата из начала первой части "как есть": все переменные, с которыми работают несколько функций - глобальные повторяющиеся конструкции типа off += off_v вынесены в несколько вспомогательных функций Запускаем… 76ms - ой, и это вместо 48ms исходного JS… Давайте разбираться. А ведь мы каждый раз все 96 байт, куда записываются возвращаемые результаты, затираем нулями - причем неважно, одно, два или 10 значений мы реально записывали. Да еще и читаем их все! А ведь если вернуть информацию о том, какие именно ключи были записаны, то и от зануления можно отказаться, и от чтения лишнего. Сделать это весьма просто - достаточно вернуть из WASM-кода значение с битовой маской записанных ключей: Запускаем - уже 54ms, практически вплотную приблизились к исходному времени! Если внимательно посмотреть на исходные данные, то станет очевидно, что большинство строк имеют либо вид 'shared hit=XXX', либо 'shared read=YYY', либо 'shared hit=XXX read=YYY', и существенно реже что-то другое - что вполне логично, ведь почти всегда узел плана или читает что-то из памяти, или с диска, или получает вверх по дереву накопленную сумму этих же показателей. В нашем случае это означает значение маски 1, 2 или 3, обработку которых мы можем пустить по сокращенному пути: Результат - 44ms, ура! Мы уже опередили исходный вариант. А еще быстрее - можно? Заметим, что в нашем WASM-коде есть несколько весьма похожих кусков типа такого: Причем, если нам приходится проверять совпадение с 'w', то, значит, уже 6 неудачных сравнений до этого мы произвели - с вариантами 's', 'l', 't', 'h', 'r', 'd'. Чтобы избежать необходимости подобного перебора, в WASM используется инструкция call_indirect, реализующая вызов функции в соответствии с заранее сформированной таблицей. Собственно, для этого нам и придется преобразовать каждое такое условие в отдельную функцию, возвращающую 1. 0 будет возвращать функция-заглушка, означающая возникновение неопознанного символа. В нашем случае это как раз будет '\n', сигнализирующая окончание строки: Такой вариант вызова позволяет отыграть еще немного и добиться результата в 40ms - почти 20% выигрыша! Выше у нас приведен код, в котором стоп-символ сравнивается в худшем случае трижды - давайте объединим эти условия в одно: Поскольку CPU достаточно сильно не любит ветвления, этот способ позволяет отыграть еще 2мс. Вспомним, что мы считываем число "посимвольно", а все развитие процессорной отрасли последние пару десятилетий говорит о том, что обрабатывать надо как можно больше данных за единственную операцию - то есть SIMD-подход в чистом виде. Воспользуемся техникой, описанной в статье про парсинг double - зачем перебирать "поцифирно", если можно обрабатывать сразу 8 последовательных символов, считав их в регистр. Основных сложностей тут две: надо четко понимать, где/чем число заканчивается - пробелом (0x20), запятой (0x2C) или концом строки (0x0A), поскольку от этого зависит, через сколько символов дальше надо "перешагнуть" число может не умещаться в 8 байт В нашем случае число '12345' с пробелом на конце будет считано в регистр как 0x..203534333231 .... - и тут можно легко заметить, что любой из стоп-символов не превосходит 0x2F, в то время как цифры - заведомо не меньше 0x30. Поэтому определить количество цифр в числе можно достаточно легко: все цифры при наложении маски & 0x10 дают ненулевой результат, а стоп-символы - нулевой находим первый с конца ненулевой байт с помощью инструкции .ctz, определяющей количество нулевых бит "в хвосте" числа незначимое для нас битовое смещение внутри байта (ctz = 44) усечется при делении на 8 с помощью битового сдвига Чтобы получить значение первого нецифрового символа, сдвинем наше число на n << 3 бит вправо - самый младший байт и будет искомым: Теперь, чтобы SWAR-хинт отработал как надо, необходимо обеспечить выравнивание числа в максимальную позицию: В подавляющем большинстве ситуаций, числа будут умещаться в 7 знаков, а из 8-го мы извлечем стоп-символ. Но значения длиной 8 и более знаков тоже встречаются - для этого, вместо организации цикла, просто повторим SWAR для следующего 8-символьного блока. Только надо не забыть, что второй блок может содержать 0 цифр, в отличие от первого. В результате, мы получим в val число из первых 8 символов, а в qword - из следующего блока. Чтобы получить итоговый результат, надо val домножить на соответствующую количеству цифр во втором блоке количество цифр: Чтобы не вычислять коэффициенты домножения каждый раз, занесем их сразу в память по смещению 128 (32 ячейки по 4 байта): Проверяем - 32ms! Поскольку наш код должен работать "на потоке", давайте посмотрим, какое время продемонстрирует он при повторном прогоне: Хм… почему-то стало 26ms. Приятно, что меньше, но - почему? Для ответа на этот вопрос посмотрим генерируемый байткод: В теле сформировавшегося файла поищем уникальную для нашего WASM-кода сигнатуру - например, "маску цифр" 1010101010101010. Что интересно, она найдется дважды: Мы видим два примера ассемблерного кода, полученного из одного и того же WASM-кода. Например, среди WASM-инструкций банально отсутствует i32.not(v), которую вполне официально предлагается реализовывать через i32.xor(v, -1). Собственно, именно это мы и видим в нашем участке кода. Компилятор Liftoff, хоть и считается более продвинутым, превратил такой хинт "по написанному" в xorq rax,0xff, а TurboFan использовал более эффективный вариант notq rdx. А это только одна из множества мелких деталей, поэтому хорошая производительность результирующего ассемблерного кода в V8 - результат не только правильного алгоритма, но и все-таки определенной доли везения. В следующей части посмотрим, как можно использовать вместо SWAR-хинта нативные SIMD-инструкции в WebAssembly, и сильно ли это поможет конкретно нам. Материалы: MDN: Таблицы WebAssembly Лайфхак: как спарсить гигабайт double-ов в секунду i32.neg and i32.not operations Liftoff: a new baseline compiler for WebAssembly in V8 Архитектура ИС: PostgreSQL, Node.js и highload