Привет! Хочу рассказать об интересном опыте, как я писал енумератор для типа Range, который был бы таким же дешевым, как цикл for.

Что мы хотим?

У System.Range, как известно, очень красивый синтаксис создания:

var a = 1..2; // эквивалент new Range(1, 2)

Поэтому, как эксперимент, я решил абузить его для цикла foreach, как замена for:

foreach (var i in 1..5)
    Console.Write(i);

(выводит 12345)

Для foreach Розлин требует метод GetEnumerator, который возвращал бы что-то, у чего есть bool MoveNext() и T Current. Этот метод можно добавить как метод расширения:

public static class Extensions
{
		public ... GetEnumerator(this System.Range range) => ...
}

Baseline (на что равняемся)

Вообще-то for это куда больше, чем пройтись по последовательности. Но так как очень часто мы видим

for (int i = 0; i < n; i++)

то это стоит своего кейса использования for. Сравнивать мы будем с циклом for у которого в качестве итерации стоит i += inc:

for (int i = 0; i < n; i += inc)

По производительности он идентичен версии с i++:

Method

Iterations

Mean

Error

StdDev

i++

10000

3.380 us

0.0534 us

0.0446 us

i += inc

10000

3.385 us

0.0575 us

0.0685 us

Код метода с for-ом:

[Benchmark]
public int ForWithCustomIncrement()
{
    var iters = Iterations;
    var a = 0;
    var inc = Inc;
    for (int i = 0; i < iters; i += inc)
        a += i;
    return a;
}

(мы выгрузили все проперти в локальные переменные чтобы исключить чтение из памяти при итерации)

Кодген for-а:

    L000f: add edx, r8d
    L0012: add r8d, ecx
    L0015: cmp r8d, eax
    L0018: jl short L000f

Мы будем смотреть только на код одной итерации цикла, начальный оверхед и окружающий мусор нас не интересует.

Эксперимент 1. Enumerable.Range

Конечно, сначала стоит попробовать вариант из BCL: Enumerable.Range, исходный код которого можно поизучать здесь.

Так выглядит наш бенчмарк:

[Benchmark]
public int ForeachWithEnumerableRange()
{
    var a = 0;
    foreach (var i in Enumerable.Range(0, Iterations))
        a += i;
    return a;
}

Этот код Розлином раскрывается как

public int ForeachWithEnumerableRange()
{
    int num = 0;
    IEnumerator<int> enumerator = Enumerable.Range(0, Iterations).GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            int current = enumerator.Current;
            num += current;
        }
        return num;
    }
    finally
    {
        if (enumerator != null)
        {
            enumerator.Dispose();
        }
    }
}

Изучать ассемблерный кодген смысла нет, достаточно взглянуть на бенчмарк:

Method

Iterations

Mean

Error

StdDev

ForWithCustomIncrement

10000

3.448 us

0.0689 us

0.0896 us

ForeachWithEnumerableRange

10000

43.946 us

0.8685 us

1.2456 us

Эксперимент 2. yield return

Второй по простоте способ - написать метод-генератор, и вызывать его енумератор:

private static IEnumerable<int> MyRange(int from, int to, int inc)
{
    for (int i = from; i <= to; i += inc)
        yield return i;
}

[Benchmark]
public int ForeachWithYieldReturn()
{
    var a = 0;
    foreach (var i in MyRange(0, Iterations - 1, 1))
        a += i;
    return a;
}

Принципиально нового мы ничего не получаем, наш енумератор это все еще огромный референс тип с кучей полей. Бенчмарки сообщают, что с yield-ом даже хуже, чем с Enumerable.Range:

Method

Iterations

Mean

Error

StdDev

ForWithCustomIncrement

10000

3.548 us

0.0661 us

0.1320 us

ForeachWithEnumerableRange

10000

51.663 us

0.9103 us

1.2460 us

ForeachWithYieldReturn

10000

56.580 us

0.3561 us

0.2780 us

Эксперимент 3. Собственный енумератор

Теперь будем писать собственный енумератор. Чтобы оно работало с foreach-ем, нужно, чтобы был метод bool MoveNext() и свойство T Current (в нашем случае int Current).

public struct RangeEnumerator
{
    private readonly int to;
    private readonly int step;

    private int curr;

    public RangeEnumerator(int @from, int to, int step)
    {
        this.to = to + step;
        this.step = step;
        this.curr = @from - step;
    }

    public bool MoveNext()
    {
        curr += step;
        return curr != to;
    }
    
    public int Current => curr;
}

Чтобы Розлин увидел, что по System.Range можно форычиться, нужно сделать метод расширения:

public static class Extensions
{
    public static RangeEnumerator GetEnumerator(this Range @this)
        => (@this.Start, @this.End) switch
        {
            ({ IsFromEnd: true, Value: 0 }, { IsFromEnd: true, Value: 0 }) => new RangeEnumerator(0, int.MaxValue, 1),
            ({ IsFromEnd: true, Value: 0 }, { IsFromEnd: false, Value: var to }) => new RangeEnumerator(0, to + 1, 1),
            ({ IsFromEnd: false, Value: var from }, { IsFromEnd: true, Value: 0 }) => new RangeEnumerator(from, int.MaxValue, 1),
            ({ IsFromEnd: false, Value: var from }, { IsFromEnd: false, Value: var to })
                => (from < to) switch
                {
                    true => new RangeEnumerator(from, to, 1),
                    false => new RangeEnumerator(from, to, -1),
                },
            _ => throw new InvalidOperationException("Invalid range")
        };
}

Код бенчмарка выглядит так:

[Benchmark]
public int ForeachWithRangeEnumerator()
{
    var a = 0;
    foreach (var i in 0..(Iterations - 1))
        a += i;
    return a;
}

И раскрывается Розлином так:

public int ForeachWithRangeEnumerator()
{
    int num = 0;
    RangeEnumerator enumerator = Extensions.GetEnumerator(new Range(0, Iterations - 1));
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        num += current;
    }
    return num;
}

На этот раз нет референс типов, а так как RangeEnumerator не имплементит IDisposable, то и нет try-catch блоков вокруг. Взглянем на бенчмарк:

Method

Iterations

Mean

Error

StdDev

ForWithCustomIncrement

10000

3.477 us

0.0690 us

0.0989 us

ForeachWithEnumerableRange

10000

50.501 us

0.9984 us

1.2627 us

ForeachWithYieldReturn

10000

53.782 us

1.0583 us

0.9900 us

ForeachWithRangeEnumerator

10000

18.061 us

0.1731 us

0.1619 us

18 микросекунд на 10000 операций. Намного лучше, чем аналоги до этого, но сильно хуже, чем for. Время взглянуть на кодген!

Одна итерация цикла выглядит так:

    L003e: add esi, eax
    L0040: mov eax, [rsp+0x38]
    L0044: add eax, [rsp+0x34]
    L0048: mov [rsp+0x38], eax
    L004c: mov eax, [rsp+0x38]
    L0050: cmp eax, [rsp+0x30]
    L0054: jne short L003a

Да, у нас референс-типов нет, но все данные-то все равно в памяти! Хоть и на стеке.

Эксперимент 4. Собственный енумератор прячем в локальную функцию

Что за магия? Дело в том, что чтобы получить наш енумератор таким же быстрым, как for, следует поместить его поля в регистры. Но компилятор сам этого не сделал, слишком много локальных переменных в методе было, и он не положил нужные. Давайте облегчим задачу.

Из эксперимента 3:

[Benchmark]
public int ForeachWithRangeEnumeratorRaw()
{
    var a = 0;
    var enumerator = (0..(Iterations - 1)).GetEnumerator();
    while (enumerator.MoveNext())
        a += enumerator.Current;
    return a;
}

(я заменил foreach на точно то же самое с постфиксом Raw, только написанное ручками, для наглядности. См. эксперимент 3, там показано, как Розлин раскрывает foreach).

Чтобы уменьшить количество локальных переменных, спрячем цикл в локальную функцию:

[Benchmark]
public int ForeachWithRangeEnumeratorRawWithLocal()
{
    var enumerator = (0..(Iterations - 1)).GetEnumerator();        
    return EnumerateItAll(enumerator);

    static int EnumerateItAll(RangeEnumerator enumerator)
    {
        var a = 0;
        while (enumerator.MoveNext())
            a += enumerator.Current;
        return a;
    }
}

И побенчим:

Method

Iterations

Mean

Error

StdDev

ForWithCustomIncrement

10000

3.498 us

0.0658 us

0.1153 us

ForeachWithEnumerableRange

10000

43.313 us

0.8207 us

1.0079 us

ForeachWithYieldReturn

10000

56.963 us

1.0638 us

1.0448 us

ForeachWithRangeEnumerator

10000

17.931 us

0.2047 us

0.1915 us

ForeachWithRangeEnumeratorRaw

10000

17.932 us

0.1486 us

0.1390 us

ForeachWithRangeEnumeratorRawWithLocal

10000

3.501 us

0.0678 us

0.0807 us

ForeachWithRangeEnumeratorRawWithLocal - это как раз вариант с локальной функцией. И... он почти в шесть раз быстрее, чем заинлайненный! Как же так вышло-то?

Смотрим кодген:

В нашем методе локальная функция не заинлайнилась:

Benchmarks.ForeachWithRangeEnumeratorRawWithLocal()
    ...
    L0044: call Benchmarks.<ForeachWithRangeEnumeratorRawWithLocal>g__EnumerateItAll|8_0(RangeEnumerator)

И это нам на руку. Смотрим эту локальную функцию:

    L0000: mov eax, [rcx]
    L0002: mov edx, [rcx+4]
    L0005: mov ecx, [rcx+8]
    L0008: xor r8d, r8d
    L000b: jmp short L0010
    L000d: add r8d, ecx         |
    L0010: add ecx, edx         |
    L0012: cmp ecx, eax         |
    L0014: jne short L000d      |
    L0016: mov eax, r8d
    L0019: ret

Отмеченные четыре строчки,

    L000d: add r8d, ecx
    L0010: add ecx, edx
    L0012: cmp ecx, eax
    L0014: jne short L000d

это и есть наша победа, так как этот код идентичен итерации for-а.

Первые три строчки

    L0000: mov eax, [rcx]
    L0002: mov edx, [rcx+4]
    L0005: mov ecx, [rcx+8]

Выгружают все, что нужно, в регистры, и поэтому так быстро работает наш метод.

Вывод

Во-первых, хоть и желаемое получилось, в реальной жизни проще написать for, чем проверять, догадался ли компилятор положить ваш енумератор в регистры.

Во-вторых, это очень показательный пример, что инлайнинг может сделать не только не лучше, но и сильно хуже (в данном случае он увеличил время работы с 3.5 до 18 us на 10000 итераций).

Спасибо за внимание!

UPD: поведение совершенно разное на винде и линуксе. Например, разбирая WithLocal метод-бенчмарк, у @DistortNeo получился совсем иной от винды кодген. Вот здесь я сделал гист с кодгеном метода WithLocal для винды и линукса. В случае последнего, джит заменил call на jmp, а локальные переменные локальной функции разместил на стеке.

Ссылки

Весь код, который я писал, размещен в репозитории на github.

Бенчилось на винде

Комментарии (21)


  1. WhiteBlackGoose Автор
    01.09.2021 11:43
    +2

    Проводил эксперименты на x64 шестом дотнете, все ссылки с шарплаба тоже настроены x64, хоть там пятый, разницы это не дало (в коде бенчмарка я закомментил аттрибут DisassemblyDiagnoser, с которым вы можете посмотреть именно тот асм, который гоняет бенчмарк)

    Кстати, интересно то, что если поставить Default на шарплабе, т. е. 32-битный рантайм, то мы в последнем эксперименте получили бы:

        L0007: add eax, edx
        L0009: mov edx, [ebp+0x10]
        L000c: add edx, [ebp+0xc]
        L000f: mov [ebp+0x10], edx
        L0012: cmp edx, [ebp+8]
        L0015: jne short L0007

    Так что... используйте 64 бита! :)

    А еще можно попробовать включить Dynamic PGO, запустив бенчмарк так:

    dotnet run -c release --envVars DOTNET_TieredPGO:1 DOTNET_ReadyToRun:0 DOTNET_TC_QuickJitForLoops:1

    Но у меня получились те же результаты, что и в статье, так что я решил оставить это в комментарии.


  1. DistortNeo
    01.09.2021 13:51
    +1

    Странно. У меня получилось немного по-другому:


    BenchmarkDotNet=v0.13.1, OS=arch
    Intel Core i7-2600K CPU 3.40GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores
    .NET SDK=6.0.100-preview.7.21379.14
      [Host]     : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
      DefaultJob : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
    
    |                                 Method | Iterations |      Mean |     Error |    StdDev |
    |--------------------------------------- |----------- |----------:|----------:|----------:|
    |                 ForWithCustomIncrement |      10000 |  4.535 us | 0.0007 us | 0.0007 us |
    |             ForeachWithEnumerableRange |      10000 | 50.097 us | 0.0050 us | 0.0044 us |
    |                 ForeachWithYieldReturn |      10000 | 52.565 us | 0.0055 us | 0.0049 us |
    |             ForeachWithRangeEnumerator |      10000 |  4.453 us | 0.0005 us | 0.0004 us |
    |          ForeachWithRangeEnumeratorRaw |      10000 |  4.453 us | 0.0007 us | 0.0006 us |
    | ForeachWithRangeEnumeratorRawWithLocal |      10000 | 17.543 us | 0.0100 us | 0.0094 us |


    1. WhiteBlackGoose Автор
      01.09.2021 14:44

      Ух ты. А кодген покажете? Там нужно в моем бенчмарке раскомментить DisassemblyDiagnoser, а потом посмотреть Artifacts папку

      А еще я смотрю у вас Линукс, на нем может по-другому такие вещи работать (я на винде). Интересно посмотреть кодген!


      1. DistortNeo
        01.09.2021 16:20
        +1

        Возможно, у нас просто немного разные релизы .NET.
        Вот дизассемблер:


        Benchmarks-asm.md
        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForWithCustomIncrement()
               push      rbp
               mov       rbp,rsp
               mov       eax,[rdi+8]
               xor       esi,esi
               mov       edi,[rdi+0C]
               xor       edx,edx
               test      eax,eax
               jle       short M00_L01
        M00_L00:
               add       esi,edx
               add       edx,edi
               cmp       edx,eax
               jl        short M00_L00
        M00_L01:
               mov       eax,esi
               pop       rbp
               ret
        ; Total bytes of code 30

        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForeachWithEnumerableRange()
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               mov       [rbp-20],rsp
               xor       ebx,ebx
               mov       esi,[rdi+8]
               xor       edi,edi
               call      System.Linq.Enumerable.Range(Int32, Int32)
               mov       rdi,rax
               mov       r11,7FC58A340348
               mov       rax,7FC58A340348
               call      qword ptr [rax]
               mov       rdi,rax
               mov       [rbp-10],rdi
               mov       r11,7FC58A340350
               mov       rax,7FC58A340350
               call      qword ptr [rax]
               test      eax,eax
               je        short M00_L01
        M00_L00:
               mov       rdi,[rbp-10]
               mov       r11,7FC58A340358
               mov       rax,7FC58A340358
               call      qword ptr [rax]
               add       ebx,eax
               mov       rdi,[rbp-10]
               mov       r11,7FC58A340350
               mov       rax,7FC58A340350
               call      qword ptr [rax]
               test      eax,eax
               jne       short M00_L00
        M00_L01:
               mov       rdi,[rbp-10]
               mov       r11,7FC58A340360
               mov       rax,7FC58A340360
               call      qword ptr [rax]
               mov       eax,ebx
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
               push      rbp
               push      rbx
               push      rax
               mov       rbp,[rdi]
               mov       [rsp],rbp
               lea       rbp,[rbp+20]
               cmp       qword ptr [rbp-10],0
               je        short M00_L02
               mov       rdi,[rbp-10]
               mov       r11,7FC58A340360
               mov       rax,7FC58A340360
               call      qword ptr [rax]
        M00_L02:
               nop
               add       rsp,8
               pop       rbx
               pop       rbp
               ret
        ; Total bytes of code 233

        ; System.Linq.Enumerable.Range(Int32, Int32)
               push      rbp
               push      r15
               push      r14
               push      rbx
               push      rax
               lea       rbp,[rsp+20]
               mov       r14d,edi
               mov       ebx,esi
               movsxd    rdi,r14d
               movsxd    rax,ebx
               lea       rdi,[rdi+rax-1]
               test      ebx,ebx
               jl        short M01_L00
               cmp       rdi,7FFFFFFF
               jg        short M01_L00
               test      ebx,ebx
               je        short M01_L01
               mov       rdi,offset MT_System.Linq.Enumerable+RangeIterator
               call      CORINFO_HELP_NEWSFAST
               mov       r15,rax
               call      CORINFO_HELP_GETCURRENTMANAGEDTHREADID
               mov       [r15+8],eax
               mov       [r15+14],r14d
               add       ebx,r14d
               mov       [r15+18],ebx
               mov       rax,r15
               add       rsp,8
               pop       rbx
               pop       r14
               pop       r15
               pop       rbp
               ret
        M01_L00:
               mov       edi,1
               call      System.Linq.ThrowHelper.ThrowArgumentOutOfRangeException(System.Linq.ExceptionArgument)
               int       3
        M01_L01:
               mov       rdi,7FC58B0D0BB0
               mov       esi,2
               call      CORINFO_HELP_CLASSINIT_SHARED_DYNAMICCLASS
               mov       rax,7FC56C005AB8
               mov       rax,[rax]
               add       rsp,8
               pop       rbx
               pop       r14
               pop       r15
               pop       rbp
               ret
        ; Total bytes of code 152

        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForeachWithYieldReturn()
               push      rbp
               push      r15
               push      r14
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+30]
               mov       [rbp-30],rsp
               xor       ebx,ebx
               mov       r14d,[rdi+8]
               dec       r14d
               mov       rdi,offset MT_Benchmarks+<MyRange>d__10
               call      CORINFO_HELP_NEWSFAST
               mov       r15,rax
               mov       dword ptr [r15+8],0FFFFFFFE
               call      CORINFO_HELP_GETCURRENTMANAGEDTHREADID
               mov       [r15+10],eax
               xor       edi,edi
               mov       [r15+18],edi
               mov       [r15+28],r14d
               mov       dword ptr [r15+20],1
               mov       rdi,r15
               mov       r11,7FEE0B690348
               mov       rax,7FEE0B690348
               call      qword ptr [rax]
               mov       r14,rax
               mov       [rbp-20],r14
               mov       rdi,r14
               mov       r11,7FEE0B690350
               mov       rax,7FEE0B690350
               call      qword ptr [rax]
               test      eax,eax
               je        short M00_L01
        M00_L00:
               mov       rdi,r14
               mov       r11,7FEE0B690358
               mov       rax,7FEE0B690358
               call      qword ptr [rax]
               add       ebx,eax
               mov       rdi,r14
               mov       r11,7FEE0B690350
               mov       rax,7FEE0B690350
               call      qword ptr [rax]
               test      eax,eax
               jne       short M00_L00
        M00_L01:
               mov       rdi,r14
               mov       r11,7FEE0B690360
               mov       rax,7FEE0B690360
               call      qword ptr [rax]
               mov       eax,ebx
               add       rsp,18
               pop       rbx
               pop       r14
               pop       r15
               pop       rbp
               ret
               push      rbp
               push      r15
               push      r14
               push      rbx
               push      rax
               mov       rbp,[rdi]
               mov       [rsp],rbp
               lea       rbp,[rbp+30]
               cmp       qword ptr [rbp-20],0
               je        short M00_L02
               mov       rdi,[rbp-20]
               mov       r11,7FEE0B690360
               mov       rax,7FEE0B690360
               call      qword ptr [rax]
        M00_L02:
               nop
               add       rsp,8
               pop       rbx
               pop       r14
               pop       r15
               pop       rbp
               ret
        ; Total bytes of code 299

        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForeachWithRangeEnumerator()
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               xor       ebx,ebx
               mov       edi,[rdi+8]
               dec       edi
               test      edi,edi
               jl        short M00_L02
               mov       [rbp-20],ebx
               mov       [rbp-1C],edi
               mov       rdi,[rbp-20]
               call      Library.Extensions.GetEnumerator(System.Range)
               mov       [rbp-18],rax
               mov       [rbp-10],edx
               mov       eax,[rbp-18]
               mov       edi,[rbp-14]
               mov       esi,[rbp-10]
               add       esi,edi
               cmp       esi,eax
               je        short M00_L01
               nop       dword ptr [rax+rax]
        M00_L00:
               add       ebx,esi
               add       esi,edi
               cmp       esi,eax
               jne       short M00_L00
        M00_L01:
               mov       eax,ebx
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
        M00_L02:
               call      System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()
               int       3
        ; Total bytes of code 87

        ; Library.Extensions.GetEnumerator(System.Range)
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               xor       eax,eax
               mov       [rbp-20],rax
               mov       [rbp-18],rax
               mov       [rbp-10],rdi
               mov       edi,[rbp-10]
               mov       eax,[rbp-0C]
               test      edi,edi
               jge       short M01_L01
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               test      eax,eax
               jge       short M01_L00
               mov       edi,eax
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L00:
               jmp       short M01_L03
        M01_L01:
               test      eax,eax
               jge       short M01_L02
               not       eax
               test      eax,eax
               jne       short M01_L08
               dec       edi
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       [rbp-18],edi
               jmp       short M01_L07
        M01_L02:
               jmp       short M01_L04
        M01_L03:
               add       eax,2
               mov       [rbp-20],eax
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L04:
               cmp       edi,eax
               jge       short M01_L05
               inc       eax
               dec       edi
               mov       esi,1
               jmp       short M01_L06
        M01_L05:
               dec       eax
               inc       edi
               mov       esi,0FFFFFFFF
        M01_L06:
               mov       [rbp-20],eax
               mov       [rbp-1C],esi
               mov       [rbp-18],edi
        M01_L07:
               mov       rax,[rbp-20]
               mov       edx,[rbp-18]
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
        M01_L08:
               mov       rdi,offset MT_System.InvalidOperationException
               call      CORINFO_HELP_NEWSFAST
               mov       rbx,rax
               mov       edi,1
               mov       rsi,7F33C40AC9F8
               call      CORINFO_HELP_STRCNS
               mov       rsi,rax
               mov       rdi,rbx
               call      System.InvalidOperationException..ctor(System.String)
               mov       rdi,rbx
               call      CORINFO_HELP_THROW
               int       3
        ; Total bytes of code 246

        Method was not JITted yet.
        System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()


        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForeachWithRangeEnumeratorRaw()
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               xor       ebx,ebx
               mov       edi,[rdi+8]
               dec       edi
               test      edi,edi
               jl        short M00_L02
               mov       [rbp-20],ebx
               mov       [rbp-1C],edi
               mov       rdi,[rbp-20]
               call      Library.Extensions.GetEnumerator(System.Range)
               mov       [rbp-18],rax
               mov       [rbp-10],edx
               mov       eax,[rbp-18]
               mov       edi,[rbp-14]
               mov       esi,[rbp-10]
               add       esi,edi
               cmp       esi,eax
               je        short M00_L01
               nop       dword ptr [rax+rax]
        M00_L00:
               add       ebx,esi
               add       esi,edi
               cmp       esi,eax
               jne       short M00_L00
        M00_L01:
               mov       eax,ebx
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
        M00_L02:
               call      System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()
               int       3
        ; Total bytes of code 87

        ; Library.Extensions.GetEnumerator(System.Range)
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               xor       eax,eax
               mov       [rbp-20],rax
               mov       [rbp-18],rax
               mov       [rbp-10],rdi
               mov       edi,[rbp-10]
               mov       eax,[rbp-0C]
               test      edi,edi
               jge       short M01_L01
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               test      eax,eax
               jge       short M01_L00
               mov       edi,eax
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L00:
               jmp       short M01_L03
        M01_L01:
               test      eax,eax
               jge       short M01_L02
               not       eax
               test      eax,eax
               jne       short M01_L08
               dec       edi
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       [rbp-18],edi
               jmp       short M01_L07
        M01_L02:
               jmp       short M01_L04
        M01_L03:
               add       eax,2
               mov       [rbp-20],eax
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L04:
               cmp       edi,eax
               jge       short M01_L05
               inc       eax
               dec       edi
               mov       esi,1
               jmp       short M01_L06
        M01_L05:
               dec       eax
               inc       edi
               mov       esi,0FFFFFFFF
        M01_L06:
               mov       [rbp-20],eax
               mov       [rbp-1C],esi
               mov       [rbp-18],edi
        M01_L07:
               mov       rax,[rbp-20]
               mov       edx,[rbp-18]
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
        M01_L08:
               mov       rdi,offset MT_System.InvalidOperationException
               call      CORINFO_HELP_NEWSFAST
               mov       rbx,rax
               mov       edi,1
               mov       rsi,7FB2CB80C9F8
               call      CORINFO_HELP_STRCNS
               mov       rsi,rax
               mov       rdi,rbx
               call      System.InvalidOperationException..ctor(System.String)
               mov       rdi,rbx
               call      CORINFO_HELP_THROW
               int       3
        ; Total bytes of code 246

        Method was not JITted yet.
        System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()


        .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
        ; Benchmarks.ForeachWithRangeEnumeratorRawWithLocal()
               push      rbp
               sub       rsp,20
               lea       rbp,[rsp+20]
               mov       edi,[rdi+8]
               dec       edi
               test      edi,edi
               jl        short M00_L00
               xor       eax,eax
               mov       [rbp-18],eax
               mov       [rbp-14],edi
               mov       rdi,[rbp-18]
               call      Library.Extensions.GetEnumerator(System.Range)
               mov       [rbp-10],rax
               mov       [rbp-8],edx
               mov       rdi,[rbp-10]
               mov       esi,[rbp-8]
               add       rsp,20
               pop       rbp
               jmp       near ptr Benchmarks.<ForeachWithRangeEnumeratorRawWithLocal>g__EnumerateItAll|14_0(Library.RangeEnumerator)
        M00_L00:
               call      System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()
               int       3
        ; Total bytes of code 66

        ; Library.Extensions.GetEnumerator(System.Range)
               push      rbp
               push      rbx
               sub       rsp,18
               lea       rbp,[rsp+20]
               xor       eax,eax
               mov       [rbp-20],rax
               mov       [rbp-18],rax
               mov       [rbp-10],rdi
               mov       edi,[rbp-10]
               mov       eax,[rbp-0C]
               test      edi,edi
               jge       short M01_L01
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               test      eax,eax
               jge       short M01_L00
               mov       edi,eax
               not       edi
               test      edi,edi
               jne       near ptr M01_L08
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L00:
               jmp       short M01_L03
        M01_L01:
               test      eax,eax
               jge       short M01_L02
               not       eax
               test      eax,eax
               jne       short M01_L08
               dec       edi
               mov       dword ptr [rbp-20],80000000
               mov       dword ptr [rbp-1C],1
               mov       [rbp-18],edi
               jmp       short M01_L07
        M01_L02:
               jmp       short M01_L04
        M01_L03:
               add       eax,2
               mov       [rbp-20],eax
               mov       dword ptr [rbp-1C],1
               mov       dword ptr [rbp-18],0FFFFFFFF
               jmp       short M01_L07
        M01_L04:
               cmp       edi,eax
               jge       short M01_L05
               inc       eax
               dec       edi
               mov       esi,1
               jmp       short M01_L06
        M01_L05:
               dec       eax
               inc       edi
               mov       esi,0FFFFFFFF
        M01_L06:
               mov       [rbp-20],eax
               mov       [rbp-1C],esi
               mov       [rbp-18],edi
        M01_L07:
               mov       rax,[rbp-20]
               mov       edx,[rbp-18]
               add       rsp,18
               pop       rbx
               pop       rbp
               ret
        M01_L08:
               mov       rdi,offset MT_System.InvalidOperationException
               call      CORINFO_HELP_NEWSFAST
               mov       rbx,rax
               mov       edi,1
               mov       rsi,7FCE847FCE78
               call      CORINFO_HELP_STRCNS
               mov       rsi,rax
               mov       rdi,rbx
               call      System.InvalidOperationException..ctor(System.String)
               mov       rdi,rbx
               call      CORINFO_HELP_THROW
               int       3
        ; Total bytes of code 246

        ; Benchmarks.<ForeachWithRangeEnumeratorRawWithLocal>g__EnumerateItAll|14_0(Library.RangeEnumerator)
               push      rbp
               sub       rsp,10
               lea       rbp,[rsp+10]
               mov       [rbp-10],rdi
               mov       [rbp-8],esi
               xor       eax,eax
               mov       edi,[rbp-8]
               add       edi,[rbp-0C]
               mov       [rbp-8],edi
               mov       esi,[rbp-10]
               cmp       edi,esi
               je        short M02_L01
        M02_L00:
               mov       edi,[rbp-8]
               add       eax,edi
               add       edi,[rbp-0C]
               mov       [rbp-8],edi
               cmp       edi,esi
               jne       short M02_L00
        M02_L01:
               add       rsp,10
               pop       rbp
               ret
        ; Total bytes of code 56

        Method was not JITted yet.
        System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()


        1. WhiteBlackGoose Автор
          01.09.2021 16:27

          Интересно. В последнем методе (который WithLocal) у вас JIT решил использовать jmp вместо call-а:

          ...
          jmp       near ptr Benchmarks.<ForeachWithRangeEnumeratorRawWithLocal>g__EnumerateItAll|14_0(Library.RangeEnumerator)
          ...

          Поэтому итерация цикла в локальной функции выглядит как

          M02_L00:
                 mov       edi,[rbp-8]
                 add       eax,edi
                 add       edi,[rbp-0C]
                 mov       [rbp-8],edi
                 cmp       edi,esi
                 jne       short M02_L00

          А вот там, где у меня он разместил структуру на стеке, у вас он смог разместить ее в регистрах:

          M00_L00:
                 add       ebx,esi
                 add       esi,edi
                 cmp       esi,eax
                 jne       short M00_L00

          Видимо связано это с тем, что на линуксе джит может больше локальных переменных в регистры положить, но я не уверен, может тут есть эксперты?

          А насчет jmp-а получается, что джит ошибся, и все-таки нужен был call. Интересно, а если вы добавите [MethodImpl(MethodImplOptions.NoInlining)] на локальную функцию, мы получим call?


          1. KislyFan
            01.09.2021 16:53

            BenchmarkDotNet=v0.13.1, OS=ubuntu 21.04
            AMD FX(tm)-8120, 1 CPU, 8 logical and 4 physical cores
            .NET SDK=5.0.400
              [Host]     : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
              DefaultJob : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
            
            
            |                                 Method | Iterations |      Mean |     Error |     StdDev |    Median |
            |--------------------------------------- |----------- |----------:|----------:|-----------:|----------:|
            |                 ForWithCustomIncrement |      10000 |  5.911 us | 0.0171 us |  0.0152 us |  5.906 us |
            |             ForeachWithEnumerableRange |      10000 | 68.460 us | 1.3414 us |  1.5968 us | 67.668 us |
            |                 ForeachWithYieldReturn |      10000 | 89.176 us | 4.1332 us | 11.7252 us | 84.396 us |
            |             ForeachWithRangeEnumerator |      10000 |  6.087 us | 0.0410 us |  0.0364 us |  6.076 us |
            |          ForeachWithRangeEnumeratorRaw |      10000 |  9.171 us | 0.0918 us |  0.0766 us |  9.152 us |
            | ForeachWithRangeEnumeratorRawWithLocal |      10000 | 27.249 us | 0.1797 us |  0.1501 us | 27.258 us |
            
            

            Это без атрибута NoInlining, а это с ней

            BenchmarkDotNet=v0.13.1, OS=ubuntu 21.04
            AMD FX(tm)-8120, 1 CPU, 8 logical and 4 physical cores
            .NET SDK=5.0.400
              [Host]     : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
              DefaultJob : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
            
            
            |                                 Method | Iterations |      Mean |     Error |    StdDev |
            |--------------------------------------- |----------- |----------:|----------:|----------:|
            |                 ForWithCustomIncrement |      10000 |  6.106 us | 0.0520 us | 0.0461 us |
            |             ForeachWithEnumerableRange |      10000 | 70.015 us | 1.3127 us | 1.1637 us |
            |                 ForeachWithYieldReturn |      10000 | 77.383 us | 1.5419 us | 2.0584 us |
            |             ForeachWithRangeEnumerator |      10000 |  6.115 us | 0.0234 us | 0.0208 us |
            |          ForeachWithRangeEnumeratorRaw |      10000 |  6.128 us | 0.0349 us | 0.0292 us |
            | ForeachWithRangeEnumeratorRawWithLocal |      10000 | 27.460 us | 0.2394 us | 0.1999 us |
            
            // * Hints *
            Outliers
              Benchmarks.ForWithCustomIncrement: Default                 -> 1 outlier  was  removed (6.26 us)
              Benchmarks.ForeachWithEnumerableRange: Default             -> 1 outlier  was  removed (76.72 us)
              Benchmarks.ForeachWithYieldReturn: Default                 -> 2 outliers were removed (86.81 us, 91.80 us)
              Benchmarks.ForeachWithRangeEnumerator: Default             -> 1 outlier  was  removed (6.18 us)
              Benchmarks.ForeachWithRangeEnumeratorRaw: Default          -> 2 outliers were removed (6.23 us, 6.24 us)
              Benchmarks.ForeachWithRangeEnumeratorRawWithLocal: Default -> 2 outliers were removed (28.46 us, 29.80 us)
            

            me@Bella:~$ uname -a Linux Bella 5.11.0-34-generic #36-Ubuntu SMP Thu Aug 26 19:22:09 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

            Ну и собственно вопрос, где "научиться плохому" или хорошо разбираться в IL?


            1. WhiteBlackGoose Автор
              01.09.2021 17:35

              Так, я на всякий случай уточню - вы сюда

              [Benchmark]
              // НЕ СЮДА
              public int ForeachWithRangeEnumeratorRawWithLocal()
              {
                  var enumerator = (0..(Iterations - 1)).GetEnumerator();        
                  return EnumerateItAll(enumerator);
              
                  // А СЮДА?
                  static int EnumerateItAll(RangeEnumerator enumerator)
                  {
                      var a = 0;
                      while (enumerator.MoveNext())
                          a += enumerator.Current;
                      return a;
                  }
              }

              вешали? Т. е. не на внешнюю функцию, а на локальную?

              хорошо разбираться в IL?

              На самом деле IL мы тут вообще не изучали. В основном здесь все от джита зависит


              1. KislyFan
                01.09.2021 20:44

                Совершенно верно

                [Benchmark]
                public int ForeachWithRangeEnumeratorRawWithLocal()
                {
                    var enumerator = (0..(Iterations - 1)).GetEnumerator();        
                    return EnumerateItAll(enumerator);
                
                    [MethodImpl(MethodImplOptions.NoInlining)]
                    static int EnumerateItAll(RangeEnumerator enumerator)
                    {
                        var a = 0;
                        while (enumerator.MoveNext())
                            a += enumerator.Current;
                        return a;
                    }
                }

                Пересобрал с разкомментированным DisassemblyDiagnoser, еще раз приведу результат

                BenchmarkDotNet=v0.13.1, OS=ubuntu 21.04
                AMD FX(tm)-8120, 1 CPU, 8 logical and 4 physical cores
                .NET SDK=5.0.400
                  [Host]     : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
                  DefaultJob : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT
                
                
                |                                 Method | Iterations |      Mean |     Error |    StdDev | Code Size |
                |--------------------------------------- |----------- |----------:|----------:|----------:|----------:|
                |                 ForWithCustomIncrement |      10000 |  6.125 us | 0.0586 us | 0.0549 us |      30 B |
                |             ForeachWithEnumerableRange |      10000 | 69.712 us | 1.1155 us | 1.2399 us |     433 B |
                |                 ForeachWithYieldReturn |      10000 | 77.253 us | 1.4753 us | 2.0681 us |     365 B |
                |             ForeachWithRangeEnumerator |      10000 |  6.171 us | 0.0640 us | 0.0567 us |     340 B |
                |          ForeachWithRangeEnumeratorRaw |      10000 |  6.228 us | 0.0513 us | 0.0480 us |     340 B |
                | ForeachWithRangeEnumeratorRawWithLocal |      10000 | 27.767 us | 0.1562 us | 0.1461 us |     370 B |
                

                Мне честно говоря было лень копировать простыню под кат, загрузил на github https://github.com/iamkisly/ForHabrahabr/blob/main/FastEnum_with_NoInlining_attr_01092021_2040/Benchmarks-asm.md#net-509-5092135908-x64-ryujit-5


                1. WhiteBlackGoose Автор
                  01.09.2021 20:49

                  Удивительно. У человека сверху хотя бы я объяснял это тем, что там джамп вместо колла. Почему здесь джит не смог сделать что надо, при том, что там колл - непонятно. Может на шестом дотнете получится?


                  1. KislyFan
                    01.09.2021 23:49

                    Результат почти не поменялся, ну в пределах флуктуаций.

                    BenchmarkDotNet=v0.13.1, OS=ubuntu 21.04
                    AMD FX(tm)-8120, 1 CPU, 8 logical and 4 physical cores
                    .NET SDK=6.0.100-preview.7.21379.14
                      [Host]     : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
                      DefaultJob : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
                    
                    
                    |                                 Method | Iterations |      Mean |     Error |    StdDev | Code Size |
                    |--------------------------------------- |----------- |----------:|----------:|----------:|----------:|
                    |                 ForWithCustomIncrement |      10000 |  6.147 us | 0.0387 us | 0.0343 us |      30 B |
                    |             ForeachWithEnumerableRange |      10000 | 64.961 us | 0.7376 us | 0.6539 us |     385 B |
                    |                 ForeachWithYieldReturn |      10000 | 79.919 us | 0.7037 us | 0.5876 us |     299 B |
                    |             ForeachWithRangeEnumerator |      10000 |  6.182 us | 0.0260 us | 0.0243 us |     333 B |
                    |          ForeachWithRangeEnumeratorRaw |      10000 |  6.199 us | 0.0362 us | 0.0338 us |     333 B |
                    | ForeachWithRangeEnumeratorRawWithLocal |      10000 | 28.486 us | 0.2395 us | 0.2000 us |     368 B |
                    

                    Линк на сренерированный markdown


          1. KislyFan
            01.09.2021 17:02

            С использованием [MethodImpl(MethodImplOptions.AggressiveInlining)]

            |                                 Method | Iterations |      Mean |     Error |    StdDev |
            |--------------------------------------- |----------- |----------:|----------:|----------:|
            |                 ForWithCustomIncrement |      10000 |  6.038 us | 0.0337 us | 0.0316 us |
            |             ForeachWithEnumerableRange |      10000 | 63.210 us | 0.8231 us | 0.7699 us |
            |                 ForeachWithYieldReturn |      10000 | 78.043 us | 1.2545 us | 1.1735 us |
            |             ForeachWithRangeEnumerator |      10000 |  6.090 us | 0.0416 us | 0.0369 us |
            |          ForeachWithRangeEnumeratorRaw |      10000 |  6.094 us | 0.0239 us | 0.0224 us |
            | ForeachWithRangeEnumeratorRawWithLocal |      10000 |  9.214 us | 0.0637 us | 0.0532 us |
            


          1. DistortNeo
            01.09.2021 17:14

            Перегрузился в Windows — всё стало как у вас. Также я вообще не заметил никакого эффекта ни от [MethodImpl(MethodImplOptions.NoInlining)], ни от [MethodImpl(MethodImplOptions.AggressiveInlining)]. В Linux не пробовал.


            1. WhiteBlackGoose Автор
              01.09.2021 17:32

              Я говорю именно о том, чтобы повесить NoInlining на локальную функцию в последнем методе на линуксе. Кажется, тогда тоже будет так же быстро, как фор


  1. Daniil_Palii
    01.09.2021 14:22

    Получилось бы внедрить такую оптимизацию в Рослин?


    1. WhiteBlackGoose Автор
      01.09.2021 14:47
      +1

      Вряд ли, Розлин не занимается оптимизациями. А вот RyuJIT с помощью Dynamic PGO имеет потенциал сделать магию, на ходу поменяв код, выгружая в регистры "горячие" локальные переменные.


  1. Timofeuz
    02.09.2021 10:26
    +1

    foreach (var i in 1..5)

    Console.Write(i);

    (выводит 12345)

    Каноничней выводить 1234.


    1. WhiteBlackGoose Автор
      02.09.2021 10:30
      +2

      Это по каким канонам "каноничнее"?


      1. Timofeuz
        02.09.2021 11:08
        -2

        Всем, где нумеруют индексы с нуля.


      1. orion_tvv
        02.09.2021 13:03

        Во многих языках правая граница не включена в отрезок(питон, раст, свифт, етц) При таком подходе апи становится более стройнее, особенно если нужно еще ходить по самого конца


      1. Piskov
        04.09.2021 04:11

        Даже в шарпе 1..5 — 5 не входит в интервал — см синтакс для range based indices.

        Во всех других языках аналогично: правая граница не включена.

        В сфите, например, для включения ее в интервал нужно написать вместо двух три точки: 1…5 vs 1..5


        1. WhiteBlackGoose Автор
          04.09.2021 09:40

          Однако F# согласен со мной, как и дельфи.

          Дело вкуса

          cc @orion_tvv, @Timofeuz