Привет! Хочу рассказать об интересном опыте, как я писал енумератор для типа 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 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()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, запустив бенчмарк так:
Но у меня получились те же результаты, что и в статье, так что я решил оставить это в комментарии.