На примере простой задачи клонирования ключей объекта посмотрим, есть ли реальные альтернативы по производительности столь презираемой JavaScript-разработчиками функции eval().

Подобная задача возникает, если оригинальное значение ключа надо оставить у объекта, а как-то обработанное - положить рядом в новый соответствующий ключ. То есть, для начала, из {"a" : 1, "b" : 2} надо получить {"a" : 1, "a-copy" : 1, "b" : 2, "b-copy" : 2}.

Пару лет назад я уже рассказывал, почему максимальная производительность подобных операций на JavaScript важна для нашего сервиса потокового анализа логов PostgreSQL, как можно поускорять парсинг с помощью WebAssembly, и вот сегодня - продолжение.


Сначала договоримся, что объекты у нас не совсем произвольные, а имеют вполне конкретные интересные нам ключи 'a'..'h' и 'mask' - битовую маску, сигнализирующую их присутствие в объекте. То есть имеют вид вроде:

{
  "mask" : 13 // a:1 + c:4 + d:8
, "a" : 123
, "c" : 321
, "d" : 222
}

#0 : фиксируем тестовый набор

Чтобы все наши тесты проходили на едином наборе объектов, сгенерируем его и сохраним в файл:

const fs = require('node:fs');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', ... , 'h']
const size = 1 << 16; // количество объектов в тесте - 64K

const objs = Array(size).fill().map(_ => {
  const mask = Math.random() * (1 << keys.length) | 0; // формируем и сохраняем маску ключей объекта
  const obj = {mask};

  for (let i = 0; i < keys.length; i++) {
    if (mask & (1 << i)) {
      obj[keys[i]] = Math.random() * (1 << 16) | 0;    // заполняем ключи согласно маске
    }
  }
  return obj;
});

// сохраняем массив в файл
fs.writeFileSync('./objs-64k.json', JSON.stringify(objs));

#1 : "как слышится, так и пишется"

Чтобы оттолкнуться хоть от какого-то значения производительности, напишем самый "примитивный" вариант кода, который просто перебирает все ключи объекта и создает новые для интересующих нас:

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const ts = hrtime();
for (const obj of objs) {
  for (const [key, val] of Object.entries(obj)) {
    if (keys.includes(key)) {
      obj[`${key}-copy`] = val;
    }
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 131ms

Этот код для наших 64K объектов показывает время порядка 131ms.

#2 : замена по матрице

В коде выше, как минимум, две нехорошие с точки зрения производительности вещи:

  • includes проверяет наличие ключа в массиве за линейное время, в худшем случае, полностью перебирая весь массив;

  • `${key}-copy` создает новую строку каждый раз.

Исправим это, заменив includes на in-проверку наличия ключа в объекте, а формирование строки - ее извлечением из этого же объекта:

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const repl = Object.fromEntries(keys.map(key => [key, `${key}-copy`])); // copy-ключи

const ts = hrtime();
for (const obj of objs) {
  for (const [key, val] of Object.entries(obj)) {
    if (key in repl) {
      obj[repl[key]] = val;
    }
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 104ms

Такой код уже выполняется на 20% быстрее! Но мы все равно тут обращаемся в repl-объект дважды. Исправим и это:

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const repl = Object.fromEntries(keys.map(key => [key, `${key}-copy`])); // copy-ключи

const ts = hrtime();
for (const obj of objs) {
  for (const [key, val] of Object.entries(obj)) {
    const copy = repl[key];
    if (copy) {
      obj[copy] = val;
    }
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 102ms

Мелочь - а приятно!

#3 : битовая маска

Но во всех предыдущих вариантах мы никак не использовали тот факт, что нам заранее известно, какие ключи есть в объекте, и перебирали и фильтровали их все. А давайте будем перебирать только нужные, примерно как использовали это при генерации?

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const repl = Object.fromEntries(keys.map(key => [key, `${key}-copy`])); // copy-ключи

const ts = hrtime();
const ln = keys.length;
for (const obj of objs) {
  const mask = obj['mask'];
  for (let i = 0; i < ln; i++) {
    if (mask & (1 << i)) {
      const key = keys[i];
      const copy = repl[key];
      obj[copy] = obj[key];
    }
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 44ms

Почти в 2.5 раза быстрее!

Но зачем перебирать все биты маски, если мы точно знаем, что оставшиеся старшие - нулевые? Можно и не перебирать:

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const repl = Object.fromEntries(keys.map(key => [key, `${key}-copy`])); // copy-ключи

const ts = hrtime();
for (const obj of objs) {
  for (let i = 0, mask = obj['mask']; mask; i++, mask >>= 1) {
    if (mask & 1) {
      const key = keys[i];
      const copy = repl[key];
      obj[copy] = obj[key];
    }
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 42ms

Выиграли копейку, но она - рубль бережет!

#4 : наборы-по-маске

Теперь обратим внимание, что сам перечень ключей каждого объекта мы все равно "перебираем" - а давайте не будем? Для этого всего-то надо нужный набор ключей и их замен сгенерировать для каждой маски заранее (их всего-то 2 ^ nKeys):

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const copy = keys.map(key => ({key, copy : `${key}-copy`})); // copy-ключи

const maskKeys = Array(1 << keys.length).fill() // наборы ключей каждой маски
  .map((_, mask) => {
    const res = [];
    for (let i = 0; mask; i++, mask >>= 1) {
      if (mask & 1) {
        res.push(copy[i]);
      }
    }
    return res;
  });

const ts = hrtime();
for (const obj of objs) {
  for (const {key, copy} of maskKeys[obj['mask']]) {
    obj[copy] = obj[key];
  }
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 36ms

Однако, еще на 15% эффективнее!

#5 : наконец, eval

Очевидно, что раз уж мы заранее знаем по каждой маске, какие ключи должны быть скопированы, то почему бы не превратить это в готовую функцию?

const objs = require('./objs-64k.json');

const keys = Array(8).fill()
  .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']

const hrtime = process.hrtime.bigint;

const copy = keys.map(key => `obj['${key}-copy'] = obj['${key}']`); // copy-сегменты

const maskFunc = Array(1 << keys.length).fill() // copy-функции для каждой маски
  .map((_, mask) => {
    const res = [];
    for (let i = 0; mask; i++, mask >>= 1) {
      if (mask & 1) {
        res.push(copy[i]);
      }
    }
    return (0, eval)(`obj => {` + res.join(',') + `}`);
  });

const ts = hrtime();
for (const obj of objs) {
  maskFunc[obj['mask']](obj);
}
console.log(Number(hrtime() - ts)/1e6, 'ms');
// 42ms

Ой... внезапно откатились к предыдущему результату:

-- 64K
for[mask] = 36ms
eval      = 42ms +16%

Давайте резюмируем, что у нас получилось на выборке 64K объектов - лучшим оказался for-of по предрассчитанному набору ключей для каждой маски:

Время клонирования ключей для 64K объектов
Время клонирования ключей для 64K объектов

Но так ли плох eval?

Может, у нас движок V8 не успел "прогреть" каждую из функций? Попробуем тест для миллиона объектов:

-- 1M
for[mask] = 418ms
eval      = 434ms +4%

Нет, это не случайность, и банальный перебор объектов все равно быстрее. Но уже не на 16%, а всего на 4%! Ну-ка, ну-ка...

-- 4M
for[mask] = 1702ms
eval      = 1241ms -27%

Промеряем для разного количества объектов:

Разница между eval и for-of на разном количестве объектов
Разница между eval и for-of на разном количестве объектов

А eval-то все-таки оказывается существенно быстрее, если их достаточно много!

Очевидно, что данные для 1M на данном графике представляют некоторую аномалию, и вместо +4% должно было получиться -10%, но отложим пока этот вопрос.

И, если следовать графику, то в нашем случае для комбинаций из 8 ключей, начиная примерно с 200K объектов, eval - лучший выбор!

Комментарии (12)


  1. mobi
    19.01.2023 15:59

    А если вместо eval сгенерировать js-файл, в котором экспортируется массив с объявлением всех 256 вариантов функций? Все-таки eval в чистом виде — вещь довольно спорная.


    1. Kilor Автор
      19.01.2023 16:01

      А в чем для транслятора должна быть принципиальная разница между текстом функции из файла и текстом, сгенерированным в рантайме?


      1. mobi
        19.01.2023 16:17
        -2

        В том-то и дело, что для транслятора — никакой. А вот если CI из соображений безопасности не пропускает пуши с eval — то разница существенная.


        1. Kilor Автор
          19.01.2023 16:25

          "Не пропускает пуши с eval" - это такая же паранойя, как и "goto - зло". То есть грамотный разработчик хоть на лету файл сгенерирует и сделает require, но это вынуждает его искать лишние лазейки.

          Да, "тривиально" запушить eval должно быть сложно для большинства разработчиков, но для "грамотных" должна быть возможность сделать именно это, а не заниматься прокладкой бэкдоров.


  1. iliazeus
    19.01.2023 16:21

    Почему именно eval, а не конструктор Function? MDN, например, обещает для него лучшую производительность, чем для простого eval.

    (Здесь ниже был еще один вопрос, но я внимательней почитал код и все понял. Возможно, это знак, что код с eval менее читаемый :)


    1. Kilor Автор
      19.01.2023 16:32
      +1

      return new Function('obj', res.join(','));

      У меня получились ровно те же результаты, что и для eval.


      1. Alexandroppolus
        19.01.2023 18:21
        +2

        Зато он не "evil" )

        eval имеет дурную репутацию прежде всего из-за доступа к тому скоупу, где запускается.


        1. Kilor Автор
          19.01.2023 19:14
          +2

          Ну, в случае indirect call он доступа туда не имеет.


  1. mark_ablov
    20.01.2023 03:44

    Думается, что еще быстрее будет вариант с switch на 2**x вариантов. Он в итоге может транслироваться в простую jump таблицу (не проверял как оно там в v8, но думаю оптимизация достаточно очевидная).


    1. iliazeus
      20.01.2023 12:13

      Кстати, этот самый switch можно тоже генерировать через eval, чтобы не писать руками все варианты. @Kilor, сможете проверить, станет ли быстрее?


      1. mark_ablov
        20.01.2023 12:21
        +1

        Мне стало интересно и я уже проверил - для 1M объектов производительность не улучшается. Занятно то, что если использовать Object.assign() то скорость даже проседает.


      1. Kilor Автор
        20.01.2023 12:36
        +1

        64K - 51ms, 1M - 437ms, 8M - 3458ms - везде не лучший результат

        Полный код
        const objs = require('./objs-64K.json');
        
        const keys = Array(8).fill()
          .map((_, i) => String.fromCharCode('a'.charCodeAt() + i)); // ключи ['a', .., 'h']
        
        const hrtime = process.hrtime.bigint;
        
        const copy = keys.map(key => `obj['${key}-copy'] = obj['${key}']`); // copy-сегменты
        
        const caseText = Array(1 << keys.length).fill() // copy-блоки для каждой маски
          .map((_, mask) => {
            const res = [];
            const orig = mask;
            for (let i = 0; mask; i++, mask >>= 1) {
              if (mask & 1) {
                res.push(copy[i]);
              }
            }
            return orig ? `case ${orig}:` + res.join(',') + `;break;\n` : '';
          })
          .join('');
        const fnCopy = new Function('obj', `switch (obj['mask']) {${caseText}}`)
        
        const ts = hrtime();
        objs.forEach(fnCopy);
        console.log(Number(hrtime() - ts)/1e6, 'ms');