Hacker News new | ask | show | jobs
by notanaverageman 1675 days ago
Note that most of the performance improvements come from PGO, which is enabled with following environment variables. PGO is not enabled in .NET 6 by default, but will be in .NET 7 IIRC.

  set DOTNET_ReadyToRun=0 
  set DOTNET_TieredPGO=1 
  set DOTNET_TC_QuickJitForLoops=1
Here are my own benchmarks from a CPU intensive application without any IO and already optimized for allocations. Application runs a task graph either serially or in parallel.

  .NET 5
  --------------------------
  |      Method |     Mean |
  |------------ |---------:|
  | RunParallel | 473.4 us |
  |         Run | 513.5 us |

  .NET 6
  --------------------------
  |      Method |     Mean |
  |------------ |---------:|
  | RunParallel | 452.5 us |
  |         Run | 499.8 us |

  .NET 6 PGO
  --------------------------
  |      Method |     Mean |
  |------------ |---------:|
  | RunParallel | 381.8 us |
  |         Run | 412.2 us |

  .NET 5 - .NET 6     -> ~5%
  .NET 5 - .NET 6 PGO -> ~20%
Here is what I learned from micro-optimizing a .NET application:

- Use BenchmarkDotNet[0] for general measurements and Visual Studio profiler tools for detailed inspection. They help a lot.

- Memory allocations matter. Using capturing lambdas, LINQ, even foreach on interfaces introduce allocations and slows down the application. You can use ClrHeapAllocationAnalyzer[1] to find these hidden allocations.

- Using abstractions with interfaces and casting back to concrete types cause some overhead, though PGO will probably eliminate most of these.

- Use LINQ cautiously as its variants are mostly slower than explicit coding. E.g. .Any() vs .Count == 0

- Checking Logger.IsEnabled() before calling Logger.Debug() etc. helps a lot. You can even automate this with Fody [2], but it breaks Edit&Continue and possibly .NET Hot Reload too, so it may hinder your productivity.

[0] https://github.com/dotnet/BenchmarkDotNet

[1] https://github.com/microsoft/RoslynClrHeapAllocationAnalyzer

[2] https://github.com/jorisdebock/LoggerIsEnabled.Fody

6 comments

> - Use LINQ cautiously as its variants are mostly slower than explicit coding. E.g. .Any() vs .Count == 0

Is this really true for the example? To me it seems that the implementation for .Any actually uses .Count when available, see https://github.com/dotnet/runtime/blob/main/src/libraries/Sy...

Any method does an extra null check and a cast to ICollection<T> which incur unnecessary performance degradation. Of course this is in micro optimization scale. If you do not call Any() on a hot path it does not matter which one you use.
> - Use LINQ cautiously as its variants are mostly slower than explicit coding. E.g. .Any() vs .Count == 0

When using LINQ also be aware that .First(predicate) is significantly slower than .Where(predicate).First() when called on List<T> and T[]. This is true for essentially all methods like Last, Single, Count etc. Don't trust Visual Studio when it's telling you to "optimize" this.

But if you want the last bit of performance, you shouldn't use LINQ anyways.

Do you know why that is? That's very interesting.
Not sure if it is still the case, but it used to be that First did a fairly naive foreach over the IEnumerable while Where has several collection specific type checks that allow it to use MoveNext and maybe other more efficient ways to traverse the collection.
LINQ is parsing a tree of System.Linq.Expression here and the cases of First(pred) etc. are just not optimized because of the added complexity with little benefit. It only recently became a problem when Visual Studio got a new built-in analyzer that tells people to "optimize" this.
If you're using LINQ on a list, it will not use Expressions, but a plain Func. So nothing will get parsed either way.

The difference, as @sbelskie already mentioned, is that Where has an optimization for List, while First only uses the naive enumerator.

As for BenchmarkDotNet, I totally agree with you in general - it's the best option available for micro-benchmarks. But if you want to run a benchmark involving a fairly complex interaction, multithreading, etc. (caching benchmark that I used is of this kind - it runs on client + server process, uses SQL Server hosted in Docker, etc.), it's rarely the best fit.

On a positive side, I can assure you I know how to run such benchmarks properly - i.e. things like warmup, explicit GC before / after are just some of the aspects taken into account. If you're curious about some other benchmarks I ran in past, check out https://itnext.io/geting-4x-speedup-with-net-core-3-0-simd-i... and https://github.com/alexyakunin/GCBurn

I still just use System.Diagnostics for debug statements, any left overs are no problems in production code, they don't even get compiled.

I'm interested, what's the advantage for you to using ILogger instead?

I use it for structured logging, which makes filtering and searching very convenient. E.g. I can filter by an object’s id and a property to see which tasks change the property of that specific object and in what order. Serilog[0] and Seq[1] are the best tools for this in my opinion.

[0] https://github.com/serilog/serilog

[1] https://datalust.co/seq

Can log to different outputs, such as a remote sink or a file.
Hi there, the author of the original post is here. PGO is disabled in .NET 6 by default mainly because of trade-offs associated w/ the startup time - and IMO it's totally reasonable assuming .NET 6 brings decent speed benefits even w/o PGO.

I turned it on mostly to show what you can expect from a service that runs for a while (more than a few minutes?) in a typical server-side scenario after migration to .NET 6 - IMO it's totally reasonable to turn PGO on for nearly any service of this kind.

FYI your chart is very unreadable on mobile.
Thanks, I removed unnecessary parts. It should be better now.
A lot better now, thanks.