Привет! Хочу рассказать об интересном опыте, как я писал енумератор для типа 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.
Тесты, если есть подозрение на неправильный код
BenchmarkDotNet - утилита/библиотека для бенчинга
Бенчилось на винде
Комментарии (21)

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 |
WhiteBlackGoose Автор
01.09.2021 14:44Ух ты. А кодген покажете? Там нужно в моем бенчмарке раскомментить DisassemblyDiagnoser, а потом посмотреть Artifacts папку
А еще я смотрю у вас Линукс, на нем может по-другому такие вещи работать (я на винде). Интересно посмотреть кодген!

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 246Method 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 246Method 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 56Method was not JITted yet.
System.ThrowHelper.ThrowValueArgumentOutOfRange_NeedNonNegNumException()
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?
KislyFan
01.09.2021 16:53BenchmarkDotNet=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?

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 мы тут вообще не изучали. В основном здесь все от джита зависит

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

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

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

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 |

DistortNeo
01.09.2021 17:14Перегрузился в Windows — всё стало как у вас. Также я вообще не заметил никакого эффекта ни от
[MethodImpl(MethodImplOptions.NoInlining)], ни от[MethodImpl(MethodImplOptions.AggressiveInlining)]. В Linux не пробовал.
WhiteBlackGoose Автор
01.09.2021 17:32Я говорю именно о том, чтобы повесить NoInlining на локальную функцию в последнем методе на линуксе. Кажется, тогда тоже будет так же быстро, как фор

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

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

Timofeuz
02.09.2021 10:26+1foreach (var i in 1..5)
Console.Write(i);
(выводит 12345)
Каноничней выводить 1234.

WhiteBlackGoose Автор
02.09.2021 10:30+2Это по каким канонам "каноничнее"?

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

Piskov
04.09.2021 04:11Даже в шарпе 1..5 — 5 не входит в интервал — см синтакс для range based indices.
Во всех других языках аналогично: правая граница не включена.
В сфите, например, для включения ее в интервал нужно написать вместо двух три точки: 1…5 vs 1..5

WhiteBlackGoose Автор
04.09.2021 09:40
          
 
WhiteBlackGoose Автор
Проводил эксперименты на x64 шестом дотнете, все ссылки с шарплаба тоже настроены x64, хоть там пятый, разницы это не дало (в коде бенчмарка я закомментил аттрибут DisassemblyDiagnoser, с которым вы можете посмотреть именно тот асм, который гоняет бенчмарк)
Кстати, интересно то, что если поставить Default на шарплабе, т. е. 32-битный рантайм, то мы в последнем эксперименте получили бы:
Так что... используйте 64 бита! :)
А еще можно попробовать включить Dynamic PGO, запустив бенчмарк так:
Но у меня получились те же результаты, что и в статье, так что я решил оставить это в комментарии.