Уважаемые читатели, в этой статье я хочу разобраться, что происходит с 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 отношение 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 без библиотеки: 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/