Недавно я реализовал простой регистратор для библиотеки 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 и если да, то есть ли какие-нибудь действительные ссылки на то, как проверить на практике?
@RobbyCornelissen, меня особенно интересует часть конкатенации без каких-либо тяжелых операций внутри нее, так что это просто 'a' + 'b' + ...
против () => 'a' + 'b' + ...
Статическая конкатенация также может быть оптимизирована во время выполнения.
если метод/функция message()
выполняет дорогостоящие вычисления и часто протоколируется, то, возможно, вы заметите разницу. (но этот вопрос вряд ли имеет отношение к Java) В Java "a" + "b"
точно такой же, как "ab"
в скомпилированном коде, только размеры исходных файлов немного отличаются. Выражение "a" + new Date()
немного дороже, но оптимизация необходима только при многократном вызове.
Я согласен, оптимизация от "a" + "b"
до "ab"
наверняка происходит во время компиляции, но мне интересно, приведет ли использование () => "a" + "b"
к отсутствию конкатенации вообще (для уровней журнала, которые не используются)
опять же, в Java (вопрос был отмечен тегом java, иначе он бы мне не был представлен) нет разницы между "a" + "b"
и "ab"
- компилятор видит только более поздний, независимо от того, используется ли он напрямую или как () -> "a" + "b"
- Re: вы сами: "оптимизация точно происходит во время компиляции", но "Интересно... приведет к отсутствию конкатенации" -> нет конкатенации во время выполнения (это, как вы набрали "ab"
, своего рода объединение во время компиляции - Только Java, без понятия о JavaScript)
Я протестировал ваши две версии и заменил тело функции 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) сказали, что невозможно измерить разницу, с прагматической точки зрения решается вопрос: не тратьте время на вещи, которые не имеют значения :-)
«потому что new Date() выглядит слишком непрозрачно для Turbofan». - хм, но для всех встроенных конструкторов теоретически должна быть возможность пометить их как не имеющие побочных эффектов? Не уверен, стоит ли оно того, но здесь можно избежать выделения объектов — а построение дат довольно распространено…
Кроме того, мой самый большой вывод из этого ответа заключается в том, что всегда имеет смысл обернуть console.info в оболочку, которую можно включать и отключать. Поскольку журналы на самом деле нужны редко, и имеет смысл включать их по требованию, а не платить. накладные расходы на постоянную сериализацию сообщений (что должен делать console.info на случай, если Devtools будут открыты в будущем, верно?)
@JonasWilms V8 не имеет простого способа определить, что встроенные функции не имеют побочных эффектов. Итак, да, «new Date()» можно пометить как не имеющую побочных эффектов (но «new Date(arg)» может иметь побочные эффекты), но это придется делать вручную, что чревато ошибками. Кроме того, мы иногда допускаем ошибки в аннотациях побочных эффектов встроенных вызовов, и это довольно сложно обнаружить, но легко приводит к проблемам безопасности/корректности:/ Тем не менее, в настоящее время мы проводим рефакторинг/переосмысление того, как определяются встроенные функции, и в качестве В результате мы вполне могли бы лучше узнать побочные эффекты встроенных вызовов.
Не думайте, что современный JavaScript или сама конкатенация имеют к этому какое-то отношение. Если при оценке сообщения об ошибке необходимо создать
new Date()
, влияние, скорее всего, будет минимальным. Если вы звонитеJSON.stringify(largeObjectGraph)
, это может быть грандиозно.