Ускоряем цикл foreach до for
Привет! Хочу рассказать об интересном опыте, как я писал енумератор для типа 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 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 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.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 итераций).
Спасибо за внимание!
Ссылки
Весь код, который я писал, размещен в репозитории на github.