Как стать автором
Поиск
Написать публикацию
Обновить
31.91
Тензор
Разработчик системы Saby

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

Время на прочтение7 мин
Количество просмотров6K
Сегодня мы померяемся парсерами. Точнее, померяем эффективность разных вариантов 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=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)


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

Чтобы не заниматься «двухуровневым» спуском по словарю за именами ключей, построим такой страшный 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
Теги:
Хабы:
Всего голосов 15: ↑15 и ↓0+15
Комментарии19

Публикации

Информация

Сайт
saby.ru
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия