0

I changed from .NET 6.0 to .NET 7.0 and found that some of my performance-critical tasks take almost twice as long as with .NET 6.0 (no code changes were made!).

I was able to derive a simple example which shows at least one interesting effect:

using System;
using System.Collections.Generic;
using System.Diagnostics;

public readonly struct MyStruct {
  public int Parent { get; }
  public int Child { get; } 
  public MyStruct(int p, int c) {
    Parent = p;
    Child = c;
  }
}

static class MyProgram {
  
  static int Main(string[] args) {
    Stopwatch stopwatch = new Stopwatch();
    List<MyStruct> list = new() { new(1, 1)};
    stopwatch.Start();
    for (int j = 0; j < 500_000; ++j) {
      for (int i = 0; i < 1000; ++i) {
        int p = list[0].Parent;
        int c = list[0].Child;
      }
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    return 0;
  }

}

With .NET 6.0 this takes about 240ms, and with .NET 7.0 450ms (so about 41% longer).

As far as I can tell the MSIL code is equivalent, so it seems the JIT compiler does something severely different in .NET 7.0?

Can anyone confirm this or does anybody else experience performance degradation when switching to .NET 7.0 (for .NET 8.0 it's the same by the way)?

This behavior is very fragile: Make a class of the struct, add another property, combine the nested loops into one, etc. and performance is almost the same for both versions.

Instrumentation shows (for .NET 7.0): enter image description here

and for .NET 6.0: enter image description here

So it shows that .NET 6.0 does some optimizations (inlining?) which are not done any more in .NET 7.0.

Veverke
  • 9,208
  • 4
  • 51
  • 95
  • 2
    I wouldn't measure performance via a Stopwatch. Use a dedicated tool like [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) which will help ensure you get a reliable result. – mason Apr 15 '23 at 18:54
  • 1
    1) Use proper benchmarking tools like [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) 2) Try setting `DOTNET_TC_QuickJitForLoops` environment variable to 0 - see [this answer](https://stackoverflow.com/a/74837351/2501279) for some info. – Guru Stron Apr 15 '23 at 18:57
  • 1
    P.S. was not able to repro - on my machine if compiled in Release and started from CLI .NET 7 version is a bit faster. (~280 vs ~310 between multiple runs) – Guru Stron Apr 15 '23 at 18:59
  • 2
    Seeing get_Child and get_Parent back in the .net7 profile, but not the .net6 profile, indicates the problem. Small property getters always get inlined in the Release build. In other words, you are running the non-optimized (Debug) .net7 build. Yes, that's slow. No clue in the question how that happened, but easy mistake. – Hans Passant Apr 15 '23 at 19:52
  • Thanks for the useful answers so far. Some clarifications: I just used the performance profiler first, then added the stopwatch to have a simple enough code snippet to share. As the performance differences are quite significant, this seemed good enough. The instrumentation measurements have both been taken with Release settings, I just double- and triple-checked. I was also baffled by the fact that the property getters are showing up, as I would only expect them in debug builds. Having said this, I can reproduce the results from @mason, so I am wondering if my setup is strangely corrupted. – Markus Ferringer Apr 16 '23 at 18:25

1 Answers1

7

There's something wrong with your test. Just resorting to the Stopwatch class and running in Debug mode won't get you a meaningful result. There's a lot more that goes into it, such as warming up the JITer. Using a proper benchmarking tool like BenchmarkDotNet reveals that .NET 7 is actually a bit faster at this, and more memory effficient.

Method Job Runtime Mean Error StdDev Allocated
MyBenchmark .NET 6.0 .NET 6.0 366.3 ms 7.24 ms 9.15 ms 728 B
MyBenchmark .NET 7.0 .NET 7.0 320.0 ms 6.09 ms 5.40 ms 388 B

MyBenchmarks.cs

using BenchmarkDotNet.Attributes;

namespace HelloConsole;

[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net60)]
[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net70)]
[MemoryDiagnoser]
public class MyBenchmarks
{
    public readonly struct MyStruct
    {
        public int Parent { get; }
        public int Child { get; }
        public MyStruct(int p, int c)
        {
            Parent = p;
            Child = c;
        }
    }

    [Benchmark]
    public void MyBenchmark()
    {        
        List<MyStruct> list = new() { new(1, 1) };
        for (int j = 0; j < 500_000; ++j)
        {
            for (int i = 0; i < 1000; ++i)
            {
                int p = list[0].Parent;
                int c = list[0].Child;
            }
        }
    }
}

Program.cs

using BenchmarkDotNet.Running;
using HelloConsole;

var summary = BenchmarkRunner.Run<MyBenchmarks>();

Project file (.csproj)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net6.0;net7.0</TargetFrameworks>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.13.5" />
  </ItemGroup>
</Project>
mason
  • 31,774
  • 10
  • 77
  • 121
  • I marked this answer because it answers the concrete question I asked. I think the problem breakdown to a single property access was way too oversimplified. In my project I do have performance penalties when switching to .NET7 (measured with an ACTUAL stopwatch :-) ), in the range of 10-20% for some workloads, so not as dramatic as I pointed out above. Should I ever find the cause for this, I will of course post it here. Thanks for all your efforts! – Markus Ferringer Apr 21 '23 at 20:55