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

Ссылки

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

Комментарии (1)


  1. Naf2000
    18.08.2026 07:48

    Меняя Skip и Where местами, вы, вообще говоря, получаете разные результаты