Бенчмаркая ZLinq: один IEnumerable в сигнатуре — и .NET 10 быстрее библиотеки в 3,9 раза

от автора

Уважаемые читатели, в этой статье я хочу разобраться, что происходит с ZLinq, когда параметр метода объявлен интерфейсом, и представить свои выводы.

ZLinq — замена LINQ, но без аллокаций. Один и тот же перебор можно записать двумя способами:

  • foreach (int item in source);

  • foreach (int item in source.AsValueEnumerable()) — то же самое, но на структурах вместо объектов в куче.

Данные одни и те же. Отличается тип параметра, которым метод их принимает: int[], List<int> или IEnumerable<int>. На .NET 8 и .NET 9 оба способа давали одно время при любом из этих типов. На .NET 10 у IEnumerable<int> первый способ обошёл второй в 2,58–3,89 раза. 

Будет 4 истории:

  • что меняет тип параметра;

  • почему на .NET 8 и .NET 9 разницы нет;

  • что изменилось — библиотека или рантайм;

  • сколько памяти выделяет каждый из них.

Код — в репозитории ZLinqSourceKindProof. Четыре машины:

CPU

Ядра/потоки

Частота

ОС

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц, до 4,9 в турбо

Windows 10 1809

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц, до 5,3 в турбо

Windows 10 22H2

№3

2 × Intel Xeon Silver 4314

2×16 / 64

2,4 ГГц, до 3,4 в турбо

Windows Server 2022

№4

Intel Xeon W-2255

10 / 20

3,7 ГГц, до 4,5 в турбо

Windows Server 2022

ZLinq 1.5.6, BenchmarkDotNet 0.15.8, Release, .NET 8, 9 и 10. Числа в таблицах — до двух знаков. Полные отчёты в репозитории.

История 1. Что меняет тип параметра

Один массив на 1 024 элемента, перебор без единого оператора. Данные во всех строках одни и те же — тот самый int[]. Меняется только то, каким типом метод его принимает. Оба метода — в Subjects.cs:

// ZLinqSourceKindProof, Subjects.cs[MethodImpl(MethodImplOptions.NoInlining)]public static int PlainForeachEnumerable(IEnumerable<int> source){    int total = 0;    foreach (int item in source)    {        total += item;    }    return total;}[MethodImpl(MethodImplOptions.NoInlining)]public static int ZLinqForeachEnumerable(IEnumerable<int> source){    int total = 0;    foreach (int item in source.AsValueEnumerable())    {        total += item;    }    return total;}

Шестая строка таблицы — метод с yield return. Он возвращает элементы по одному, массива или списка за ним нет, и определение фактического типа тут ничего не меняет ни для первого способа, ни для второго. Строка взята как нижняя граница.

Что меряем

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

int[]

0,98

1,00

1,00

1,00

List<int>

0,98

0,99

0,85

0,99

IEnumerable, за ним массив

3,89

3,83

2,58

3,79

IEnumerable, за ним список

2,65

2,91

3,06

1,18

IReadOnlyList, за ним массив

3,99

3,81

2,62

3,79

метод с yield return

1,59

1,59

1,23

1,60

Отношение ZLinq к foreach без библиотеки, массив на 1 024 элемента, .NET 10. За 1 принят тот же перебор без AsValueEnumerable

Из таблицы можно сделать выводы:

  • на массиве разницы нет: 0,98–1,00;

  • на списке ZLinq немного быстрее: 0,85–0,99;

  • тот же массив, когда параметр объявлен как IEnumerable, — 2,58–3,89;

  • с типом IReadOnlyList — 2,62–3,99;

  • тот же список, принятый как IEnumerable, — 1,18–3,06;

  • на методе с yield return — 1,23–1,60.

Разница появляется там, где параметр объявлен интерфейсом, а метод получает массив или список. Причина — в выборе перегрузки, AsValueEnumerable.cs:

// Cysharp/ZLinq, src/ZLinq/Linq/AsValueEnumerable.cs// сокращеноpublic static ValueEnumerable<FromEnumerable<T>, T> AsValueEnumerable<T>(this IEnumerable<T> source)public static ValueEnumerable<FromArray<T>, T> AsValueEnumerable<T>(this T[] source)public static ValueEnumerable<FromList<T>, T> AsValueEnumerable<T>(this List<T> source)

Перегрузка выбирается по объявленному типу, а не по фактическому. Массив, принятый как IEnumerable<int>, попадает в FromEnumerable, и фактический тип проверяется во время работы:

// Cysharp/ZLinq, src/ZLinq/Linq/AsValueEnumerable.cs// сокращеноpublic struct FromEnumerable<T> : IValueEnumerator<T>{    readonly CollectionIterator<T> iterator;    FromEnumerableContent content;    public FromEnumerable(IEnumerable<T> source)    {        if (source.GetType() == typeof(T[]))        {            this.iterator = ArrayIterator<T>.Instance;        }        else if (source.GetType() == typeof(List<T>))        {            this.iterator = ListIterator<T>.Instance;        }        else if (source is IReadOnlyList<T>)        {            this.iterator = IReadOnlyListIterator<T>.Instance;        }        // ... остальные ветки    }}

Проверка типа выполняется один раз, при создании, и на тысяче элементов её не видно. Поле iterator — ссылка на класс, и через него проходит обращение к каждому элементу.

Размер структуры источника показывает то же самое — отчёт sizes из репозитория:

// ZLinqSourceKindProof, вывод dotnet run -c Release -f net10.0 -- sizes=== Размер структуры источника без операторов ===Объявление            Байтint[]                   16List<int>               16IEnumerable<int>        24IReadOnlyList<int>      24итератор                24Одинаковый размер у последних трёх строк означает один и тот же перечислитель.

На четырёх элементах разница меньше, 1,93–3,49: часть работы библиотека делает один раз при создании запроса, и на четырёх элементах эта работа занимает заметную часть общего времени, а на тысяче — нет.

История 2. Почему на .NET 8 и .NET 9 разницы нет

Тот же замер и та же строка таблицы — массив с параметром-интерфейсом, — но на трёх рантаймах.

На .NET 8 и .NET 9 оба способа идут одинаково, на .NET 10 библиотека отстаёт в 2,58–3,89 раза

На .NET 8 и .NET 9 оба способа идут одинаково, на .NET 10 библиотека отстаёт в 2,58–3,89 раза

Из графика можно сделать выводы:

  • на .NET 8 отношение 1,00–1,17;

  • на .NET 9 — 0,87–1,03, то есть ZLinq немного быстрее;

  • на .NET 10 — 2,58–3,89.

Ни библиотека, ни измеряемый код между рантаймами не менялись: пакет один, ZLinq 1.5.6, исходники методов те же. Изменился машинный код, который выдаёт компилятор.

История 3. Что изменилось — библиотека или рантайм

Из отношения не видно, которая из двух строк изменилась. Те же замеры:

Foreach без библиотеки на .NET 10 быстрее, чем на .NET 8, в 3,6–4,2 раза. У ZLinq за три версии отношение 1,0–1,6

Foreach без библиотеки на .NET 10 быстрее, чем на .NET 8, в 3,6–4,2 раза. У ZLinq за три версии отношение 1,0–1,6

Из графика можно сделать выводы:

  • foreach без библиотеки: 898–2 408 нс на .NET 8, 251–577 нс на .NET 10;

  • ZLinq на тех же данных: 1 051–2 451 нс и 976–1 488 нс;

  • на .NET 10 foreach без библиотеки сравнялся с перебором того же массива без интерфейса: отношение 0,93–1,16.

На методе с yield return, где массива или списка нет, такого не происходит.

Что меряем

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

yield return, .NET 8

1 262

1 268

2 050

1 501

yield return, .NET 9

1 312

1 369

2 692

1 604

yield return, .NET 10

1 261

1 281

2 509

1 708

Наносекунд на 1 024 элемента, foreach без библиотеки, параметр объявлен интерфейсом, внутри метод с yield return

Между .NET 8 и .NET 10 отношение 1,00–1,22 — на двух машинах из четырёх стало медленнее. Ускорение получил только тот случай, где метод принял массив или список как интерфейс.

Машинный код показывает, что именно поменялось. Листинги сняты проектом Disasm из репозитория, по одному виду источника на процесс. На .NET 8 метод начинается с вызова GetEnumerator через интерфейс:

// ZLinqSourceKindProof, Disasm, disasm-Enumerable-net8.0.txt// PlainForeachEnumerable, Tier1, optimized using Dynamic PGO, сокращеноG_M000_IG02:       xor      ebx, ebx       mov      r11, 0xD1FFAB1E       call     [r11]System.Collections.Generic.IEnumerable`1[int]:GetEnumerator()G_M000_IG04:       mov      r8, rcx       mov      r10d, dword ptr [r8+0x08]       cmp      r10d, dword ptr [r8+0x0C]       jae      G_M000_IG19       mov      r14, gword ptr [r8+0x10]       mov      eax, r10d       mov      r15d, dword ptr [r14+4*rax+0x10]

Сначала создаётся перечислитель, потом цикл работает с его полями — индекс, длина, ссылка на внутренний массив. На .NET 10 этого вызова нет:

// ZLinqSourceKindProof, Disasm, disasm-Enumerable-net10.0.txt// PlainForeachEnumerable, Tier1, optimized using Synthesized PGO, сокращеноG_M000_IG02:       xor      ebx, ebx       mov      rax, 0xD1FFAB1E       cmp      qword ptr [rcx], rax       jne      SHORT G_M000_IG07       mov      eax, dword ptr [rcx+0x08]G_M000_IG03:       cmp      edx, eax       jae      SHORT G_M000_IG06       mov      r8d, edx       add      ebx, dword ptr [rcx+4*r8+0x10]       inc      edx       cmp      edx, eax       jb       SHORT G_M000_IG03

Сверяется таблица методов источника, и если это массив — цикл читает его напрямую: длина по смещению 0x08, элемент по 0x10. Вызовы GetEnumerator, MoveNext и get_Current вынесены в ветку G_M000_IG07, куда переход идёт только при несовпадении. Размер метода: 469 байт на .NET 8, 263 на .NET 9, 193 на .NET 10, одинаково на четырёх машинах.

Число 0xD1FFAB1E в обоих листингах — не адрес. Это заглушка: при снятии листинга проектом Disasm задана переменная DOTNET_JitDisasmDiffable, и тогда компилятор заменяет ею любые указатели, чтобы листинги с разных прогонов совпадали побайтово — jitconfigvalues.h и emitxarch.cpp. Здесь она закрывает адрес таблицы методов.

В шапках листингов записано и то, чем пользовался компилятор: на .NET 8 и .NET 9 это Dynamic PGO, на .NET 10 — Synthesized PGO. Так на всех четырёх машинах.

История 4. Что стало с памятью

В README библиотеки нулевые аллокации заявлены и для коллекции, объявленной интерфейсом:

// Cysharp/ZLinq, README.mdWhen a type is declared as IEnumerable<T> or ICollection<T> rather than concretetypes like T[] or List<T>, generally additional allocations occur when usingforeach. In ZLinq, even when these interfaces are declared, if the actual typeis T[] or List<T>, processing is performed with zero allocation.

У ZLinq ноль на всех четырёх машинах и всех трёх рантаймах. В таблице — foreach без библиотеки. Те же числа даёт и второй счётчик, отчёт alloc из репозитория: он берёт выделенную память счётчиком рантайма, а не диагностикой BenchmarkDotNet, и сходится с ней во всех 72 значениях.

Что меряем

.NET 8

.NET 9

.NET 10

int[]

0

0

0

List<int>

0

0

0

IEnumerable, за ним массив

32

32

0

IEnumerable, за ним список

40

40

0

IReadOnlyList, за ним массив

32

32

0

метод с yield return

0

0

0

Выделено байт на вызов foreach без библиотеки, массив на 1 024 элемента. Числа на четырёх машинах совпали, поэтому в столбцах версии рантайма, а не машины

Из таблицы можно сделать выводы:

  • на .NET 8 и .NET 9 foreach без библиотеки с параметром-интерфейсом создаёт перечислитель: 32 байта на массиве, 40 на списке;

  • на .NET 10 в тех же строках ноль;

  • на массиве и списке напрямую ноль на всех трёх рантаймах;

  • на методе с yield return тоже ноль на всех трёх.

Это тот же машинный код из третьей истории. Раз перечислитель на .NET 10 не создаётся, то и выделять под него нечего. Здесь библиотека уступает и по времени, и по памяти.

Так на переборе без единого оператора. С Where объект под запрос создаётся на всех трёх рантаймах: 48 байт на массиве, 72 на списке, 56 на методе с yield return. У ZLinq в этих же строках ноль.

Стоит добавить в запрос хоть один оператор — и по времени всё меняется: Where с Sum на том же параметре даёт 0,95–1,19, Sum без операторов — 1,15–1,89. Отставание в 3,9 раза остаётся только у перебора без операторов.

Выводы

По цифрам:

  • параметр объявлен как int[] или List<int> — отношение ZLinq к foreach без библиотеки 0,85–1,00;

  • тот же массив с параметром IEnumerable на .NET 10 — 2,58–3,89, с параметром IReadOnlyList — 2,62–3,99;

  • та же строка на .NET 8 — 1,00–1,17, на .NET 9 — 0,87–1,03;

  • foreach без библиотеки с параметром-интерфейсом между .NET 8 и .NET 10 ускорился в 3,6–4,2 раза, ZLinq за то же время — в 1,0–1,6;

  • на методе с yield return ускорения нет: отношение .NET 10 к .NET 8 равно 1,00–1,22;

  • foreach без библиотеки с параметром-интерфейсом выделяет 32 и 40 байт на вызов на .NET 8 и .NET 9, на .NET 10 — ноль; у ZLinq ноль везде;

  • с оператором Where объект создаётся на всех трёх рантаймах: 48 байт на массиве, 72 на списке;

  • Where с Sum на том же параметре — 0,95–1,19, Sum без операторов — 1,15–1,89.

Что делать на практике:

  • проверить сигнатуры методов, которые принимают коллекцию и перебирают её через ZLinq: с int[] и List<int> правка не нужна;

  • если там IEnumerable или IReadOnlyList, а вызывающий код передаёт массив или список — заменить тип параметра на конкретный: в 3,0–4,4 раза ускорится и библиотека, и обычный foreach;

  • на .NET 10 перебор без операторов оставить без AsValueEnumerable: он там быстрее в 2,58–3,89 раза и памяти не выделяет;

  • запросы с Where и другими операторами оставить на ZLinq: по времени они дают 0,95–1,19, а памяти выделяют ноль против 48 и 72 байт;

  • перед переходом на .NET 10 замерить заново те места, где ZLinq работает с параметром-интерфейсом: числа .NET 8 к нему неприменимы.

Код из статьи

  • ZLinqSourceKindProof — замеры, сверки и прогоны на четырёх машинах

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1065192/