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

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

Из таблицы — три факта.
First_Linq на десятке (349,4 нс) обогнал First_For (445,6 нс), и так на всех четырёх машинах — разрыв от 14 до 40%. После статьи про Sum уже не удивляюсь, но там работали вектора, а здесь чистая победа инлайнинга: сравнение полей ушло внутрь библиотечного цикла по span, а for ходит в List через индексатор.
Девятка уже инлайнила кэшированный делегат: First_LinqCached на .NET 9 — 352,9 нс, практически уровень десятки. А вот замыкание (507,4 нс) девятка не инлайнила — и разрыв между «лямбда с захватом» и «статичный делегат» в полтора раза. Десятка доучила замыкания: 349,4 против 347,4 — разницы больше нет. Так что +31% из заголовка — это не «LINQ ускорили», это JIT научился инлайнить замыкания.

Разрыв замыкания и кэшированного делегата в полтора раза: в десятке его больше нет Колонка Allocated: замыкание аллоцирует 24 байта на вызов — display class с двумя int. Кэшированный делегат — ноль. В горячем коде на миллионах вызовов это единственный оставшийся довод в пользу статичного предиката.
История 2. Тот самый OSR-сценарий
Обычный горячий вызов — это ещё не сценарий из issue. Эйерс прямо писал, что регресс завязан на OSR: там First крутится в длинном цикле внутри одного вызова большого метода, и метод компилируется через on-stack replacement — отдельный путь JIT со своей PGO-историей.
Воспроизвожу: OsrLinq — 200 000 итераций First с замыканием в одном вызове, рядом OsrFor с тем же поиском циклом.

Здесь же ответ на вопрос из комментов 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 нс.

Это не магия 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%. Один метод, один код, четыре машины — три ускорились, одна просела.

Картина один в один с 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
Ссылки
Issue #117717 — регресс First(предикат) в .NET 10, разбор JIT-команды
Репозиторий LinqFirstProof — код, прогоны и листинги всех четырёх машин
Статья про Sum — те же машины, обратный сюжет: где LINQ обгоняет цикл векторами
Всем удачи и до новых встреч!
PS. Поймали регресс у себя — пишите в комменты процессор и цифры.
