Русский
Русский
English
Статистика
Реклама

Первый парсер на деревне

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


Вопросы сложностей организации многопоточности в 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=31770Buffers: shared hit=1159Buffers: shared hit=255Buffers: shared hit=2579 read=2961 dirtied=3Buffers: shared hit=3 read=1Buffers: shared hit=205 read=44Buffers: shared hit=230 read=34 dirtied=3Buffers: shared hit=13Buffers: shared hit=5Buffers: 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: 544msSize 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: 270msSize Delta: +8.5MB

Золото: полнопозиционный .match(RegExp)


Но ни в одном из предыдущих вариантов мы никак не использовали информацию о том, что наши потенциальные ключи идут в строго определенном порядке.

Чтобы не заниматься двухуровневым спуском по словарю за именами ключей, построим такой страшный 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: 111msSize 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: 304msSize Delta: +8.5MB

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

Итого


В сегодняшнем забеге кубок вручается обычному полнопозиционному .match(RegExp). Ура, товарищи!
Источник: habr.com
К списку статей
Опубликовано: 24.12.2020 16:21:15
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании тензор

Высокая производительность

Javascript

Регулярные выражения

Node.js

Regexp

Postgresql

Nodejs

Разбор текста

Парсеры

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru