Оптимизация конкатенации строк 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 или сама конкатенация имеют к этому какое-то отношение. Если при оценке сообщения об ошибке необходимо создать new Date(), влияние, скорее всего, будет минимальным. Если вы звоните JSON.stringify(largeObjectGraph), это может быть грандиозно.

Robby Cornelissen 09.07.2024 06:21

@RobbyCornelissen, меня особенно интересует часть конкатенации без каких-либо тяжелых операций внутри нее, так что это просто 'a' + 'b' + ... против () => 'a' + 'b' + ...

Slava.In 09.07.2024 06:39

Статическая конкатенация также может быть оптимизирована во время выполнения.

Robby Cornelissen 09.07.2024 06:45

если метод/функция message() выполняет дорогостоящие вычисления и часто протоколируется, то, возможно, вы заметите разницу. (но этот вопрос вряд ли имеет отношение к Java) В Java "a" + "b" точно такой же, как "ab" в скомпилированном коде, только размеры исходных файлов немного отличаются. Выражение "a" + new Date() немного дороже, но оптимизация необходима только при многократном вызове.

user85421 09.07.2024 08:30

Я согласен, оптимизация от "a" + "b" до "ab" наверняка происходит во время компиляции, но мне интересно, приведет ли использование () => "a" + "b" к отсутствию конкатенации вообще (для уровней журнала, которые не используются)

Slava.In 09.07.2024 09:20

опять же, в Java (вопрос был отмечен тегом java, иначе он бы мне не был представлен) нет разницы между "a" + "b" и "ab" - компилятор видит только более поздний, независимо от того, используется ли он напрямую или как () -> "a" + "b" - Re: вы сами: "оптимизация точно происходит во время компиляции", но "Интересно... приведет к отсутствию конкатенации" -> нет конкатенации во время выполнения (это, как вы набрали "ab", своего рода объединение во время компиляции - Только Java, без понятия о JavaScript)

user85421 10.07.2024 09:37
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
1
6
101
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 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".

+1, особенно абзац «Лучшее, что можно сделать...»: не беспокойтесь о производительности конкатенации строк, это очень дешево (дешевле, чем выделение лямбды!). Кроме того, поскольку вы (@Slava.In) сказали, что невозможно измерить разницу, с прагматической точки зрения решается вопрос: не тратьте время на вещи, которые не имеют значения :-)

jmrk 09.07.2024 10:35

«потому что new Date() выглядит слишком непрозрачно для Turbofan». - хм, но для всех встроенных конструкторов теоретически должна быть возможность пометить их как не имеющие побочных эффектов? Не уверен, стоит ли оно того, но здесь можно избежать выделения объектов — а построение дат довольно распространено…

Jonas Wilms 09.07.2024 12:11

Кроме того, мой самый большой вывод из этого ответа заключается в том, что всегда имеет смысл обернуть console.info в оболочку, которую можно включать и отключать. Поскольку журналы на самом деле нужны редко, и имеет смысл включать их по требованию, а не платить. накладные расходы на постоянную сериализацию сообщений (что должен делать console.info на случай, если Devtools будут открыты в будущем, верно?)

Jonas Wilms 09.07.2024 12:23

@JonasWilms V8 не имеет простого способа определить, что встроенные функции не имеют побочных эффектов. Итак, да, «new Date()» можно пометить как не имеющую побочных эффектов (но «new Date(arg)» может иметь побочные эффекты), но это придется делать вручную, что чревато ошибками. Кроме того, мы иногда допускаем ошибки в аннотациях побочных эффектов встроенных вызовов, и это довольно сложно обнаружить, но легко приводит к проблемам безопасности/корректности:/ Тем не менее, в настоящее время мы проводим рефакторинг/переосмысление того, как определяются встроенные функции, и в качестве В результате мы вполне могли бы лучше узнать побочные эффекты встроенных вызовов.

Dada 10.07.2024 08:17

Другие вопросы по теме