Сегодня мы померяемся парсерами. Точнее, померяем эффективность разных вариантов JavaScript-парсеров на примере одной простой задачи преобразования строки конкретного формата в объект.

Вопросы сложностей организации многопоточности в JavaScript оставим за рамками этой статьи и сосредоточимся на различных вариантах и малоизвестных способах использования регулярных выражений для разбора строки buffers-атрибута узла плана PostgreSQL.
То есть из строки вида
Выглядит вроде все тривиально, правда же?

Откуда вообще возникла такая задача — разбирать строки как можно быстрее?
Я уже рассказывал, что у нас в «Тензоре» используется много сотен серверов PostgreSQL. И чтобы приглядывать за актуальной производительностью запросов на них, мы разработали коллектор-анализатор логов этой СУБД, который «выцепляет» из потока от сервера планы запросов, разбирает их и вычисляет нагрузку для каждого отдельного узла, что не так уж и просто.
То есть надо уметь «сидеть на потоке» и быстро-быстро анализировать (а потому — иметь максимальную производительность и минимальный прирост памяти) примерно вот такие блоки текста, а среди них — и наши buffers-строки:
В общем случае, формат описан в исходниках PostgreSQL. Если представить его в виде JS-кода, то получится что-то вроде:
Но поскольку парсить пробелы неинтересно совсем, давайте сразу договоримся, что экспериментировать мы будем на уже нормализованном наборе, где они убраны:
Чтобы исключить возможное влияние GC, запускать наши тесты будем с ключами
Шаблон нашего теста будет выглядеть примерно так:
Что ж… Приступим, и постараемся грамотно использовать все возможности, которые дают нам современные версии языка.

И начнем с самого простого.
Итак, сделаем выводы из предыдущего теста: .slice и .split нам не друзья, как и динамическая генерация имен ключей.
С именами ключей все понятно — давайте сгенерируем их все заранее, их же всего 10 вариантов. А вот
Оказывается, можно, если использовать принудительную установку
На этот раз будем искать в строке только те ключевые слова, которые нас интересуют:
Но ни в одном из предыдущих вариантов мы никак не использовали информацию о том, что наши потенциальные ключи идут в строго определенном порядке.
Чтобы не заниматься «двухуровневым» спуском по словарю за именами ключей, построим такойстрашный RegExp, каждая захватываемая позиция которого всегда соответствует одному и тому же имени ключа. Позиции отсутствующих в строке ключей будут заполнены в
Наблюдательный читатель сразу же задаст вопрос — разве не будет быстрее, если убрать из регулярки константное начало
Ведь результат от этого не должен никак измениться? Но — нет, такой вариант на четверть хуже:
Дело в том, что наш «полный» RegExp
В предыдущем варианте мы все-таки внесли начало строки в регулярку, и оно проверяется каждый раз! Давайте же воспользуемся методом с

То есть наш странный гибрид по памяти никакого выигрыша не дал, а по скорости проиграл обоим своим родителям.
В сегодняшнем забеге кубок вручается обычному полнопозиционному
Вариант из серии «и никогда так не делайте!»

Вопросы сложностей организации многопоточности в JavaScript оставим за рамками этой статьи и сосредоточимся на различных вариантах и малоизвестных способах использования регулярных выражений для разбора строки buffers-атрибута узла плана PostgreSQL.
То есть из строки вида
'Buffers: shared hit=123 read=456, local hit=789' мы хотим как можно быстрее получить JSON такого формата:{ "shared-hit" : 123 , "shared-read" : 456 , "local-hit" : 789 }
Выглядит вроде все тривиально, правда же?

Немного предыстории
Откуда вообще возникла такая задача — разбирать строки как можно быстрее?
Я уже рассказывал, что у нас в «Тензоре» используется много сотен серверов PostgreSQL. И чтобы приглядывать за актуальной производительностью запросов на них, мы разработали коллектор-анализатор логов этой СУБД, который «выцепляет» из потока от сервера планы запросов, разбирает их и вычисляет нагрузку для каждого отдельного узла, что не так уж и просто.
То есть надо уметь «сидеть на потоке» и быстро-быстро анализировать (а потому — иметь максимальную производительность и минимальный прирост памяти) примерно вот такие блоки текста, а среди них — и наши buffers-строки:
Hash Left Join (actual time=9.248..51.659 rows=551 loops=1) Hash Cond: (c.reloftype = t.oid) Buffers: shared hit=5814 read=251 dirtied=63 -> Hash Join (actual time=2.990..7.148 rows=551 loops=1) Hash Cond: (c.relnamespace = nc.oid) Buffers: shared hit=4249 read=2 -> Seq Scan on pg_class c (actual time=0.046..3.922 rows=555 loops=1) Filter: ((relkind = ANY ('{r,v,f,p}'::"char"[])) AND (pg_has_role(relowner, 'USAGE'::text) OR has_table_privilege(oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text))) Rows Removed by Filter: 3308 Buffers: shared hit=1829 -> Hash (actual time=2.931..2.931 rows=7 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 9kB Buffers: shared hit=2420 read=2 -> Seq Scan on pg_namespace nc (actual time=0.035..2.912 rows=7 loops=1) Filter: (NOT pg_is_other_temp_schema(oid)) Rows Removed by Filter: 784 Buffers: shared hit=2420 read=2 -> Hash (actual time=6.199..6.199 rows=1629 loops=1) Buckets: 2048 Batches: 1 Memory Usage: 277kB Buffers: shared hit=105 read=162 dirtied=63 -> Hash Join (actual time=0.338..5.640 rows=1629 loops=1) Hash Cond: (t.typnamespace = nt.oid) Buffers: shared hit=105 read=162 dirtied=63 -> Seq Scan on pg_type t (actual time=0.015..4.910 rows=1629 loops=1) Buffers: shared hit=57 read=162 dirtied=63 -> Hash (actual time=0.307..0.307 rows=791 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 86kB Buffers: shared hit=48 -> Seq Scan on pg_namespace nt (actual time=0.004..0.121 rows=791 loops=1) Buffers: shared hit=48
Формат строки
В общем случае, формат описан в исходниках PostgreSQL. Если представить его в виде JS-кода, то получится что-то вроде:
const keys = [ ['shared', ['hit', 'read', 'dirtied', 'written']] , ['local', ['hit', 'read', 'dirtied', 'written']] , ['temp', ['read', 'written']] // да, тут другой набор ключей 2-го уровня ]; let str = 'Buffers: ' + // константное начало keys .filter(([keyo, keysi]) => node[keyo]) .map(([keyo, keysi]) => [ keyo , ...keysi .filter(keyi => node[keyo][keyi] > 0) .map(keyi => `${keyi}=${node[keyo][keyi]}`) ].join(' ') // внутри собираем сегменты через пробел ) .join(', '); // снаружи - через запятая-пробел
Методика тестирования
Но поскольку парсить пробелы неинтересно совсем, давайте сразу договоримся, что экспериментировать мы будем на уже нормализованном наборе, где они убраны:
Buffers: shared hit=31770 Buffers: shared hit=1159 Buffers: shared hit=255 Buffers: shared hit=2579 read=2961 dirtied=3 Buffers: shared hit=3 read=1 Buffers: shared hit=205 read=44 Buffers: shared hit=230 read=34 dirtied=3 Buffers: shared hit=13 Buffers: shared hit=5 Buffers: shared hit=6 ...
Чтобы исключить возможное влияние GC, запускать наши тесты будем с ключами
--expose-gc --initial-old-space-size=1024. Оцениваем всех участников по двум показателям: общее время работы и прирост объема памяти, который пришлось использовать (и на чистку которого потом придется потратить время GC и ресурсы CPU).Шаблон нашего теста будет выглядеть примерно так:
const fs = require('fs'); const heapdump = require('heapdump'); const buffers = fs.readFileSync('buffers.txt').toString().split('\n'); const parseBuffers = line => { // -- 8< -- // ... // -- 8< -- }; global.gc(); // нулевое состояние до теста heapdump.writeSnapshot(); const hrb = process.hrtime(); for (let line of buffers) { let obj = parseBuffers(line); } const hre = process.hrtime(hrb); // состояние памяти после теста heapdump.writeSnapshot(); const usec = hre[0] * 1e+9 + hre[1]; console.log(usec);
Что ж… Приступим, и постараемся грамотно использовать все возможности, которые дают нам современные версии языка.

И начнем с самого простого.
Бронза: обыкновенный .split
const parseBuffers = line => { let rv = {}; line.slice('Buffers: '.length) // "константное" начало нас не интересует .split(', ').forEach(part => { // 'shared ..., local ..., temp ...' => ['shared ...', 'local ...', 'temp ...'] let [kind, ...pairs] = part.split(' '); // 'shared hit=1 read=2' => ['shared', ['hit=1', 'read=2']] pairs.forEach(pair => { let [type, val] = pair.split('='); // 'hit=1' => ['hit', '1'] rv[`${kind}-${type}`] = Number(val); // ['shared-hit'] = 1 }); }); return rv; };
Time, avg: 544ms Size Delta: +14.8MB: - (sliced string) : +6.8 // сегменты строк без 'Buffers: ' - (string) : +6.3 // строки имен ключей - (array) : +1.7 // массивы pairs
Серебро: .lastIndex + итерация по .matchAll(RegExp)
Итак, сделаем выводы из предыдущего теста: .slice и .split нам не друзья, как и динамическая генерация имен ключей.
С именами ключей все понятно — давайте сгенерируем их все заранее, их же всего 10 вариантов. А вот
.slice строки мы использовали, только лишь чтобы каждый раз «сдвинуть» начало анализа на одинаковое начало 'Buffers: '. А нельзя ли как-то сделать это без порождения новых строк?Оказывается, можно, если использовать принудительную установку
re.lastIndex для «глобального» RegExp.Подробнее проg-иy-ключи и использ��вание.lastIndexдля более точного применения RegExp.
На этот раз будем искать в строке только те ключевые слова, которые нас интересуют:
const buffersRE = /(shared|local|temp)|(hit|read|dirtied|written)=(\d+)/g; const buffersKeys = { 'shared' : { 'hit' : 'shared-hit' , 'read' : 'shared-read' , 'dirtied' : 'shared-dirtied' , 'written' : 'shared-written' } , 'local' : { 'hit' : 'local-hit' , 'read' : 'local-read' , 'dirtied' : 'local-dirtied' , 'written' : 'local-written' } , 'temp' : { 'read' : 'temp-read' , 'written' : 'temp-written' } }; const parseBuffers = line => { let rv = {}; let keys; buffersRE.lastIndex = 9; // сдвигаем начало поиска на 'Buffers: '.length for (let match of line.matchAll(buffersRE)) { if (match[1]) { keys = buffersKeys[match[1]]; } else { rv[keys[match[2]]] = Number(match[3]); } } return rv; };
Time, avg: 270ms Size Delta: +8.5MB
Золото: полнопозиционный .match(RegExp)
Но ни в одном из предыдущих вариантов мы никак не использовали информацию о том, что наши потенциальные ключи идут в строго определенном порядке.
Чтобы не заниматься «двухуровневым» спуском по словарю за именами ключей, построим такой
match-массиве undefined:const buffersRE = /^Buffers:(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/; const buffersKeys = ['shared-hit', 'shared-read', 'shared-dirtied', 'shared-written', 'local-hit', 'local-read', 'local-dirtied', 'local-written', 'temp-read', 'temp-written']; const parseBuffers = line => line.match(buffersRE) .slice(1) // в match[0] лежит исходная строка, которая нам не нужна .reduce( (rv, val, idx) => (val !== undefined && (rv[buffersKeys[idx]] = Number(val)), rv) , {} );
Time, avg: 111ms Size Delta: +8.5MB
Наблюдательный читатель сразу же задаст вопрос — разве не будет быстрее, если убрать из регулярки константное начало
'^Buffers:':const buffersRE = /(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/;
Ведь результат от этого не должен никак измениться? Но — нет, такой вариант на четверть хуже:
Time, avg: 140ms
Дело в том, что наш «полный» RegExp
/^...$/ не содержит ни одной переменной части, а в случае «без начала» для каждой позиции этого сегмента приходится проверять, не начинается ли тут один из «хвостов» (shared ...|local ...|temp ...) — что требует гораздо больше ресурсов, чем просто впустую проверить совпадение двух подстрок.Вне конкурса: скрещиваем ужа и ежа
В предыдущем варианте мы все-таки внесли начало строки в регулярку, и оно проверяется каждый раз! Давайте же воспользуемся методом с
.lastIndex:- но он работает только с «глобальными» RegExp
- для «глобального» RegExp обычный
.matchзахватывает сразу всю строку, а не позиционно - для получения позиционного набора нам придется использовать первый (а на самом деле, единственный) результат итератора
.matchAll

const buffersRE = /(?:,? shared(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? local(?: hit=(\d+))?(?: read=(\d+))?(?: dirtied=(\d+))?(?: written=(\d+))?)?(?:,? temp(?: read=(\d+))?(?: written=(\d+))?)?$/g; const buffersKeys = ['shared-hit', 'shared-read', 'shared-dirtied', 'shared-written', 'local-hit', 'local-read', 'local-dirtied', 'local-written', 'temp-read', 'temp-written']; const parseBuffers = line => { buffersRE.lastIndex = 8; // 'Buffers:'.length return line.matchAll(buffersRE).next().value .slice(1) .reduce( (rv, val, idx) => (val !== undefined && (rv[buffersKeys[idx]] = Number(val)), rv) , {} ); };
Time, avg: 304ms Size Delta: +8.5MB
То есть наш странный гибрид по памяти никакого выигрыша не дал, а по скорости проиграл обоим своим родителям.
Итого
В сегодняшнем забеге кубок вручается обычному полнопозиционному
.match(RegExp). Ура, товарищи!![]() |
![]() |
UPD: 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; for (let off = 9 /*'Buffers: '*/, ln = line.length; off < ln; ) { if (state === undefined) { switch (line.charCodeAt(off)) { case 0x73: // s = shared state = 0; off += 7; break; case 0x6c: // l = local state = 4; off += 6; break; case 0x74: // t = temp state = 8; off += 5; break; } } else { let key; switch (line.charCodeAt(off)) { case 0x68: // h = hit key = 0; off += 4; break; case 0x72: // r = read key = 1; off += 5; break; case 0x64: // d = dirtied key = 2; off += 8; break; case 0x77: // w = written key = 3; off += 8; break; } let sp = line.indexOf(' ', off); let eos = sp == -1 || line.charCodeAt(sp - 1) == 0x2c; // ',' // let val = Number(line.slice(off, sp == -1 ? undefined : sp - (eos ? 1 : 0))); let val = 0; for (let i = off, lim = sp == -1 ? line.length : sp - (eos ? 1 : 0); i< lim; i++) { val *= 10; val += line.charCodeAt(i) - 0x30; // '0' } rv[buffersKeys[state + key]] = val; if (sp == -1) { break; } else { off = sp + 1; } if (eos) { state = undefined; } } } return rv; };
Time, avg: 58ms


