Уважаемые читатели, в этой статье я хочу рассказать о том, насколько медленнее работает метод, если Where стоит перед Skip — и представить свои выводы.
В большинстве проектов пагинацию делают через Skip и Take. Пока перед Skip нет Where, LINQ берёт элемент по индексу. С Where — перебирает все пропускаемые, и так на каждой странице.
Сравниваются семь способов:
Array_Where_Select — Where и Select, потом Skip и Take;
Array_Where_NoSelect — то же самое без Select;
Array_Take_Then_Where — Skip и Take перед Where;
Range_Select и List_Select — без Where;
Range_ToArray — ToArray один раз, дальше страницы по индексу;
Array_ByIndex — то же самое без LINQ.
Будет 3 истории:
во сколько раз Where перед Skip замедляет метод;
одна страница: как её номер влияет на время;
три способа решить проблему и настройка, которая делает только хуже.
Замеры сделаны на четырёх машинах:
№ |
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 |
BenchmarkDotNet 0.15.8, сборка Release. Патч-версии рантаймов на машинах различаются, поэтому сравнивать имеет смысл столбцы внутри одной машины. Замеры сняты на .NET 8, .NET 9, .NET 10 и .NET 11.
История 1. Where перед Skip
Массив из 157 000 элементов, страница — 157 элементов, всего 1000 страниц. Каждая страница попадает в отдельный массив, значения складываются:
// SizeOptLinqProof, Subjects.cs public static long PagesFromWhere(int[] data) { IEnumerable<int> source = data.Where(static x => x >= 0).Select(Value); long sum = 0; int pages = PageCount(data.Length); for (int page = 0; page < pages; page++) { int[] block = source.Skip(page * PageSize).Take(PageSize).ToArray(); for (int i = 0; i < block.Length; i++) { sum += block[i]; } } return sum; }
Where написан так, что не отбрасывает ни одного элемента: все семь способов возвращают одну и ту же сумму. Перед каждым прогоном это проверяет отчёт checks.
Способ |
№1 Ryzen |
№2 i9 |
№3 2×Xeon Silver |
№4 Xeon W |
Array_Where_Select |
172,068 |
171,961 |
252,846 |
259,505 |
Array_Where_NoSelect |
226,298 |
204,699 |
329,007 |
437,143 |
Array_Take_Then_Where |
0,654 |
0,608 |
1,047 |
1,006 |
Range_Select |
0,545 |
0,361 |
0,615 |
0,612 |
List_Select |
0,415 |
0,374 |
0,636 |
0,639 |
Range_ToArray |
0,452 |
0,399 |
0,689 |
0,619 |
Array_ByIndex |
0,157 |
0,130 |
0,247 |
0,200 |
Миллисекунды на 157 000 элементов и 1000 страниц, .NET 10: с Where перед Skip медленнее в 1024–1323 раза, чем по индексу
Медленнее в 1096 раз на Ryzen 5950X, в 1323 на i9-10900KF, в 1024 на 2 × Xeon Silver 4314, в 1298 на Xeon W-2255.
Дело не в Select. Без него остаются те же три порядка: 1332–2186 раз против работы по индексу. Остальные способы уложились в 0,130–1,047 миллисекунды.
Отчёт types показывает, какой тип создаёт LINQ на каждом запросе:
; SizeOptLinqProof, dotnet run -c Release -f net10.0 -- types ; Results/Comp_2/types_net10.0.txt Запрос Тип, который создал LINQ Range().Select() RangeSelectIterator Range().Select().Skip(157) RangeSelectIterator List.Select() ListSelectIterator List.Select().Skip(157) IListSkipTakeSelectIterator Array.Where().Select() ArrayWhereSelectIterator Array.Where().Select().Skip(157) IEnumerableSkipTakeIterator Array.Where().Skip(157) IEnumerableSkipTakeIterator Array.Skip(157).Take(157) IListSkipTakeIterator Array.Skip(157).Take(157).Where() IEnumerableWhereIterator
После List.Select() появляется IListSkipTakeSelectIterator, после массива — IListSkipTakeIterator. Оба берут элемент по индексу. После Where в обоих случаях стоит IEnumerableSkipTakeIterator, а он ищет нужный элемент перебором.
История 2. Одна страница
По первой таблице непонятно, влияет ли номер страницы. Второй замер берёт одну страницу из тех же 157 000 элементов: работа на самой странице одна и та же, меняется только номер.
Страница |
Без Where |
С Where |
Во сколько раз |
0 |
367,7 нс |
569,2 нс |
×1,5 |
500 |
403,4 нс |
157 229,8 нс |
×390 |
999 |
367,8 нс |
343 591,2 нс |
×934 |
Наносекунды на одну страницу, i9-10900KF, .NET 10: без Where номер страницы на время не влияет, с Where — чем дальше страница, тем дольше
На нулевой странице пропускать нечего — разница всего в 1,5 раза. На 500-й уже в 390 раз, на 999-й в 934. Остальные три машины ведут себя так же: 324 731, 513 495 и 457 082 наносекунды на последней странице против 367–644 наносекунд без Where — это разброс по всем четырём машинам.
Колонка Allocated почти не меняется: 816 байт на 0-й странице и 872 на 999-й у обоих способов.
Отсюда и цифры первой таблицы. Каждая следующая страница медленнее предыдущей, а страниц 1000:
Страниц |
№1 Ryzen |
№2 i9 |
№3 2×Xeon Silver |
№4 Xeon W |
100, с Where |
2,010 |
1,977 |
2,884 |
3,089 |
300, с Where |
14,709 |
15,561 |
23,091 |
25,187 |
1000, с Where |
172,068 |
171,961 |
252,846 |
259,505 |
100, по индексу |
0,018 |
0,014 |
0,026 |
0,023 |
1000, по индексу |
0,157 |
0,130 |
0,247 |
0,200 |
Миллисекунды с Where перед Skip и без него, .NET 10: страниц больше в 3 раза — времени в 7,3–8,2 раза, а на 1000 страницах против 100 разница уже в 84–88 раз
История 3. Три способа решить проблему
Первый способ — поменять порядок. Skip и Take идут перед Where:
// SizeOptLinqProof, Subjects.cs int[] block = data .Skip(page * PageSize) .Take(PageSize) .Where(static x => x >= 0) .Select(Value) .ToArray();
Быстрее в 241–283 раза: 0,608–1,047 миллисекунды против 172–260. Порядок меняет и смысл запроса: Where теперь отбирает элементы внутри страницы, а не по всему массиву. Пока Where ничего не отбрасывает, как в этом замере, результат тот же.
Второй способ — вызвать ToArray до цикла, дальше брать страницы по индексу. Быстрее в 367–431 раза, но памяти уходит 1254 килобайта против 805 у способа с Where — по колонке Allocated в отчёте BenchmarkDotNet.
Третий способ — не брать страницы через LINQ. Array_ByIndex копирует нужный участок массива и тратит 0,130–0,247 миллисекунды.
Есть ещё System.Linq.Enumerable.IsSizeOptimized — настройка рантайма, которая уменьшает объём кода LINQ. В runtimeconfig.json её пишет свойство UseSizeOptimizedLinq, а его ставит SDK при PublishAot=true. Android SDK включает свойство по умолчанию. Отчёт switch читает настройку из рантайма и выводит значение, с которым работает сам System.Linq:
; SizeOptLinqProof, bin\SizeOpt\net10.0\SizeOptLinqProof.exe switch ; Results/Comp_2/switch_net10.0_sizeopt.txt Runtime: 10.0.11 System.Linq.Enumerable.IsSizeOptimized задан в runtimeconfig: True значение: True видит System.Linq: True Тип запроса Range().Select(): SizeOptIListSelectIterator
С ней метод медленнее в 1,55–4,09 раза. Меньше всего теряет Array_Where_Select — 1,55–2,03, больше всего List_Select — 3,55–4,09. У Array_ByIndex разницы нет — 0,99–1,10, то есть замер от настройки не зависит. В .NET 8 и .NET 9 этой настройки нет, и отчёт switch так и выводит: свойства в рантайме не существует.
Переход на новый рантайм ничего не меняет: тип LINQ тот же, счёт идёт на те же сотни миллисекунд. Разница между рантаймами внутри одной машины — 1,31–1,37 раза, между способами — три порядка:
Машина |
.NET 8 |
.NET 9 |
.NET 10 |
.NET 11 |
№1 Ryzen 5950X |
198,2 |
144,9 |
172,1 |
165,8 |
№2 i9-10900KF |
204,8 |
172,5 |
172,0 |
156,9 |
№3 2×Xeon Silver |
343,0 |
283,0 |
252,8 |
256,5 |
№4 Xeon W-2255 |
347,0 |
264,9 |
259,5 |
300,1 |
Миллисекунды с Where перед Skip на 157 000 элементов: на всех четырёх рантаймах те же сотни миллисекунд против 0,130–0,247 у выборки по индексу
Выводы
По цифрам:
Where перед Skip замедляет работу в 1024–1323 раза по сравнению с выборкой по индексу — на 157 000 элементов и 1000 страницах;
без Select замедление сильнее — в 1332–2186 раз, так что дело не в Select;
одна страница обходится тем медленнее, чем больше её номер: 569 наносекунд на 0-й, 157 230 на 500-й, 343 591 на 999-й;
страниц больше в 3 раза — времени в 7,3–8,2 раза, страниц больше в 10 раз — времени в 84–88 раз;
память почти не зависит от номера страницы: 816 байт на 0-й, 872 на 999-й;
Skip и Take перед Where ускоряют в 241–283 раза, ToArray до цикла — в 367–431, выборка по индексу — в 1024–1323;
UseSizeOptimizedLinq замедляет в 1,55–4,09 раза, а на выборку по индексу не влияет;
на .NET 8, .NET 9, .NET 10 и .NET 11 счёт идёт на те же сотни миллисекунд: 144,9–347,0 против 0,130–0,247 у выборки по индексу.
Что делать на практике:
если Where фильтрует внутри страницы, ставьте Skip и Take перед ним: тогда элемент берётся по индексу, а Where работает на 157 элементах вместо 157 000;
если Where должен фильтровать весь набор, вызовите ToArray до цикла и дальше берите страницы по индексу — памяти уйдёт больше, а времени меньше;
свой запрос можно проверить через query.Skip(1).GetType().Name: IListSkipTakeIterator и IListSkipTakeSelectIterator берут элемент по индексу, IEnumerableSkipTakeIterator перебирает;
на 1 странице разница незаметна, на 100 страницах она уже 111–141 раз, на 1000 — 1024–1323;
проверьте, включён ли PublishAot: вместе с ним SDK включает UseSizeOptimizedLinq, и запрос замедляется в 1,55–4,09 раза. Под Android настройка активна по умолчанию.
Код из статьи
SizeOptLinqProof — бенчмарки, прогоны на четырёх машинах и отчёты по типам LINQ
Ссылки
Всем удачи и до новых встреч!
Naf2000
Меняя Skip и Where местами, вы, вообще говоря, получаете разные результаты