LoggerFactory.CreateLogger performance improvements in .NET 8 v .NET 7




Date Added (UTC):

07 Apr 2024 @ 23:00

Date Updated (UTC):

13 Apr 2024 @ 00:22


.NET Version(s):

.NET 7 .NET 8

Tag(s):

#.Net8PerfImprovement #Logging


Added By:
Profile Image

Blog   
Ireland    
.NET Developer and tech lead from Ireland!

Benchmark Results:





Benchmark Code:



using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs; 
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Reports;
using Microsoft.Extensions.Logging;
using System.Linq;
using System.Threading.Tasks;
using System;
using BenchmarkDotNet.Columns;

[HideColumns("Error", "StdDev", "Median", "RatioSD", "NuGetReferences")]
[MemoryDiagnoser(displayGenColumns: false)]
[Config(typeof(Config))]
public class CreateLogger
{
    private readonly LoggerFactory _factory = new();

    [Benchmark]
    public void Serial() => _factory.CreateLogger("test");

    [Benchmark]
    public void Concurrent()
    {
        Parallel.ForEach(Enumerable.Range(0, Environment.ProcessorCount), (i, ct) =>
        {
            for (int j = 0; j < 1_000_000; j++)
            {
                _factory.CreateLogger("test");
            }
        });
    }

    private class Config : ManualConfig
    {
        public Config()
        {
            AddJob(Job.Default.WithRuntime(CoreRuntime.Core70).WithNuGet("Microsoft.Extensions.Logging", "7.0.0").AsBaseline());
            AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).WithNuGet("Microsoft.Extensions.Logging", "8.0.0"));

            SummaryStyle =
                SummaryStyle.Default.WithRatioStyle(RatioStyle.Percentage);
        }
    }
}

// .NET 7 Lowered C# Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)
// .NET 8 Lowered C# Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)

// .NET 7 IL Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)
// .NET 8 IL Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)

// .NET 7 Jit Asm Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)
// .NET 8 Jit Asm Code unavailable due to errors:
error CS0246: The type or namespace name 'LoggerFactory' could not be found (are you missing a using directive or an assembly reference?)


Benchmark Description:


From Stephen Toubs amazing 'Performance Improvements in .NET 8' post on the main devblogs.microsoft.com website: *One issue that’s plagued some applications is in Microsoft.Extensions.Logging‘s LoggerFactory.CreateLogger method. Some libraries are passed an ILoggerFactory, call CreateLogger once, and then store and use that logger for all subsequent interactions; in such cases, the overhead of CreateLogger isn’t critical. However, other code paths, including some from ASP.NET, end up needing to “create” a logger on demand each time it needs to log. That puts significant stress on CreateLogger, incurring its overhead as part of every logging operation. To reduce these overheads, LoggerFactory.CreateLogger has long maintained a Dictionary<TKey, TValue> cache of all logger instances it’s created: pass in the same categoryName, get back the same ILogger instance (hence why I put “create” in quotes a few sentences back). However, that cache is also protected by a lock. That not only means every CreateLogger call is incurring the overhead of acquiring and releasing a lock, but if that lock is contended (meaning others are trying to access it at the same time), that contention can dramatically increase the costs associated with the cache. This is the perfect use case for a ConcurrentDictionary<TKey, TValue>, which is optimized with lock-free support for reads, and that’s exactly how dotnet/runtime#87904 improves performance here. We still want to perform some work atomically when there’s a cache miss, so the change uses “double-checked locking”: it performs a read on the dictionary, and only if the lookup fails does it then fall back to taking the lock, after which it checks the dictionary again, and only if that second read fails does it proceed to create the new logger and store it. The primary benefit of ConcurrentDictionary<TKey, TValue> here is it enables us to have that up-front read, which might execute concurrently with another thread mutating the dictionary; that’s not safe with Dictionary<,> but is with ConcurrentDictionary<,>. This measurably lowers the cost of even uncontended access, but dramatically reduces the overhead when there’s significant contention.* [PR](https://github.com/dotnet/runtime/pull/87904) I think this change could make a big difference for many apps. ***This benchmark shows off a really cool capability in BenchmarkDotNet which is that it allows us to bring in arbitrary versions of NuGet packages.***

The provided benchmark code is designed to evaluate the performance of creating loggers in a .NET application using the `Microsoft.Extensions.Logging` library. It is set up with BenchmarkDotNet, a powerful .NET library for benchmarking, to compare the performance across different .NET versions and configurations, specifically focusing on serial vs. concurrent logger creation. Let's break down the setup and the rationale behind each benchmark method. ### General Setup - **.NET Versions:** The benchmark is configured to test against two different .NET Core versions, Core 7.0 and Core 8.0, using the `CoreRuntime` class. This allows for performance comparison across different runtime versions. - **NuGet Packages:** It specifies the version of `Microsoft.Extensions.Logging` to use for each job, aligning with the .NET Core versions being tested. - **BenchmarkDotNet Configuration:** The `Config` class extends `ManualConfig` from BenchmarkDotNet, customizing the benchmarking jobs, summary style, and hiding certain columns in the output (like Error, StdDev, etc.) for clarity. - **Memory Diagnoser:** Enabled with `MemoryDiagnoser` attribute but configured to not display garbage collection generation columns. This focuses on memory allocation without the noise of GC details. - **Columns:** Certain columns are hidden to streamline the output, focusing on the most relevant metrics for this benchmark. ### Benchmark Methods #### 1. Serial - **Purpose:** This method measures the performance of creating loggers serially, one after another, simulating a single-threaded logging scenario. - **Performance Aspect:** It tests how efficiently the `LoggerFactory` can create a new logger instance when not under concurrent demand. This is important for applications that do not heavily rely on parallel operations but still require logging. - **Expected Insights:** The results will show how quickly loggers can be created in a loop without concurrent stress. Lower execution times and allocations are better, indicating more efficient logger creation. #### 2. Concurrent - **Purpose:** This method evaluates the performance of creating loggers in a highly concurrent environment using `Parallel.ForEach` over the number of logical processors on the host machine. - **Performance Aspect:** It specifically tests the logger factory's ability to handle concurrent logger creation requests, simulating a high-load scenario where many components might be logging simultaneously. - **Expected Insights:** This benchmark aims to uncover any performance bottlenecks or scalability issues when logger creation is subjected to concurrent demands. Ideally, the logger factory should scale well with the number of processors, showing minimal degradation in performance as concurrency increases. ### Summary By running these benchmarks, you can gain insights into how the `LoggerFactory` performs under different loads and runtime versions, which is crucial for designing and optimizing logging in high-performance .NET applications. The serial benchmark provides a baseline for logger creation speed in a controlled, single-threaded scenario, while the concurrent benchmark tests the logger factory's scalability and efficiency under stress. Together, these benchmarks help identify potential performance issues and guide optimizations to improve logging performance across different .NET Core versions.


Benchmark Comments: