Оптимизация конкатенации строк JS

Недавно я реализовал простой регистратор для библиотеки JS, пример выглядит следующим образом:

class Logger {
  static level: 'error' | 'info' = 'error';

  static error(message: string) {
    if (this.level === 'error') {
      console.error(message);
    }
  }

  static info(message: string) {
    if (this.level === 'info') {
      console.info(message);
    }
  }
}

function app() {
  Logger.level = 'info';

  Logger.error('Error happened at ' + new Date());
  Logger.info('App started at ' + new Date());
}

Это работает, однако я заметил, что некоторые из моих коллег, реализующих одну и ту же функциональность в Java и C#, используют лямбды (или обратные вызовы) для передачи сообщения в регистратор, для них регистратор выглядит следующим образом:

class Logger {
  static level: 'error' | 'info' = 'error';

  static error(message: () => string) {
    if (this.level === 'error') {
      console.error(message());
    }
  }

  static info(message: () => string) {
    if (this.level === 'info') {
      console.info(message());
    }
  }
}

function app() {
  Logger.level = 'info';

  Logger.error(() => 'Error happened at ' + new Date());
  Logger.info(() => 'App started at ' + new Date());
}

Аргументом в пользу второй реализации является то, что в данный момент активен только один уровень журнала, поэтому, если error включен, метод info никогда не будет вызывать message(), поэтому строка не будет объединена, что позволит более оптимизировать код.

Это имеет смысл, но после многих попыток тестирования я не смог найти никакой разницы во времени выполнения и использовании памяти. Я пробовал действительно сложные конкатенации с несколькими десятками + как для обратных вызовов, так и для обычных конкатенов, но числа кажутся одинаковыми.

У меня такое ощущение, что компиляторы современных браузеров хорошо оптимизируют условную конкатенацию, но я не смог найти никаких убедительных доказательств после просмотра AST Explorer и Compiler Explorer.

Вопрос: Кто-нибудь знает, имеет ли такая оптимизация смысл для современного JS и если да, то есть ли какие-нибудь действительные ссылки на то, как проверить на практике?

🤔 А знаете ли вы, что...
JavaScript можно использовать для создания видеоигр, как 2D, так и 3D, с использованием библиотеки Three.js.


1
101
1

Ответ:

Решено

Я протестировал ваши две версии и заменил тело функции app на

  for (let i = 0; i < 200000; i++) {
    Logger.error('Error happened at ' + new Date());
    Logger.info('App started at ' + new Date());
  }

(или то же самое с лямбдами) И я удалил console.info (при этом обязательно вызывая message() в версии с лямбда-выражениями).

Версия с лямбдами занимает около 0,5 секунды, а без лямбд — около 1 секунды.

Итак, похоже, что лямбды действительно помогают. Однако стоимость выполнения console.info превышает стоимость объединения строк и new Date(). Действительно, если я снова добавлю console.info, обе версии будут иметь одинаковую производительность. Если ваши отпечатки обычно не выполняются (например, если ожидается, что level будет info в производстве, но ваш код содержит много Logger.error), то использование Lambdas действительно ускоряет работу.
Я сравнил версию вашего кода, в которой основной цикл содержит только Logger.info, а Logger.level — это error (==> ничего не регистрируется), а версия с лямбда-выражениями примерно в 15 раз быстрее, чем версия без лямбда-выражений. Основные затраты связаны с выполнением new Date(), поскольку он вызывает версию и выделяет память.

Однако лучше всего, вероятно, поместить new Date() внутри error и info помощников. Конкатенация строк в V8 чрезвычайно дешева, потому что она использует «строки минусов»: когда вы делаете "aaaaaaaaa" + "bbbbbbbbb", V8 на самом деле не создает строку "aaaaaaaaabbbbbbbbb", а вместо этого создает объект ConsString, который содержит 2 указателя: один на "aaaaaaaaa" и один на "bbbbbbbbb". Только когда вы пытаетесь прочитать строку (что происходит при ее печати), V8 сворачивает ее в обычную последовательную строку.


Тем не менее, поскольку вы пометили вопрос тегом «компиляция», вот несколько объяснений того, как V8 теоретически должен иметь возможность обеспечить одинаковую скорость обеих версий (с лямбда-выражением и без него) благодаря сочетанию встраивания, постоянное сворачивание, устранение мертвого кода, а также спекулятивная оптимизация и деоптимизация. Вот как это может работать.

Встраивание. Маленькие функции почти всегда встроены в Turbofan (компилятор верхнего уровня V8). Более крупные функции также встраиваются, если у нас достаточно встраивания бюджета. Итак, в вашей функции app:

Logger.error('Error happened at ' + new Date());
Logger.info('App started at ' + new Date());

будет встроен. На данный момент app содержит более или менее:

if (this.level === 'error') {
    console.error('Error happened at ' + new Date());
}
if (this.level === 'info') {
    console.info('App started at ' + new Date());
}

Тогда могут произойти две разные вещи:

Постоянное складывание. Turbofan может определить, что this.level является константой (ему было присвоено заданное значение и до сих пор ни разу не менялось), а затем заменить this.level его значением, что превратило бы проверки в <some value> === 'error' и <some value> === 'info'. Затем Turbofan оценит эти проверки во время компиляции, заменив их на true или false. Затем Turbofan поймет, что тело некоторых из этих IF недоступно, и удалит их (это называется устранением мертвого кода). Однако на практике 'Error happened at ' + new Date() раньше был аргументом функции и поэтому вычисляется перед телом IF; код действительно больше похож

let message = 'Error happened at ' + new Date();
if (this.level === 'error') {
    console.info(message);
}

Таким образом, удаление мертвого кода в основном удалит console.info(message), а затем может удалить конкатенацию строк (поскольку ее результат не используется), но не удалит new Date(), потому что оно недостаточно умно, чтобы понять, что вызов new Date() не имеет побочных эффектов. .

Обратите внимание, что свертывание констант в этом случае было бы спекулятивным: если бы this.level изменилось позже, V8 выбросил бы код, сгенерированный Turbofan (поскольку теперь он был бы неверным), и перекомпилировал бы.

Если Turbofan не может спекулировать на значении this.value (например, потому что оно не является постоянным или потому что вы вызывали console.error для разных Logger объектов), V8 все равно сможет оптимизировать ваш код.

В частности, Turbofan не генерирует ассемблерный код для JS-кода, по которому у него нет обратной связи. Вместо этого он генерирует деоптимизации — специальные инструкции, которые переключаются обратно на интерпретатор. Итак (все еще после встраивания), если каждый раз, когда вы выполняли функцию, this.level было info, Turbofan сгенерирует что-то вроде

if (this.level === 'error') {
    Deoptimize();
}
if (this.level === 'info') {
    console.info('App started at ' + new Date());
}

В результате 'Error happened at ' + new Date() вообще больше не присутствует на графике и, таким образом, является «бесплатным». Если вы когда-нибудь измените this.level и нажмете на этот случай, инструкция Deoptimize() позаботится о возврате к интерпретатору байт-кода, чтобы выполнить console.error('Error happened at ' + new Date());. На практике это снова не так просто, потому что 'Error happened at ' + new Date() изначально вычисляется перед if, а не внутри. И поэтому график больше похож на

let message = 'Error happened at ' + new Date();
if (this.level === 'error') {
    Deoptimize(message)
}

(Деоптимизация всегда принимает некоторые входные данные, описывающие значения, которые в данный момент присутствуют в функции).

На этом этапе движение кода все еще может вам помочь и переместить let message = 'Error happened at ' + new Date(); внутрь if, чтобы он не вычислялся, когда он вам не нужен. Однако на практике движение кода в Turbofan не является чрезвычайно мощным, и этого не произойдет, потому что new Date() выглядит слишком непрозрачно для Turbofan.


И последнее замечание: конкатенация константных строк обычно выполняется во время компиляции с помощью Turbofan. Это, конечно, не относится к + Date(), но если вы попытаетесь провести бенчмаркинг "hello " + "world", то это будет похоже на бенчмаркинг "hello world".