В этой статье я поместил бенчмарки наиболее частных вызовов логгеров. Все эксперименты я проводил над log4net и NLog, на Windows 10 x64 Intel с M.2 SSD.
Сырые результаты можно посмотреть на GitHub. В том же репозитории код (для запуска потребуется .Net 4.7.2 + Microsoft Visual Studio 2017+).
Что, как и почему — под катом.
Чтобы долго не читать, таблица результатов:
Method | Mean | Error | StdDev |
---|---|---|---|
KeepFileOpen=true, ConcurrentWrites=false, Async=true | 1,144.677 ns | 26.3805 ns | 77.7835 ns |
KeepFileOpen=true, ConcurrentWrites=true, Async=true | 1,106.691 ns | 31.4041 ns | 87.5421 ns |
KeepFileOpen=false, ConcurrentWrites=false, Async=true | 4,804.426 ns | 110.3406 ns | 103.2126 ns |
KeepFileOpen=false, ConcurrentWrites=true, Async=true | 5,303.602 ns | 104.3022 ns | 102.4387 ns |
KeepFileOpen=true, ConcurrentWrites=false, Async=false | 5,642.301 ns | 73.2291 ns | 68.4986 ns |
KeepFileOpen=true, ConcurrentWrites=true, Async=false | 11,834.892 ns | 82.7578 ns | 77.4117 ns |
KeepFileOpen=false, ConcurrentWrites=false, Async=false | 731,250.539 ns | 14,612.0117 ns | 27,444.8998 ns |
KeepFileOpen=false, ConcurrentWrites=true, Async=false | 730,271.927 ns | 11,330.0172 ns | 10,598.1051 ns |
CreateLog4NetFromString | 1,470.662 ns | 19.9492 ns | 18.6605 ns |
CreateNLogFromString | 228.774 ns | 2.1315 ns | 1.8895 ns |
CreateLog4NetLogger | 21,046.294 ns | 284.1171 ns | 265.7633 ns |
CreateNLogTypeOfLogger | 164,487.931 ns | 3,240.4372 ns | 3,031.1070 ns |
CreateNLogDynamicLogger | 134,459.092 ns | 1,882.8663 ns | 1,761.2344 ns |
FileLoggingLog4NetNoParams | 8,251.032 ns | 109.3075 ns | 102.2463 ns |
FileLoggingLog4NetSingleReferenceParam | 8,260.452 ns | 145.9028 ns | 136.4776 ns |
FileLoggingLog4NetSingleValueParam | 8,378.693 ns | 121.3003 ns | 113.4643 ns |
FileLoggingLog4NetMultipleReferencesParam | 9,133.136 ns | 89.7420 ns | 79.5539 ns |
FileLoggingLog4NetMultipleValuesParam | 9,393.989 ns | 166.0347 ns | 155.3089 ns |
FileLoggingNLogNetNoParams | 6,061.837 ns | 69.5666 ns | 65.0726 ns |
FileLoggingNLogNetSingleReferenceParam | 6,458.201 ns | 94.5617 ns | 88.4530 ns |
FileLoggingNLogNetSingleValueParam | 6,460.859 ns | 95.5435 ns | 84.6969 ns |
FileLoggingNLogNetMultipleReferencesParam | 7,236.886 ns | 89.7334 ns | 83.9367 ns |
FileLoggingNLogNetMultipleValuesParam | 7,524.876 ns | 82.8979 ns | 77.5427 ns |
NoOpLog4NetNoParams | 12.684 ns | 0.0795 ns | 0.0743 ns |
NoOpLog4NetSingleReferenceParam | 10.506 ns | 0.0571 ns | 0.0506 ns |
NoOpLog4NetSingleValueParam | 12.608 ns | 0.1012 ns | 0.0946 ns |
NoOpLog4NetMultipleReferencesParam | 48.858 ns | 0.3988 ns | 0.3730 ns |
NoOpLog4NetMultipleValuesParam | 69.463 ns | 0.9444 ns | 0.8834 ns |
NoOpNLogNetNoParams | 2.073 ns | 0.0253 ns | 0.0225 ns |
NoOpNLogNetSingleReferenceParam | 2.625 ns | 0.0364 ns | 0.0340 ns |
NoOpNLogNetSingleValueParam | 2.281 ns | 0.0222 ns | 0.0208 ns |
NoOpNLogNetMultipleReferencesParam | 41.525 ns | 0.4481 ns | 0.4191 ns |
NoOpNLogNetMultipleValuesParam | 57.622 ns | 0.5341 ns | 0.4996 ns |
NoOpLogging
Сперва оценим, сколько мы тратим времени на то, чтобы вызвать метод для логгирования, который в итоге ни к чему не приведет. В большинстве случаев (по моему опыту) на боевых серверах отключен многословный Debug, однако вызовы никто не убирает.
Сперва результат:
Method | Mean | Error | StdDev |
---|---|---|---|
NoOpLog4NetNoParams | 12.684 ns | 0.0795 ns | 0.0743 ns |
NoOpLog4NetSingleReferenceParam | 10.506 ns | 0.0571 ns | 0.0506 ns |
NoOpLog4NetSingleValueParam | 12.608 ns | 0.1012 ns | 0.0946 ns |
NoOpLog4NetMultipleReferencesParam | 48.858 ns | 0.3988 ns | 0.3730 ns |
NoOpLog4NetMultipleValuesParam | 69.463 ns | 0.9444 ns | 0.8834 ns |
NoOpNLogNetNoParams | 2.073 ns | 0.0253 ns | 0.0225 ns |
NoOpNLogNetSingleReferenceParam | 2.625 ns | 0.0364 ns | 0.0340 ns |
NoOpNLogNetSingleValueParam | 2.281 ns | 0.0222 ns | 0.0208 ns |
NoOpNLogNetMultipleReferencesParam | 41.525 ns | 0.4481 ns | 0.4191 ns |
NoOpNLogNetMultipleValuesParam | 57.622 ns | 0.5341 ns | 0.4996 ns |
И код:
void Log4NetNoParams() => _log4Net.Debug("test");
void Log4NetSingleReferenceParam() => _log4Net.DebugFormat("test {0}", _stringArgument);
void Log4NetSingleValueParam() => _log4Net.DebugFormat("test {0}", _intArgument);
void Log4NetMultipleReferencesParam() => _log4Net.DebugFormat(
"test {0} {1} {2} {3} {4} {5} {6} {7} {8}",
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument);
void Log4NetMultipleValuesParam() => _log4Net.DebugFormat(
"test {0} {1} {2} {3} {4} {5} {6} {7} {8}",
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument);
void NLogNetNoParams() => _nlog.Debug("test");
void NLogNetSingleReferenceParam() => _nlog.Debug("test {0}", _stringArgument);
void NLogNetSingleValueParam() => _nlog.Debug("test {0}", _intArgument);
void NLogNetMultipleReferencesParam() => _nlog.Debug(
"test {0} {1} {2} {3} {4} {5} {6} {7} {8}",
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument,
_stringArgument);
void NLogNetMultipleValuesParam() => _nlog.Debug(
"test {0} {1} {2} {3} {4} {5} {6} {7} {8}",
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument,
_intArgument);
Во-первых определимся, почему были выбраны именно такие тесты:
- Эксперименты ставились над наиболее популярными библиотеками
В NLog и log4net разные сигнатуры функций для малого числа аргументов:
- log4net:
void DebugFormat(string format, object arg0)
- NLog:
void Debug(string message, string argument) void Debug<TArgument>(string message, TArgument argument)
- Теория: при передаче значимого типа в log4net должен происходить boxing, который просто тратит процессорное время и ни к чему не приводит. В случае NLog — подобное поведение отсутствует, потому последний должен работать быстрее.
- Сигнатуры для большого числа аргументов в библиотеках примерно одинаковые, поэтому хотелось бы узнать:
- Насколько эффективнее вызывать методы с малым числом параметров.
- Есть ли разница в скорости вызова метода "Is...Enabled" между двумя библиотеками
А теперь анализ результатов:
- За счет использования generic аргументов в NLog, он быстрее отрабатывает для случая, когда непосредственное логгирование не нужно. То есть для случая, когда в вашей программе Debug уровень включен только на тестовой системе, просто смена библиотеки может ускорить работу ПО (и улучшить жизнь пользователям).
- Если у вас выключено логгирование, и вы хотите вызвать метод с большим числом аргументов, то эффективнее разбить его на два. За счет этого вызовы методов выше будут работать быстрее в десятки раз.
- Когда Вы пишете функцию, которая может принимать любой объект, то зачастую эффективнее всего заморочиться и сделать generic функцию. За счет такой простой оптимизации код будет работать быстрее (это хорошо видно на разнице во времени вызовов
Log4NetSingleReferenceParam
иLog4NetSingleValueParam
)
FileLogging
Большинство программ (по моим наблюдениям) все-таки логгируют результаты в файл, поэтому для сравнения выберем именно эту операцию. Для простоты возьмем просто конфигурации логгеров, когда происходит запись в файл без буферизации, без дополнительных блокировок и т.д.
Результаты:
Method | Mean | Error | StdDev |
---|---|---|---|
FileLoggingLog4NetNoParams | 8,251.032 ns | 109.3075 ns | 102.2463 ns |
FileLoggingLog4NetSingleReferenceParam | 8,260.452 ns | 145.9028 ns | 136.4776 ns |
FileLoggingLog4NetSingleValueParam | 8,378.693 ns | 121.3003 ns | 113.4643 ns |
FileLoggingLog4NetMultipleReferencesParam | 9,133.136 ns | 89.7420 ns | 79.5539 ns |
FileLoggingLog4NetMultipleValuesParam | 9,393.989 ns | 166.0347 ns | 155.3089 ns |
FileLoggingNLogNetNoParams | 6,061.837 ns | 69.5666 ns | 65.0726 ns |
FileLoggingNLogNetSingleReferenceParam | 6,458.201 ns | 94.5617 ns | 88.4530 ns |
FileLoggingNLogNetSingleValueParam | 6,460.859 ns | 95.5435 ns | 84.6969 ns |
FileLoggingNLogNetMultipleReferencesParam | 7,236.886 ns | 89.7334 ns | 83.9367 ns |
FileLoggingNLogNetMultipleValuesParam | 7,524.876 ns | 82.8979 ns | 77.5427 ns |
Использованный код:
- log4net:
var roller = new RollingFileAppender();
roller.ImmediateFlush = true;
roller.RollingStyle = RollingFileAppender.RollingMode.Once;
roller.MaxFileSize = 128 * 1000 * 1000;
- NLog:
new FileTarget($"target_{_logIndex++}")
{
ArchiveAboveSize = 128 * 1000 * 1000,
MaxArchiveFiles = 16,
AutoFlush = true,
ConcurrentWrites = false,
KeepFileOpen = false
};
Как видно, конфигурация логгеров более-менее схожая, а по результатам:
- NLog немного быстрее, чем log4net, где-то на 15%.
- По тестам получилось, что эффективнее логгировать меньшее число параметров. Однако еще нельзя забывать, что при большем числе параметров результирующая строка тоже разрасталась. Поэтому в таблице корректно только сравнивать NLog против log4net.
NLog — разные способы блокировок
Method | Mean | Error | StdDev |
---|---|---|---|
KeepFileOpen=true, ConcurrentWrites=false, Async=false | 5,642.301 ns | 73.2291 ns | 68.4986 ns |
KeepFileOpen=true, ConcurrentWrites=true, Async=false | 11,834.892 ns | 82.7578 ns | 77.4117 ns |
KeepFileOpen=false, ConcurrentWrites=false, Async=false | 731,250.539 ns | 14,612.0117 ns | 27,444.8998 ns |
KeepFileOpen=false, ConcurrentWrites=true, Async=false | 730,271.927 ns | 11,330.0172 ns | 10,598.1051 ns |
Исходный код:
new FileTarget($"target_{_logIndex++}")
{
ArchiveAboveSize = 128 * 1000 * 1000,
MaxArchiveFiles = 16,
AutoFlush = true,
ConcurrentWrites = XXXXX,
KeepFileOpen = YYYYY
};
Если на место XXXXX и YYYYY поставить все возможные комбинации, мы получим тест из таблицы.
Результаты довольно предсказуемые:
- Если разрешить ConcurrentWrites, то система начнет постоянно брать и отдавать Mutex, что небесплатно. Зато, как мы видим, запись одной строчки в файл примерно эквивалентно одной системной блокировке.
- Закрытие и открытие файла, как мы видим, еще сильнее влияет на производительность системы. В примерах с
KeepFileOpen=true
на каждую операцию логгирования у нас создавался файл (вместе с Handle), производилась запись на диск, вызывался Flush, отдавался Handle и еще производилось море подкапотных операций. Как результат — скорость падает в сотни раз.
Асинхронное логгирование и разные способы блокировок
Библиотека NLog также умеет выполнять все IO операции на другом потоке, сразу же освобождая текущий. Причем делает это грамотно, с сохранением порядка событий, сбрасывая все данные блоками, причем в каждом блоке целое число событый (чтобы не получалось обрезанных строк), и так далее.
Результаты разных способов неблокирующй работы:
Method | Mean | Error | StdDev |
---|---|---|---|
KeepFileOpen=true, ConcurrentWrites=false, Async=true | 1,144.677 ns | 26.3805 ns | 77.7835 ns |
KeepFileOpen=true, ConcurrentWrites=true, Async=true | 1,106.691 ns | 31.4041 ns | 87.5421 ns |
KeepFileOpen=false, ConcurrentWrites=false, Async=true | 4,804.426 ns | 110.3406 ns | 103.2126 ns |
KeepFileOpen=false, ConcurrentWrites=true, Async=true | 5,303.602 ns | 104.3022 ns | 102.4387 ns |
Сравнение блокирующего и асинхронного подходов будут дальше, а здесь — только последний.
Код AsyncTargetWrapper
:
new AsyncTargetWrapper(fileTargetWithConcurrentWritesAndCloseFileAsync)
{
OverflowAction = AsyncTargetWrapperOverflowAction.Block,
QueueLimit = 10000
}
Как видно, настройки обертки таковы, что непосредственного сброса в файл не происходит довольно долго. Таким образом копится большой буфер, а значит все ресурсоемкие операции вроде "открыть файл" выполняются один раз для всего блока. Однако такой алгоритм требует дополнительной памяти (и немало).
Выводы:
- Если используется асинхронный вывод, то не важно, какие настройки выботы с файлом используются. Вы можете каждый раз открывать и закрывать файл, при большом буфере это будет практически незаметно.
- Все измерения верны только для случая, когда сброс данных на диск происходит примерно с той же скоростю, что и наполнение буферов (у меня такое получилось за счет быстрой файловой системы + естесственных пауз между замерами).
Синхронное и асинхронное логгирование
Результаты: | Method | Mean | Error | StdDev | Median |
---|---|---|---|---|---|
KeepFileOpen=true, ConcurrentWrites=false, Async=true | 1,835.730 ns | 55.3980 ns | 163.3422 ns | 1,791.901 ns | |
FileLoggingLog4NetNoParams | 7,076.251 ns | 41.5518 ns | 38.8676 ns | 7,075.394 ns | |
FileLoggingNLogNetNoParams | 5,438.306 ns | 42.0170 ns | 37.2470 ns | 5,427.805 ns | |
NoOpLog4NetNoParams | 11.063 ns | 0.0141 ns | 0.0125 ns | 11.065 ns | |
NoOpNLogNetNoParams | 1.045 ns | 0.0037 ns | 0.0033 ns | 1.045 ns |
Выводы:
- Несмотря на быстрый диск (в моем случае — M.2 SSD), запись в файл в другом потоке ускоряет работу в несколько раз. Если ваше приложение пишет на HDD диски, да еще и запущено на виртуальной машине, то выигрыш будет еще больше.
- Однако, несмотря на даже быструю работу асинхронного кода, отсутствие логгирование дает еще больший выигрыш (хоть и немного разный, в зависимости от библиотеки).
Создание логгеров
Результаты:
Method | Mean | Error | StdDev |
---|---|---|---|
CreateLog4NetFromString | 1,470.662 ns | 19.9492 ns | 18.6605 ns |
CreateNLogFromString | 228.774 ns | 2.1315 ns | 1.8895 ns |
CreateLog4NetLogger | 21,046.294 ns | 284.1171 ns | 265.7633 ns |
CreateNLogTypeOfLogger | 164,487.931 ns | 3,240.4372 ns | 3,031.1070 ns |
CreateNLogDynamicLogger | 134,459.092 ns | 1,882.8663 ns | 1,761.2344 ns |
Что тестировали:
[Benchmark]
public object CreateLog4NetFromString()
{
return LogManager.GetLogger("my-logger_" + (Interlocked.Increment(ref _log4NetStringLogIndex) % 1000));
}
[Benchmark]
public object CreateNLogFromString()
{
return NLog.LogManager.GetLogger("my-logger_" + (Interlocked.Increment(ref _nLogStringLogIndex) % 1000));
}
[Benchmark]
public object CreateLog4NetLogger()
{
return new []
{
LogManager.GetLogger(typeof(BaseTest)), // x16 times
};
}
[Benchmark]
public object CreateNLogTypeOfLogger()
{
return new[]
{
NLog.LogManager.GetCurrentClassLogger(typeof(BaseTest)), // x16 times
};
}
[Benchmark]
public object CreateNLogDynamicLogger()
{
return new[]
{
NLog.LogManager.GetCurrentClassLogger(), // x16 times
};
}
Важная ремарка: к сожалению, мне было сложно сделать воспроизводимый бенчмарк, который не приводит к Out Of Memory, однако который создавал бы разные логгеры (т.е. для разных типов, для разных строк и так далее).
Однако, изучив работу библиотек, я нашёл, что едва ли не самые тяжеловеские операции выполняются для создания ключа логгера (т.е. определение имени, очистка от Generic аргументов и так далее).
Более того, чтобы стабилизировать бенчмарк по созданию логгера для log4net, пришлось производить не одну операцию, а 16 (т.е. возвращается массив из 16 одинаковых объектов). Если не возвращать ничего, то .Net для меня оптимизировал выполнение (видимо, просто не возвращая результат), что приводило к некорректным результатам.
И выводы:
- Быстрее всего создаются логгеры из строк (NLog опять быстрее, однако разница между библиотеками небольшая, если учесть, что для логгеры создаются не просто так, а для последующей работы с ними).
- log4net работает быстрее, чем NLog, при инициализации проекта. Возможно, дело в дополнительном кешировании на стороне NLog, которое помогает ускорить непосредственно вызовы
Debug
,Info
и т.д. По сути каждыйILogger
знает ответ про себя: надо вызывать следующие методы или нет (а подобное требует хоть какой-то привязки к общей конфигурации). Из-за этой схемы работы у меня и выходил Out Of Memory на большинстве тестов (если использовать разные строки и т.д.). LogManager.GetCurrentClassLogger()
работает еще медленнее, чемLogManager.GetLogget(typeof(XXX))
. Это логично, даже разработчики NLog не рекомедует вызывать первый метод в цикле.- И самое главное: скорость работы всех этих методов зачастую влияет только на холодный старт приложения, когда инициализируются поля вида
private static readonly ILogger Log = LogManager.GetCurrentClassLogger()
. То есть на непосредственно производительность работы системы оно не влияет.
Вывод
Как лучше обращаться с логами:
- Если есть возможность вообще не логгировать — это будет самым быстрым (что пока очевидно).
- Если в проекте есть много вызовов логгера, которые не производят сброса данных в файл (на консоль и т.д.), то NLog работает быстрее. К тому же он выделяет меньше объектов в куче.
- Если таки надо осуществлять запись в файл, то быстрее всего работает NLog в асинхронном режиме. Да, он ест больше памяти (по сравнению с NLog в синхронном режиме, так как по моим предыдущим замерам, log4net даже не пытается переиспользовать массивы и
Stream
'ы). Однако программа сможет работать быстрее. - Создание логгера — небесплатая операция, поэтому зачастую лучше создавать его статическим полем. Это не относится к созданию из строки, то есть что-то вида
LogManager.GetLogger("123")
. Подобные вызовы работают быстрее, а значит логгер можно создавать и на большие инстансы объектов (например, " один логгер для контекста выполнения запроса"). - Если Вы хотите вывести в лог много параметров, однако в большинстве случаев непосредственно сброса данных в файл не будет, то лучше всего сделать несколько вызовов. Таким образом NLog не будет создавать дополнительных объектов в куче, если они там не нужны.
Выводы для своего кода:
- Если ваш метод принимает произвольный объект (т.е.
object
) и в большинстве случаев ничего не делает (что верно для контрактов/валидаторов), то корректнее всего завернуть вызовы в generic форму (т.е. сделать методы видаSomething<TArg>(TArg arg)
). Работать такое будет действительно быстрее. - Если в вашем коде допустим сброс данных файл и параллельно работа еще с чем-то — лучше заморочиться и поддержать подобное. Да, это кажется очевидным, что параллельное выполнение может ускорить работу, однако в случае с IO операциями подобный подход дает также дополнительный прирост производительности на машинах с медленными дисками.