- BrainTools - https://www.braintools.ru -
В ноябре 2022 года с разницей в три недели вышли .NET 7 и ChatGPT. Получилась удобная точка отсчёта: .NET 7 стал последним релизом, написанным до ChatGPT. А в мае 2025 года в dotnet/runtime пришёл Copilot coding agent и к концу октября 2026-го открыл там 1734 pull request’а, 1207 из них смержены.
dПортит ли ИИ код, спорить можно долго, а производительность можно просто измерить. Я замерил DI, конфигурацию, логирование, кэш и EF Core: 46 операций, шесть мажорных версий, 30 раундов. Заодно по истории Git проверил, кто трогал горячие методы, а две просадки в 11 RC проследил до конкретных коммитов. Самую крупную внёс человек одной строкой. Вторая, поменьше, пришла с PR от Copilot coding agent, который чинил дедлок.
Новый .NET ускоряется ещё и за счёт ASP.NET [1] Core), так не подменить: старый пакет проигрывает встроенной сборке. Их, как и EF Core 11, я сравниваю отдельно, net10.0 против net11.0, и там разделить библиотеку и рантайм уже нельзя.
Вот один из замеров, IOptionsSnapshot в новом scope:
recorder.Measure(OptionsScopes.Snapshot, 1000, () =>
{
for (var i = 0; i < 1000; i++)
{
using var scope = provider.CreateScope();
Sink.Keep(scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<Poco50>>().Value);
}
});
Время пишет EmberTrace [2], а память [3] считает GC.GetAllocatedBytesForCurrentThread. На процесс приходится 20 прогревочных и 200 измеряемых итераций, каждый сценарий запускается в своём процессе и привязан к логическим ядрам 2–5. Всего 30 раундов и 111 процессов на каждую пару версии и сценария, порядок версий в каждом раунде перемешан.
Единица наблюдения — процесс, все раунды весят одинаково. Изменение считается внутри раунда относительно 7.0, доверительный интервал даёт иерархический бутстрэп, значимость — U-критерий Манна — Уитни с поправкой Бенджамини — Хохберга. «Быстрее» или «медленнее» ставится, только если весь 95-процентный интервал лежит за порогом в 5%, ни один раунд не показывает обратного и скорректированное p меньше 0,05. Если весь интервал лежит внутри ±5%, это шум, а всё остальное остаётся без вердикта.
Раунды шли в двух состояниях одного ноутбука (Core i5-12450H, Ubuntu 22.04). «Стенд» — это governor performance и выключенный турбобуст, 19 раундов. «Заводские настройки» — powersave с турбобустом, 11 раундов. Почему это важно, разберём ниже. Если не сказано иное, цифры дальше относятся к стенду.
Для версии 10.0 против 7.0 итог по 46 операциям такой: 9 быстрее, 13 медленнее, 10 в пределах шума, по 14 вердикта нет. На графике показательные примеры с обеих сторон: повторяющиеся операции к 10.0 ускорились на 14–35%, а первые вызовы и создание объектов замедлились на 39–64%.
В единицах времени это выглядит спокойнее. Значения сняты на ноутбуке с частотой 2 ГГц, так что смотреть стоит на порядок величин:
|
Операция |
7.0 |
10.0 |
|---|---|---|
|
|
377 нс |
244 нс |
|
|
111 нс |
96 нс |
|
EF |
6,1 мс |
4,3 мс |
|
DI, первый resolve графа глубиной 5 |
3,3 мкс |
5,2 мкс |
|
Сборка провайдера с |
40 мкс |
66 мкс |
|
Новый |
23 мкс |
34 мкс |
|
Новый |
37 мкс |
51 мкс |
|
EF, самый первый запрос в процессе |
996 мс |
1162 мс |
Для долгоживущего сервиса такой размен выгоден: логирование выполняется миллионы раз, а провайдер собирается один раз. Короткоживущим процессам повезло меньше, особенно с EF: первый запрос подорожал на 166 мс, и это уже заметная прибавка к холодному старту. Правда, холодные замеры включают JIT, а старые пакеты работают на новом рантайме, так что у этой строки погрешность выше остальных.
Обе тенденции видны и на заводских настройках, но там раундов меньше, и большинство замедлений первого вызова формально остаётся без вердикта. Уверенно подтверждается только LoggerFactory: +49%.
В споре обычно звучит «до нейросетей было лучше», поэтому посмотрим на шаги между соседними релизами.
Правила вердикта здесь те же, только базой служит предыдущий релиз: подтверждается 19 сдвигов из 46 на шаге к 8.0, по 5 на шагах к 9.0 и 10.0 и 6 из 38 на шаге к 11 RC. Шаг к 10.0 на фоне предыдущего не выделяется, правда, агент застал только последние полгода его разработки.
На заводских настройках 8.0 так не выделяется (8, 7, 5 и 6): там всего 11 раундов, и интервалы в разы шире. Зато шаг к 11 RC совпадает. В обоих режимах подтверждено по 6 сдвигов, и 5 операций из 6 общие.
К самому переходу на 8.0 есть возражение. Каждая мажорная версия пакета приносит свою сборку: lib/net7.0 у 7.x, lib/net8.0 у 8.x, хотя рантайм везде .NET 10. Значит, в шаг входят и ветки условной компиляции, которых старая сборка не видит даже на новом рантайме. Это тоже код библиотеки, но разделить правки и смену целевого фреймворка такой дизайн не позволяет.
На шаге к 10.0 выделяются три операции. new DbContext подорожал на 13%, Compact в кэше подешевел на 13%, а DetectChanges стал на 19% медленнее, чем в 9.0, и отдал часть выигрыша: относительно 7.0 было −41%, стало −30%.
То, что самый крупный сдвиг случился в первом релизе после ChatGPT, ничего не говорит об участии ИИ: замеры показывают, что и когда поменялось, но не почему.
Каждый коммит DI и Caching.Memory между тегами v7.0.20 и v10.0.14 сопоставлен с его pull request’ом. Агента легко опознать: PR открывает GitHub-приложение copilot-swe-agent с id 198982749, а в squash-коммитах остаётся трейлер Co-authored-by. Нашлось вот что:
113 коммитов: 57 от участников организации, условно Microsoft, 56 от сообщества, от агента ни одного, соавторских с агентом тоже ни одного;
24 горячих метода, прослеженных через git log -L: 39 правок, агентских среди них нет, а 8 методов между этими тегами не менялись вообще;
6 PR с меткой tenet-performance, все написаны людьми.
Запланированное сравнение «методы, которые трогал агент, против методов, которые трогали только люди» на этом окне провести не получилось: первая группа пуста. Но окно заканчивалось на 10.0, а полный цикл разработки у агента был только в 11.0. Поэтому я дотянул разметку до RC3 и добавил остальные библиотеки. Здесь считается только автор коммита в Git, без разбивки на Microsoft и сообщество:
|
Библиотека |
7.0 → 10.0, коммитов |
из них агент |
10.0 → 11 RC, коммитов |
из них агент |
|---|---|---|---|---|
|
DI |
80 |
0 |
21 |
11 |
|
Кэш |
33 |
0 |
14 |
1 |
|
Конфигурация |
67 |
0 |
15 |
1 |
|
Options |
37 |
0 |
7 |
0 |
|
Логирование |
52 |
1 |
8 |
3 |
|
EF Core |
867 |
29 |
256 |
73 |
Часть агентских коммитов в DI — документация и аннотации, но не все: из 11 правок горячих методов DI и кэша в новом окне 3 принадлежат агенту. Ещё в 5 коммитах конфигурации и Options есть соавтор Copilot CLI.
Оговорки к таблице:
На реальную причастность я проверил только биндер и DI, про них ниже. EF Core только посчитан, хотя правок агента там больше всего, а на шаге к 10.0 заметно сдвинулись его DbContext и DetectChanges.
Коммит агента значит «PR открыт агентом», а не «написано без человека»: такие PR проходят ревью и вливаются людьми. Ассистент в IDE вообще не оставляет следов в Git, так что об эпохе ассистентов данные ничего сказать не могут.
Граница эпох условна. Автодополнение Copilot появилось ещё в 2021 году, так что и 7.0 мог писаться не совсем без ИИ.
Деление на Microsoft и сообщество грубое: оно опирается на поле author_association, и сотрудники со скрытым членством в организации попадают в сообщество. В 24 коммитах из 113 это поле расходится с почтой на @microsoft.com [4].
Относительно 7.0 биндинг POCO на 50 полей подорожал с 61 до 145 мкс, IOptionsSnapshot в новом scope — с 52 до 136 мкс. Аллокации у обеих операций выросли почти одинаково, примерно на 10 КБ на вызов. На заводских настройках картина та же: +134% и +158%.
Прибавка в 10 КБ на 50 свойств подсказывает, что платим за каждое свойство. В ConfigurationBinder.cs между 10.0 и RC3 всего 7 коммитов, и один из них, #126396 [5] с новым атрибутом [ConfigurationIgnore], добавляет как раз такую проверку:
private static bool IsIgnoredProperty(PropertyInfo property) =>
property.IsDefined(typeof(ConfigurationIgnoreAttribute));
Она выполняется для каждого свойства при каждом биндинге. Чтобы проверить догадку, я собрал биндер из исходников RC3 с заменой этого выражения на false и подменил одну DLL в уже собранном стенде. Те же сценарии, 6 раундов, режим стенда:
|
Операция |
10.0 |
11 RC |
11 RC, строка заменена |
|---|---|---|---|
|
Биндинг POCO |
61 мкс |
148 мкс |
60 мкс |
|
|
51 мкс |
139 мкс |
53 мкс |
|
Валидация DataAnnotations |
43 мкс |
53 мкс |
43 мкс |
|
Аллокации биндинга на вызов |
18,4 КБ |
28,4 КБ |
18,4 КБ |
Одна строка объясняет сразу три из шести подтверждённых сдвигов этого шага. Холодную сборку конфигурации (+76%) она не лечит, причина там другая и пока не найдена. Отдельно я проверил это в BenchmarkDotNet, без моего трейсера, на минимальном примере:
Основную цену даёт перегрузка-расширение: у неё по умолчанию inherit: true. С inherit: false время падает со 120 до 45 мкс, а аллокации возвращаются к уровню 10.0. Именно так написано в main после #135672 [6], но тот PR чинит другую ошибку [7] и в ветку release/11.0 не попал. Я завёл issue #136759 [8] с примером и этими цифрами. Бенчмарки dotnet/performance этого, похоже, не ловят: их тест биндинга [9] использует класс с одним свойством.
Мейнтейнер ответил в тот же день. Регрессию признали (issue получил метку regression-from-last-release), но сказали прямо: производительность рефлексионного биндера не главный приоритет, для горячих путей рекомендуют генератор исходников. Смягчить большую часть регрессии в main обещали и предложить фикс для бэкпорта в 11.0, а IOptionsSnapshot назвали отдельной проблемой, уже обсуждавшейся в #53793 [10]. Позже появился черновой PR #136899 [11]: он смотрит на флаги виртуальности геттера и сеттера и вызывает дешёвый поиск атрибута без наследования, если свойство переопределить нельзя. По цифрам автора, Get<T> на 50 свойств занимает 10,3 мкс против 27,6 мкс в затронутой сборке и 9,4 мкс в биндере 10.0, аллокации возвращаются к 17,5 КБ. Остаётся примерно 10–15% к 10.0, то есть кэширование на тип, которое я предлагал, этот PR не заменяет и не включает. На момент написания PR не влит и без ревью, так что попадёт ли фикс в 11.0, пока неизвестно. Косвенно об этом говорит и milestone: issue привязан к 12.0.0, а не к 11.0, поэтому бэкпорт пока обещан, но не гарантирован.
PR открыт участником организации, фича делалась по одобренному API-запросу, следов Copilot в коммите нет. Это не доказывает, что ИИ не использовался: ассистент в IDE в Git не виден.
Если вы уже на RC, обход есть: генератор исходников (EnableConfigurationBindingGenerator) в том же примере даёт 12 мкс и 5,7 КБ, то есть быстрее и 10.0 (29 мкс), и любого варианта рефлексионного биндера. Именно его мейнтейнеры и советуют для чувствительных к скорости сценариев, так что рассчитывать на полное устранение разницы в рефлексионном пути не стоит. IOptionsSnapshot пересоздаёт опции на каждый scope намеренно, и ещё в 2021 году Дэвид Фаулер назвал его ловушкой производительности [10]. Там, где не нужно обновление конфигурации в рамках запроса, IOptionsMonitor дешевле и без этой регрессии.
Оговорка: это релиз-кандидат, а в основном треке работает сборка net10.0 из исходников 11.0 на рантайме .NET 10, так что на настоящем net11.0 цифры могут отличаться.
Первый resolve в DI на шаге к 11 RC вырос ещё на 15%. Здесь агент действительно рядом: в DI у него 11 коммитов из 21, поэтому я собрал DI из исходников по коммитам и прогнал бисект, 8 раундов на вариант. Скачок появляется ровно на одном коммите:
|
Сборка DI |
Первый resolve, 100 провайдеров |
|---|---|
|
10.0, пакет |
0,565 мс |
|
Коммит перед #124331 |
0,554 мс |
|
#124331 [12], PR агента |
0,641 мс |
|
11 RC3, пакет |
0,653 мс |
|
11 RC3 с откатом только #124331 |
0,569 мс |
Внутри раунда коммит даёт +16,5% (95% интервал 14,3–18,8, все 8 раундов в одну сторону), а откат на RC3 возвращает к уровню 10.0: разница +1,9%, интервал включает ноль. На холодном resolve аллокации растут с 8848 до 9080 байт. Прогретые singleton, scoped и сборку провайдера коммит не задевает.
Этот PR чинит дедлок [13] при циклических зависимостях через фабрики. Теперь VisitRootCache ведёт [ThreadStatic] HashSet тех, кого сейчас резолвит поток. Цена — примерно 0,9 мкс на первый resolve в новом провайдере, и за исправленный дедлок это разумная плата. PR прошёл ревью и влит людьми.
В моём списке из 24 горячих методов VisitRootCache не было, так что разметка по методам этот случай пропустила, и нашёл его только бисект. Остальные агентские правки горячих методов, например переписанный выбор конструктора, на этот сценарий не влияют: там конструктор один. Другие сценарии я так не проверял.
Здесь библиотека и рантайм меняются вместе, и новости есть хорошие: Kestrel стартует на 54% быстрее, холодное подключение EF с созданием схемы ускорилось на 15–17%. DetectChanges там, наоборот, медленнее на 11–12%. Время обработки запроса в ASP.NET [1] Core выросло на 81–90%, но этой цифре я пока не верю: распределение многомодальное, встроенный генератор нагрузки работает с фиксированной частотой, а bombardier не запускался.
EmberTrace — мой трейсер для .NET под лицензией MIT, пакет есть на NuGet. Все основные замеры статьи — это один скоп на пачку вызовов, как в примере в начале: процессов тысячи, каждый оставляет файл .ember, а сравнение строится уже по файлам.
Виновника трейсер сам не назвал: сдвиг показали замеры, подозреваемого нашёл git log, а подтвердила подмена DLL. Зато скоп можно поставить прямо внутрь чужого кода и посмотреть, куда уходит время. Я добавил четыре скопа в исходники биндера:
private static bool IsIgnoredProperty(PropertyInfo property)
{
using var scope = Tracer.Scope(Tracer.Id("ConfigurationBinder.IsIgnoredProperty"));
return property.IsDefined(typeof(ConfigurationIgnoreAttribute));
}
Вот отчёт TraceText.Write за 20 пачек по 1000 биндингов:
Name Count Incl ms Excl ms Excl%
bind.x1k 20 2958.218 192.096 6.44
ConfigurationBinder.BindProperties 20000 2766.122 356.338 11.95
ConfigurationBinder.IsIgnoredProperty 1000000 1813.144 1813.144 60.80
ConfigurationBinder.BindProperty 1000000 551.475 551.475 18.49
ConfigurationBinder.GetAllProperties 20000 45.165 45.165 1.51
Миллион вызовов и 61% времени на проверку атрибута, которого в этом классе нет. За 3 секунды записано 4 млн событий, ни одно не потеряно. У этого есть цена: со скопами биндинг занял 148 мкс против 120 мкс в бенчмарке, то есть грубо 0,3 мкс на скоп вместе с хешированием имени в Tracer.Id [14], так что доли здесь приблизительные. Для такой разовой диагностики это приемлемо, а скоп на каждый вызов в продакшене я бы ставить не стал.
Байты на пачку почти детерминированы и от частоты не зависят, поэтому это самый надёжный сигнал в наборе.
Часто память и время идут вместе: у LoggerMessage и DetectChanges меньше и того и другого, у ValidateOnBuild и DbContext больше. Но бывает и иначе. Where + OrderBy + Take в EF аллоцирует на 22% больше при времени в пределах шума (+1,7%): в приложении это, скорее всего, вылезет позже как лишняя работа GC. А сборка DI-провайдера на 1000 регистраций требует на 17% больше памяти, хотя её среднее время упало на 25%.
Код при этом растёт. Между 7.0 и 11 RC код DI вырос на 25%, Caching.Memory на 24%, DI.Abstractions на 129%, причём у последней основной скачок приходится на 8.0, где появились keyed services. Средняя цикломатическая сложность почти не сдвинулась, она держится в диапазоне 1,3–2,5, так что по средним всё спокойно. Но максимальная сложность метода в DI выросла с 14 до 23, а в DI.Abstractions с 10 до 26.
Рекомендация для бенчмарков стандартная: зафиксируй частоту, выключи турбобуст. На этом процессоре no_turbo ограничивает ядра базовыми 2 ГГц при максимуме 4,4 ГГц. В результате на заводских настройках все 46 операций базовой версии выполняются в 1,6–2,1 раза быстрее, чем на «правильном» стенде.
Ладно бы менялись только абсолютные числа, но меняются и относительные:
Прогретые операции DI и IOptionsMonitor в 10.0 показывают ускорение на 3–8% на стенде и на 15–20% на заводских настройках. Для singleton, transient и open generic это разница между «вердикта нет» и «уверенно быстрее», хотя версия кода та же самая. Правдоподобное объяснение — другое соотношение частоты ядра и скорости памяти, но оно не проверялось.
Списать всё на дрейф нельзя: раунды стенда идут и до, и после блока с заводскими настройками. Сравнение режимов находит значимую разницу в 29 случаях из 252, а сравнение первых и последних раундов стенда между собой — в одном. Дизайн при этом неидеален: заводские раунды идут одним блоком, процессов в них меньше (33 против 78).
Поэтому ±5%, полученные на одном ноутбуке в одном режиме питания, мало что значат. Если сравниваете версии, указывайте режим, а лучше мерьте в двух.
Правила вердикта я ужесточил после первых раундов. Исходное правило сохранено в данных, а отклонение описано в методике, но решение всё же принято после просмотра данных.
Всё снято на ноутбуке с гибридным процессором, без изоляции ядер и при средней нагрузке в основном 1–2,7. Поэтому опираться стоит на отношения внутри раунда, а не на абсолютные времена.
Результат иногда зависит от метрики. У cache.set и get-or-create в 10.0 медиана показывает рост на 22–31%, а среднее — падение на 5–9%. Причину двух мод я не установил, поэтому выводов по этим операциям не делаю.
Замеры синтетические и охватывают только API, которые есть во всех версиях, так что самые новые быстрые пути в сравнение не попали. Старые пакеты работают на новом рантайме и могут не использовать его оптимизации.
Обновляться с 7.0, которая и так вне поддержки, имеет смысл и ради горячих путей: выигрыш там реальный. Если у вас serverless или короткоживущие утилиты, цену холодного старта лучше измерить самим. Перед переходом на 11.0 стоит прогнать свои сценарии с биндингом конфигурации и IOptionsSnapshot, причём на финальной версии, а не на RC, и посмотреть, попал ли в релиз фикс из #136899 [11].
На исходный вопрос ответ теперь такой: в том, что я проверил, нет признаков того, что код стал хуже из-за ИИ, но и доказательства обратного нет. До 10.0 агента в горячих методах DI и кэша просто не было. В 11.0 он там есть, и единственная его правка, которую я поймал на замерах, стоит 0,9 мкс на первый resolve в обмен на исправленный дедлок. Самая крупная регрессия релиза — одна строка в PR человека, ради новой фичи.
Открытыми остаются EF Core, где правок агента больше всего, холодная сборка конфигурации и то, сколько из этого изменится к финальному релизу.
Код стенда, данные и методика: dotnet-ai-era-benchmarks [15].
Автор: anechways
Источник [16]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/38104
URLs in this post:
[1] ASP.NET: http://ASP.NET
[2] EmberTrace: https://github.com/Echways/EmberTrace
[3] память: http://www.braintools.ru/article/4140
[4] microsoft.com: http://microsoft.com
[5] #126396: https://github.com/dotnet/runtime/pull/126396
[6] #135672: https://github.com/dotnet/runtime/pull/135672
[7] ошибку: http://www.braintools.ru/article/4192
[8] issue #136759: https://github.com/dotnet/runtime/issues/136759
[9] их тест биндинга: https://github.com/dotnet/performance/blob/main/src/benchmarks/micro/libraries/Microsoft.Extensions.Configuration/ConfigurationBinderBenchmarks.cs
[10] #53793: https://github.com/dotnet/runtime/issues/53793
[11] черновой PR #136899: https://github.com/dotnet/runtime/pull/136899
[12] #124331: https://github.com/dotnet/runtime/pull/124331
[13] дедлок: https://github.com/dotnet/runtime/issues/88390
[14] Tracer.Id: http://Tracer.Id
[15] dotnet-ai-era-benchmarks: https://github.com/Echways/dotnet-ai-era-benchmarks
[16] Источник: https://habr.com/ru/articles/1104356/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1104356
Нажмите здесь для печати.