Уважаемые читатели, в этой статье я хочу рассказать о проверке обещанного регресса LINQ в .NET 10 — и представить свои выводы.

В dotnet/runtime с лета висит issue #117717: после перехода на .NET 10 метод с First(предикат) просел к девятке — у автора issue на Intel 13-го поколения до 19%, у Энди Эйерса из JIT-команды на Zen 4 порядка 10%. Причину команда назвала сама: связка PGO и инлайнинга, делегат предиката перестал инлайниться.

Решение тоже озвучили: в десятке чинить не будем — сломаем другое, переносим в .NET 11. Был частичный фикс (PR #117816), после которого issue переоткрыли как #119425. Десятка при этом LTS, и в комментах закономерный вопрос: обновляться на десятку или ждать одиннадцатую.

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

  • регресс, который не пришёл — First(предикат) на четырёх машинах: вместо обещанных −19% вышло +25–32%, и LINQ-вариант обогнал даже обычный for;

  • тот самый OSR-сценарий — длинный цикл из issue, .NET 11 preview и дизасм, где мой предикат заинлайнен прямо в тело библиотечного метода;

  • откуда ускорение в десять раз при переходе с восьмёрки на девятку — и просадка того же рода, что в issue, только на Count и на другом железе.

Весь код проверки — в репо LinqFirstProof. Машины те же, что в статье про Sum:

CPU

Ядра/потоки

Частота

.NET 9

.NET 10

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц базовых и до 4,9 в турбо

9.0.15

10.0.5

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц базовых и до 5,3 в турбо

9.0.18

10.0.10

№3

2 × Intel Xeon Silver 4314 (Ice Lake)

2×16 / 64

2,4 ГГц базовых и до 3,4 в турбо

9.0.5

10.0.1

№4

Intel Xeon W-2255 (Cascade Lake)

10 / 20

3,7 ГГц базовых и до 4,5 в турбо

9.0.4

10.0.1

История 1. Регресс, который не пришёл

Повторяю сценарий из issue в минимальном виде: List из 1000 объектов с полями X и Y, искомый элемент лежит последним и его пара значений уникальна — First обязан пройти весь список. Лямбда захватывает обе цели, компилятор делает display class и делегат — ровно тот, который по issue перестал инлайниться.

Рядом для сравнения: тот же поиск своим for и foreach, кэшированный делегат без захвата, Count с предикатом и Sum() без делегата как контроль из прошлой статьи.

Результат по главному методу: регресс до моего железа не доехал. Наоборот:

First(предикат): .NET 10 быстрее девятки на 25–32% на всех четырёх машинах
First(предикат): .NET 10 быстрее девятки на 25–32% на всех четырёх машинах

Полная картина по методам на машине №2:

Все методы: замыкание, кэшированный делегат, свои циклы, Count и контрольный Sum()
Все методы: замыкание, кэшированный делегат, свои циклы, Count и контрольный Sum()

Из таблицы — три факта.

  1. First_Linq на десятке (349,4 нс) обогнал First_For (445,6 нс), и так на всех четырёх машинах — разрыв от 14 до 40%. После статьи про Sum уже не удивляюсь, но там работали вектора, а здесь чистая победа инлайнинга: сравнение полей ушло внутрь библиотечного цикла по span, а for ходит в List через индексатор.

  2. Девятка уже инлайнила кэшированный делегат: First_LinqCached на .NET 9 — 352,9 нс, практически уровень десятки. А вот замыкание (507,4 нс) девятка не инлайнила — и разрыв между «лямбда с захватом» и «статичный делегат» в полтора раза. Десятка доучила замыкания: 349,4 против 347,4 — разницы больше нет. Так что +31% из заголовка — это не «LINQ ускорили», это JIT научился инлайнить замыкания.

    Разрыв замыкания и кэшированного делегата в полтора раза: в десятке его больше нет
    Разрыв замыкания и кэшированного делегата в полтора раза: в десятке его больше нет
  3. Колонка Allocated: замыкание аллоцирует 24 байта на вызов — display class с двумя int. Кэшированный делегат — ноль. В горячем коде на миллионах вызовов это единственный оставшийся довод в пользу статичного предиката.

История 2. Тот самый OSR-сценарий

Обычный горячий вызов — это ещё не сценарий из issue. Эйерс прямо писал, что регресс завязан на OSR: там First крутится в длинном цикле внутри одного вызова большого метода, и метод компилируется через on-stack replacement — отдельный путь JIT со своей PGO-историей.

Воспроизвожу: OsrLinq — 200 000 итераций First с замыканием в одном вызове, рядом OsrFor с тем же поиском циклом.

OSR-репро: ×0,97–1,02 к девятке у десятки и ×0,98–1,00 у одиннадцатой — регресса нет ни там, ни там
OSR-репро: ×0,97–1,02 к девятке у десятки и ×0,98–1,00 у одиннадцатой — регресса нет ни там, ни там

Здесь же ответ на вопрос из комментов issue про .NET 11: preview идёт наравне с девяткой и десяткой на всех четырёх машинах. Отдельно замечу: на машинах №3 и №4 крутится 10.0.1 — почти релизная десятка, до сервисных обновлений. То есть дело не в том, что «уже допилили патчами»: регресса не было и в ранней десятке.

Дизасм объясняет, почему регресс не сработал. Tier1-версия TryGetFirst из BCL, машина №2, .NET 10:

; System.Linq.Enumerable:TryGetFirst, Tier1 with Dynamic PGO
G_M000_IG26:
       mov      r15, gword ptr [rsi+rdx]      ; взяли элемент списка
       test     r15, r15
       je       SHORT G_M000_IG25
G_M000_IG27:
       cmp      dword ptr [r15+0x08], 0xF4243 ; item.X == 1 000 003
       jne      SHORT G_M000_IG25
G_M000_IG28:
       cmp      dword ptr [r15+0x0C], 0xF4261 ; item.Y == 1 000 033
       jne      SHORT G_M000_IG25

В теле TryGetFirst — не вызов делегата, а сразу мои сравнения: X с 1 000 003, Y с 1 000 033: PGO увидел доминирующий делегат и заинлайнил предикат через guarded devirtualization, вызов Invoke остался только в запасной ветке. И такая картина на 9, 10 и 11 — механизм регресса («делегат перестал инлайниться») в этом паттерне не сработал ни разу.

История 3. Два сюрприза: ×10 с восьмёрки и минус 22% на Count

С .NET 8 на .NET 9 First(предикат) ускорился в 9,1–10,3 раза — восьмёрка на тех же данных выдаёт 4 596–7 089 нс.

Полный путь: с восьмёрки на девятку First(предикат) ускорился в 9,1–10,3 раза, десятка добавила ещё 25–32%
Полный путь: с восьмёрки на девятку First(предикат) ускорился в 9,1–10,3 раза, десятка добавила ещё 25–32%

Это не магия PGO, а смена пути: в First.cs восьмёрки TryGetFirst идёт foreach-ем по энумератору, в First.cs девятки — по span через TryGetSpan. Восьмёрка на каждый элемент дёргала MoveNext и Current через энумератор, девятка идёт по span напрямую — отсюда и ×10.

А вот и просадка — только не в First

А теперь самое интересное. Пока First вёл себя образцово, соседний метод повторил историю из issue: Count с предикатом на машине №4 в десятке отстал от девятки на 22% — 651,9 → 797,6 нс, StdDev 17 и 25 нс, это не шум. При этом на трёх остальных машинах тот же Count быстрее на 24–28%. Один метод, один код, четыре машины — три ускорились, одна просела.

Тот же Count(предикат) на трёх машинах быстрее на 24–28%, на №4 — медленнее на 22%
Тот же Count(предикат) на трёх машинах быстрее на 24–28%, на №4 — медленнее на 22%

Картина один в один с issue: делегатный LINQ-метод, конкретная машина, минус там, где остальные в плюсе. Не «десятка сломала LINQ» — решает связка PGO и железа, и на разных процессорах она решает по-разному. У автора issue не повезло First на 13-м поколении, у меня — Count на Cascade Lake.

Выводы

По цифрам:

  • обещанный регресс First(предикат) не воспроизвёлся ни в обычном прогоне, ни в OSR-сценарии из issue: десятка быстрее девятки на 25–32%, в OSR — ×0,97–1,02;

  • .NET 11 preview идёт наравне с десяткой — переносить туда апгрейд из-за этого issue незачем;

  • с восьмёрки на девятку First(предикат) ускорился в 9,1–10,3 раза — span-путь;

  • просадка того же рода, что в issue, нашлась и у меня: Count на машине №4 — минус 22% на фоне плюс 24–28% у остальных. Дело в конкретном железе, а не в .NET 10;

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

  • переписывать First(предикат) на цикл ради скорости больше незачем: на .NET 10 сам First быстрее цикла — на всех четырёх машинах;

  • замыкание в предикате больше не замедляет: десятка инлайнит его наравне с кэшированным делегатом. Разница осталась одна — аллокации: 24 байта на вызов у замыкания против нуля у статичного делегата.

Код из статьи

  • LinqFirstProof — бенчмарк (First с замыканием и с кэшированным делегатом, свои циклы, Count с предикатом, контрольный Sum, OSR-режим, длины 100/1000/100000, net8/9/10 и харнесс до .NET 11 preview) и снятие дизасма с живым PGO

Ссылки

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

PS. Поймали регресс у себя — пишите в комменты процессор и цифры.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
После перехода на .NET 10 замеряли свой код?
30%Замерял — стало быстрее3
0%Замерял — что-то просело0
70%Не замерял: работает — не трогаю7
0%Ещё не переходил0
0%Ушёл замерять0
Проголосовали 10 пользователей. Воздержались 4 пользователя.