Q: Код не связанный с циклом замедляет его Я столкнулся с необычной ситуацией. Строка кода, добавленная перед циклом и никак с ним не связанная, замедляет выполнение цикла. Вот как это выглядит в "уменьшенном виде".
У меня есть метод с примитивным циклом:
int[] array = new int[ElemCnt];

public int Sum()
{
    int sum = 0;

    for (int i = 0; i < array.Length; i++)
        sum += array[i];

    return sum;
}

Этот метод модифицируется затем следующим образом:
long state = 1;

public int Sum2()
{
    int sum = 0;

    if (Interlocked.Read(ref state) == 0)
        return sum;

    for (int i = 0; i < array.Length; i++)
        sum += array[i];

    return sum;
}

т.е. просто добавляется проверка перед циклом.
Логично предположить, что время выполнения этой проверки можно оценить некой константой. Время выполнения второго метода (при худшем исходе) тогда можно оценить как T2 ≈ T + C, где T - время выполнения первого метода, а C - время выполнения проверки (CPU считаем не загружен). С ростом длины массива следует ожидать, очевидно, что время выполнения двух методов будет всё менее различимым (T2 ≈ T).
Такого, однако, не происходит:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1766 (21H2)
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=6.0.301
  [Host]     : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
  DefaultJob : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT





Method
ElemCnt
Mean
Error
StdDev
Ratio
RatioSD




Sum
1000
590.1 ns
6.22 ns
5.81 ns
1.00
0.00


Sum2
1000
1,896.4 ns
9.88 ns
9.24 ns
3.21
0.03











Sum
10000
5,729.5 ns
27.40 ns
24.29 ns
1.00
0.00


Sum2
10000
18,929.2 ns
208.10 ns
194.66 ns
3.30
0.03











Sum
100000
57,273.8 ns
258.89 ns
242.17 ns
1.00
0.00


Sum2
100000
187,707.1 ns
2,121.93 ns
1,984.85 ns
3.28
0.03











Sum
1000000
590,207.1 ns
9,395.29 ns
8,328.68 ns
1.00
0.00


Sum2
1000000
1,943,186.9 ns
34,793.55 ns
32,545.90 ns
3.29
0.06




т.е. в действительности получается T2 ≈ K * T!
Это наводит на мысль о том, что во втором случае цикл выполняется медленнее. Будто бы добавленная проверка каким-то образом замедляет цикл. В чём здесь дело?

Код бенчмарка:
public class LoopBench
{
    int[] array = null;
    long state = 1;

    [Params(1000, 10000, 100000, 1000000)]
    public int ElemCnt { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        array = new int[ElemCnt];
    }

    [Benchmark(Baseline = true)]
    public int Sum()
    {
        int sum = 0;

        for (int i = 0; i < array.Length; i++)
            sum += array[i];

        return sum;
    }

    [Benchmark]
    public int Sum2()
    {
        int sum = 0;

        if (Interlocked.Read(ref this.state) == 0)
            return sum;

        for (int i = 0; i < array.Length; i++)
            sum += array[i];

        return sum;
    }
}


A: 
В чём здесь дело?

Если сравнивать IL-код двух методов, то ничего криминального не просматривается.
Вот так выглядит IL второго метода:
.maxstack  3
.locals init (int32 V_0, int32 V_1)
IL_0000:  ldc.i4.0
IL_0001:  stloc.0
IL_0002:  ldarg.0
IL_0003:  ldflda     int64 LoopBench::state
IL_0008:  call       int64 Interlocked::Read(int64&)
IL_000d:  brtrue.s   IL_0011
IL_000f:  ldloc.0
IL_0010:  ret
IL_0011:  ldc.i4.0
IL_0012:  stloc.1
IL_0013:  br.s       IL_0024
IL_0015:  ldloc.0
IL_0016:  ldarg.0
IL_0017:  ldfld      int32[] LoopBench::'array'
IL_001c:  ldloc.1
IL_001d:  ldelem.i4
IL_001e:  add
IL_001f:  stloc.0
IL_0020:  ldloc.1
IL_0021:  ldc.i4.1
IL_0022:  add
IL_0023:  stloc.1
IL_0024:  ldloc.1
IL_0025:  ldarg.0
IL_0026:  ldfld      int32[] LoopBench::'array'
IL_002b:  ldlen
IL_002c:  conv.i4
IL_002d:  blt.s      IL_0015
IL_002f:  ldloc.0
IL_0030:  ret

Для первого метода IL-код я не привожу, т.к. он практически идентичен и выглядит так, как если бы из кода второго метода выкинули инструкции с IL_0002 по IL_0010 (собственно проверка и возврат). Что касается цикла, и в особенности тела цикла (с IL_0015 по IL_002d) - различия отсутствуют.
А вот JIT генерирует немного разный код. И в теле цикла как раз таки есть отличия.
Код первого метода, если выкинуть всё второстепенное, выглядит так:
; LoopBench.Sum()
       sub       rsp,28
       xor       eax,eax                        // sum = 0
       xor       edx,edx                        // i = 0
       ...
M00_L00:
       ...
       movsxd    r9,edx                 
       add       eax,[r8+r9*4+10]               // sum += array[i]
       inc       edx                            // i++
       cmp       [rcx+8],edx
       jg        short M00_L00                  // array.Length > i
M00_L01:
       add       rsp,28
       ret

А код второго метода (также без второстепенных деталей) - так:
; LoopBench.Sum2()
       sub       rsp,28
       xor       eax,eax                        // sum = 0
       mov       [rsp+24],eax                   // [stack] <- sum
       ...
M00_L00:
       xor       eax,eax                        // i = 0
       ...
M00_L01:
       ...
       movsxd    r8,eax
       mov       r9d,[rsp+24]                   // sum <- [stack]
       add       r9d,[rcx+r8*4+10]              // sum += array[i]
       inc       eax                            // i++
       cmp       [rdx+8],eax
       jg        short M00_L03                  // array.Length > i
M00_L02:
       mov       eax,r9d                        // return sum
       add       rsp,28
       ret
M00_L03:
       mov       [rsp+24],r9d                   // [stack] <- sum
       jmp       short M00_L01

Главное отличие заключается в том, что во втором случае на каждой итерации цикла накопленная сумма загружается из стека в регистр (r9d) и после суммирования сохраняется обратно в стек. В первом же случае сумма накапливается сразу в регистр (eax) и в стек не попадает вообще. Именно эта дополнительная работа со стеком (вкупе с тем, что обращение к памяти медленнее, чем к регистрам процессора - даже с учётом возможного кэширования) и вызывает падение производительности во втором методе.
Работа со стеком не сказывалась бы заметно, если бы в цикле было много работы. Но здесь её очень мало. Поэтому такая код-генерация выглядит скорее нетипично, т.к. циклам с небольшим телом (их детекции и оптимизации) компилятор уделяет особое внимание.

Будто бы добавленная проверка каким-то образом замедляет цикл

Нет, сама проверка не замедляет выполнение цикла. Но её присутствие заставляет JIT генерировать для цикла менее производительный код.
Такое поведение RyuJIT компилятора - регрессия от предыдущих версий:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1766 (21H2)
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=6.0.301
  [Host]             : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
  .NET 6.0           : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT
  .NET 5.0           : .NET 5.0.17 (5.0.1722.21314), X64 RyuJIT
  .NET Core 3.1      : .NET Core 3.1.26 (CoreCLR 4.700.22.26002, CoreFX 4.700.22.26801), X64 RyuJIT
  .NET Core 2.2      : .NET Core 2.2.8 (CoreCLR 4.6.28207.03, CoreFX 4.6.28208.02), X64 RyuJIT
  .NET Framework 4.8 : .NET Framework 4.8 (4.8.4515.0), X64 RyuJIT





Method
Runtime
Mean
Error
StdDev
Ratio
RatioSD




Sum
.NET 6.0
587,219.9 ns
11,480.13 ns
10,738.52 ns
1.00
0.00


Sum2
.NET 6.0
1,943,794.5 ns
15,662.22 ns
13,078.66 ns
3.33
0.04











Sum
.NET 5.0
584,441.7 ns
9,659.41 ns
9,035.41 ns
1.00
0.00


Sum2
.NET 5.0
1,955,357.4 ns
18,500.14 ns
16,399.89 ns
3.35
0.06











Sum
.NET Core 3.1
597,269.8 ns
8,984.42 ns
8,404.03 ns
1.00
0.00


Sum2
.NET Core 3.1
1,987,871.5 ns
35,514.92 ns
33,220.67 ns
3.33
0.09











Sum
.NET Core 2.2
585,353.0 ns
7,527.64 ns
7,041.36 ns
1.00
0.00


Sum2
.NET Core 2.2
576,804.1 ns
2,975.21 ns
2,637.45 ns
0.99
0.01











Sum
.NET Framework 4.8
580,485.7 ns
5,634.76 ns
5,270.76 ns
1.00
0.00


Sum2
.NET Framework 4.8
591,283.8 ns
8,772.34 ns
8,205.65 ns
1.02
0.02




В .NET Framework 4.8 и .NET Core 2.2 всё было ещё не так плохо :)
О проблеме я сообщил "куда следует". Возможно она будет исправлена в будущих версиях .NET.
