ASP.NET Core 8 и .NET 8 обогатились многочисленными потрясающими нововведениями, благодаря которым повышается производительность. В этом посте мы подчеркнём некоторые улучшения, сделанные в ASP.NET Core, и покажем, как с их помощью можно нарастить скорость и эффективность работы веб-приложения. Этот пост можно считать продолжением Performance improvements in ASP.NET Core 7, выходившего в прошлом году. Естественно, при его подготовке я также вдохновлялся постом Performance Improvements in .NET 8. Многие из перечисленных улучшений также прямо или косвенно способствуют повышению производительности ASP.NET Core.

Расстановка контрольных точек

Во многих примерах из этого поста мы пользуемся BenchmarkDotNet.

Вот как подготовить такой проект для расстановки контрольных точек (бенчмаркинга):

  1. Создаём новое консольное приложение (dotnet new console)

  2. Добавляем ссылку Nuget на BenchmarkDotnet (dotnet add package BenchmarkDotnet) версия 0.13.8+

  3. Меняем содержимое файла Program.cs на var summary = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run();

  4. Ниже ставим тот фрагмент бенчмаркингового кода, который собираемся выполнить.

  5. Выполняем dotnet run -c Release и вводим номер той контрольной точки, которую хотим выполнить, как только получим такое приглашение.

Некоторые контрольные точки тестируют внутренние типы, поэтому полностью самодостаточную контрольную точку написать невозможно. В таких случаях мы либо ссылаемся на числа, которые получаем в результате прогона контрольных точек в репозитории (и ставим ссылку на репозиторий), либо предлагаем упрощённый пример, на котором можно особенно ярко продемонстрировать, каков именно эффект от описываемого улучшения.

Есть и несколько случаев, в которых мы будем ссылаться на наши сквозные бенчмарки, а эти бенчмарки находятся в открытом доступе и выложены здесь: https://aka.ms/aspnet/benchmarks. Мы выводим данные лишь за несколько последних месяцев — это делается для того, чтобы страница успевала загружаться за приемлемо краткое время.

Серверы

У нас есть 3 серверные реализации, написанные на ASP.NET Core: Kestrel, Http.Sys и IIS. Две последних применимы только в Windows, в значительной степени они используют один и тот же код. Производительность сервера крайне важна, поскольку именно сервер обрабатывает входящие запросы и переадресует их в код вашего приложения. Чем быстрее мы сможем обработать запрос, тем быстрее можно будет перейти к выполнению кода приложения.

Kestrel

Синтаксический разбор заголовков — один из первых этапов обработки любого запроса на сервере. Таким образом, для обеспечения производительности критически важно добиться, чтобы запросы как можно быстрее добирались до кода вашего приложения.

В Kestrel мы считываем байты из установленного соединения в конвейер System.IO.Pipelines.Pipe, в сущности, представляющий собой список байтов byte[]. При синтаксическом разборе заголовков мы читаем информацию из этого списка байтов byte[], и здесь возникает два пути, по которым может выполняться код. Первый — в случае, если целый заголовок умещается в пределах одного byte[]. Второй — если заголовок разделяется между несколькими байтами byte[].

В dotnet/aspnetcore#45044 второй (более медленный) путь был обновлён так, чтобы не приходилось выделять byte[] при синтаксическом разборе заголовка. Кроме того, использование SequenceReader было оптимизировано так, чтобы он работал преимущественно с базовым ReadOnlySequence<byte>, который в определённых случаях бывает самым быстрым вариантом.  

В результате удалось примерно на 18% повысить производительность при работе с многочастными заголовками, а также обойтись без дополнительных операций выделения памяти, тем самым снижая нагрузку на сборщик мусора. В следующем микротесте используются внутренние типы Kestrel, и в данном случае не так просто выделить «минимальную выборку». Те, кто хочет разобраться в этом примере подробнее, могут найти его в исходном коде Kestrel. Посмотрите на результаты прогонов до и после внесения этого изменения.

Метод

Среднее

Операций в секунду

Поколение 0

Выделено

MultispanUnicodeHeader – до

573,8 нс

1 742 893,2

48 Б

MultispanUnicodeHeader – после

484,9 нс

2 062 450,8

Ниже выведен профиль выделения памяти для сквозного бенчмарка, и именно с нововведёнными изменениями заметна разница. В данном сценарии удалось сократить выделение памяти byte[] на 73% — от 7,8 ГБ до 2 ГБ (на протяжении одного жизненного цикла данного бенчмарка). 

В dotnet/aspnetcore#48368 был заменён некоторый внутренний собственный векторизованный код для проверки сравнения по ascii. Для этого в.NET 8 предусмотрен новый класс Ascii. Так удалось избавиться примерно от 400 строк кода и воспользоваться такими усовершенствованиями, как AVX512 и ARM AdvSIMD, реализованными в коде Ascii — а в реализации Kestrel они отсутствуют.

Http.Sys

Ближе к завершению работы с версией 7.0 мы удалили часть избыточных функций, касающихся диспетчеризации пула потоков в Kestrel, тем самым существенно улучшив производительность. Подробнее об этом рассказано в посте, описывающем достижения по производительности за последние несколько лет. В самом начале существования версии 8.0 схожие изменения были внесены в сервер Http.Sys в dotnet/aspnetcore#44409. В результате наши сквозные контрольные тесты Json удалось улучшить на 11% с ~469k до ~522k запросов в секунду.

Другое внесённое нами изменение затрагивает крупные отклики, в особенности при соединениях, для которых характерна высокая задержка. В dotnet/aspnetcore#47 776 добавляется включённая по умолчанию опция, при которой активируется буферизация откликов в режиме ядра. В таком случае записи в приложение можно буферизовать на уровне операционной системы, независимо от того, одобрены или нет со стороны клиентского соединения более ранние записи. После этого операционная система может оптимизировать отправку данных, распараллелив записи и/или отправляя данные более крупными порциями за один раз. В случае использования соединений с повышенной задержкой преимущества ясны.

Чтобы продемонстрировать ситуацию на примере, мы разместили сервер в Швеции, а клиент — на западном побережье США, чтобы при соединении возникала некоторая задержка. Использовался следующий серверный код:

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseHttpSys(options =>
{
    options.UrlPrefixes.Add("http://+:12345");
    options.Authentication.Schemes = AuthenticationSchemes.None;
    options.Authentication.AllowAnonymous = true;
    options.EnableKernelResponseBuffering = true; // <-- новая настройка, действующая в 8.0
});

var app = builder.Build();

app.UseRouting();

app.MapGet("/file", () =>
{
    return TypedResults.File(File.Open("pathToLargeFile", FileMode.Open, FileAccess.Read));
});

app.Run();

Задержка (на путь туда-обратно между клиентом и сервером) составляла около 200 мс, а в ответ на поступивший с клиента запрос сервер отправлял файл размером 212 МБ. Устанавливая HttpSysOptions.EnableKernelResponseBuffering в «false», мы видели, что на скачивание файла уходит примерно 11 минут. Напротив, при значении «true» файл скачивался всего за 30 секунд или около того. Для данного конкретного сценария улучшение кардинальное, более чем в 22 раза!

Подробнее о том, как устроена буферизация откликов, рассказано в этом посте.

В dotnet/aspnetcore#44561 был отрефакторен внутренний код для записи откликов, присутствующий в Http.Sys. Это было сделано, чтобы избавиться от множества выделений GCHandle, а также с удобством удалить List<GCHandle>, в котором отслеживались дескрипторы, нужные для высвобождения. Это делается так: память (в которую напрямую пишутся данные) выделяется непосредственно в NativeMemory, это делается на этапе записи заголовков. Не закрепляя управляемую память, мы снижаем нагрузку на сборщик мусора, тем самым помогая сократить фрагментацию кучи. Недостаток такого подхода в том, что приходится чрезвычайно внимательно подходить к высвобождению памяти, поскольку сборщик мусора больше не следит за операциями выделения памяти. Если запустить простое веб-приложение и проследить, как используется GCHandle, то видно, что в 7.0 при небольшом отклике с 4 заголовками использовалось по 8 GCHandle на запрос, а при добавлении заголовков сверх этого добавлялось ещё по 2 GCHandle на заголовок. В 8.0 то же самое приложение использовало всего по 4 GCHandle на запрос, независимо от количества заголовков.

В dotnet/aspnetcore#45156 от @ladeak удалось улучшить реализации HttpContext.Request.Headers.Keys и HttpContext.Request.Headers.Count в Http.Sys. Кроме того, именно эти же реализации используются и в IIS, так что здесь мы вдвойне выигрываем. Ранее у этих свойств были обобщённые реализации, использовавшие IEnumerable и выражения linq. Теперь при работе с ними выделения памяти вручную подсчитываются и минимизируются, так, чтобы доступ к Count происходил полностью без выделения памяти. При работе с этим бенчмарком используются внутренние типы, поэтому просто оставлю ссылку на исходный код микробенчмарка, а не стану предоставлять здесь готовый микробенчмарк.

Было:

Метод

Среднее

Операций в секунду

Поколение 0

Выделено

CountSingleHeader

381,3 нс

2 622 896,1

0,0010

176 Б

CountLargeHeaders

3 293,4 нс

303 639,9

0,0534

9 032 Б

KeysSingleHeader

483,5 нс

2 068 299,5

0,0019

344 B

KeysLargeHeaders

3 559,4 нс

280 947,4

0,0572

9 648 Б

Стало:

Метод

Среднее

Операций в секунду

Поколение 0

Выделено

CountSingleHeader

249,1 нс

4 014 316,0

CountLargeHeaders

278,3 нс

3 593 059,3

KeysSingleHeader

506,6 нс

1 974 125,9

32 Б

KeysLargeHeaders

1 314,6 нс

760 689,5

0,0172

2 776 Б

Нативная предварительная компиляция (Native AOT)

Функция нативной предварительной компиляции (Native AOT) впервые появилась в .NET 7 и работала только с консольными приложениями и ограниченным набором библиотек. В .NET 8.0 этот набор библиотек преобразился, теперь нативная AOT-компиляция поддерживается гораздо шире, а также была добавлена поддержка для приложений ASP.NET Core. При AOT-компиляции можно сократить след приложения в памяти, сократить время загрузки, а также уменьшить потребность приложения в памяти. Но прежде, чем подробнее обсудить AOT и перейти к рассмотрению цифр, давайте поговорим о важной предпосылке — обрезке приложений (app trimming).

Начиная с.NET 6, функция обрезки приложений поддерживается в полной мере. Если вы активируете эту возможность при помощи <PublishTrimmed>true</PublishTrimmed> в вашем файле.csproj, то инструмент‑триммер начинает работать во время публикации и удаляет тот код, который вы не используете. В результате развёртываемые приложения могут получаться стабильно компактнее. Это полезно в сценариях, когда приходится работать на устройствах с ограниченной памятью. Но такая обрезка имеет свою цену — возможно, в библиотеках потребуется аннотировать типы и вызовы методов, чтобы рассказать триммеру об используемом коде (сам триммер не всё и не всегда может определить). В противном случае триммер может обрезать тот код, который вы активно используете, и тогда приложение начнёт работать не так, как положено. Триммер будет выдавать предупреждения, если заметит код, который потенциально несовместим с операциями обрезки. До.NET 8 свойство <TrimMode> для публикации веб‑приложений устанавливалось в значение partial. Это означало, что обрезка могла применяться только в тех сборках, которые явно объявляли, что их можно обрезать. Теперь, в 8.0, со свойством <TrimMode>используется значение full — это означает, что обрезаться могут все сборки, используемые в приложении. Эти настройки документированы в документации по вариантам обрезки.

В .NET 6 и .NET 7 было много библиотек, не совместимых с обрезкой, в особенности это касалось библиотек ASP.NET Core. Если бы вы попытались опубликовать в версии 7.0 простое приложение ASP.NET Core, то получили бы от триммера целый ворох предупреждений, так как в большей части ASP.NET Core обрезка в тот период ещё не поддерживалась.

Далее приведён отрывок из приложения на ASP.NET Core, и на этом примере показано, как отличается обрезка в net7.0 и net8.0. Все числа даны для публикации под windows.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFrameworks>net7.0;net8.0</TargetFrameworks>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>
// dotnet publish --self-contained --runtime win-x64 --framework net7.0 -p:PublishTrimmed=true -p:PublishSingleFile=true --configuration Release
var app = WebApplication.Create();

app.Run((c) => c.Response.WriteAsync("hello world"));

app.Run();

Версия

Обрезка

Предупреждения

Размер приложения

Длительность публикации

net7.0

false

0

88,4 МБ

3,9 с

net8.0

false

0

90,9 МБ

3,9 с

net7.0

true

16

28,9 МБ

16,4 с

net8.0

true

0

17,3 МБ

10,8 с

При обрезке публикации в.net8.0 не просто прекращаются предупреждения, но и само приложение становится компактнее. Дело в том, что мы аннотируем всё больше библиотек, и линковщик может найти больше такого кода, который не используется в приложении. Отчасти аннотирование библиотек связано с анализом того, какой именно код триммер не трогает — тогда код модифицируется таким образом, чтобы обрезка происходила более безболезненно. Есть множество документов, которые пригодятся вам в этой работе: dotnet/aspnetcore#47 567, dotnet/aspnetcore#47 454, dotnet/aspnetcore#46 082, dotnet/aspnetcore#46 015, dotnet/aspnetcore#45 906, dotnet/aspnetcore#46 020, и многие другие.

Поле Publish duration рассчитывалось при помощи команды Measure-Command на языке powershell (причём /bin/ и /obj/ удалялись перед каждым прогоном). Как видите, с включённой обрезкой длительность публикации может увеличиться, поскольку триммеру приходится проанализировать всю программу, чтобы посмотреть, что можно удалить — а эта операция требует затрат.

Также были введены две уменьшенные версии WebApplication, и в том случае, если вам понадобятся ещё более мелкие приложения, то их можно создавать при помощи CreateSlimBuilder и CreateEmptyBuilder. Давайте изменим предыдущее приложение, переориентировав его на работу с CreateSlimBuilder:

// dotnet publish --self-contained --runtime win-x64 --framework net8.0 -p:PublishTrimmed=true -p:PublishSingleFile=true --configuration Release
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Create();

app.Run((c) => c.Response.WriteAsync("hello world"));

app.Run();

Такой вариант даст нам приложение размером 15,5 МБ. А затем пойдём ещё дальше при помощи CreateEmptyBuilder:

// dotnet publish --self-contained --runtime win-x64 --framework net8.0 -p:PublishTrimmed=true -p:PublishSingleFile=true --configuration Release
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()
{
    Args = args
});
var app = builder.Create();

app.Run((c) => c.Response.WriteAsync("hello world"));

app.Run();

В результате получим приложение размером 13,7 МБ. Правда, в данном случае приложение работать не будет, так как нет зарегистрированной серверной реализации. Поэтому, если мы добавим Kestrel при помощи builder.WebHost.UseKestrelCore(); , то приложение увеличится до 15 МБ.

TFM

Сборщик

Размер приложения

net8.0

Create

17,3 МБ

net8.0

Slim

15,5 МБ

net8.0

Empty

13,7 МБ

net8.0

Empty+Server

15,0 МБ

Обратите внимание: оба эти API есть в доступе, начиная с версии 8.0, и при работе с ними убирается множество умолчаний, поэтому такой стиль обходится дороже.

Итак, мы немного разобрали, как происходит обрезка, и убедились, что в 8.0 больше совместимых с обрезкой библиотек. Поэтому давайте поговорим и о нативной предварительной компиляции (Native AOT). Как и в случае с обрезкой, если ваше приложение или библиотека не совместимы с Native AOT, вы будете получать предупреждения при сборке под Native AOT. Кроме того, есть дополнительные ограничения, касающиеся того, что срабатывает и не срабатывает с Native AOT.

Работая с тем же приложением, что и ранее, мы активируем нативную предварительную компиляцию, добавив в наш файл csproj <PublishAot>true</PublishAot>.

TFM

AOT

Размер приложения

Длительность публикации

net7.0

false

88,4 МБ

3,9 с

net8.0

false

90,9 МБ

3,9 с

net7.0

true

40 МБ

71,7 с

net8.0

true

12,6 МБ

22,7 с

Точно как в случае с обрезкой, можно протестировать те API WebApplication, в которых включено сравнительно немного умолчаний.

TFM

Сборщик

Размер приложения

net8.0

Create

12,6 МБ

net8.0

Slim

8,8 МБ

net8.0

Empty

5,7 МБ

net8.0

Empty+Server

7,8 МБ

Это же круто! Имеем небольшое приложение net8.0 размером 90,9 МБ, а когда оно публикуется с применением нативной предварительной компиляции, размер приложения составит 12,6 МБ, а может уменьшиться до 7,8 МБ (я предполагаю, что нам понадобится сервер, а он нам действительно понадобится).

Теперь давайте рассмотрим некоторые другие аспекты производительности, присущие приложению с Native AOT: скорость запуска, расход памяти, количество запросов в секунду. Чтобы правильно показать числовые значения E2E, нам нужно обустроить конфигурацию из нескольких машин. Это нужно, чтобы серверные и клиентские процессы не крали друг у друга процессорное время. Тогда у нас не было бы случайных процессов, которые работали бы так, как у вас на локальной машине. Я воспользуюсь внутренней инфраструктурой бенчмаркинга, в которой используется контрольный инструмент crank. Также воспользуемся машинами aspnet-citrine-win и aspnet-citrine-lin для сервера и загрузки соответственно. Спецификации обеих машин описаны в нашем файле readme по бенчмаркам. Наконец, я воспользуюсь приложением, в котором применяются минимальные API для возврата полезной нагрузки в формате json. В этом приложении используется уже показанный нами ранее тонкий (Slim) сборщик, а также устанавливается свойство <InvariantGlobalization>true</InvariantGlobalization> в файле csproj.

Если запустить приложение без каких-либо дополнительных настроек:

crank –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/goldilocks.benchmarks.yml –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/build/ci.profile.yml –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/steadystate.profile.yml –scenario basicminimalapivanilla –profile intel-win-app –profile intel-lin-load –application.framework net8.0 –application.options.collectCounters true

то приложение станет запускаться примерно за 293 мс, в рабочем виде будет занимать 444 МБ и при этом будет обрабатывать ~762k запросов в секунду.

Если запустить то же самое приложение, но опубликовать его с включённой нативной предварительной компиляцией:

crank –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/goldilocks.benchmarks.yml –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/build/ci.profile.yml –config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/steadystate.profile.yml –scenario basicminimalapipublishaot –profile intel-win-app –profile intel-lin-load –application.framework net8.0 –application.options.collectCounters true

В таком случае получаем запуск примерно за 67 мс, приложение размером 56 МБ в рабочем виде, и оно обрабатывает ~681k запросов в секунду. Таким образом, скорость запуска удаётся увеличить примерно на 77%, рабочая конфигурация получается на 87% компактнее, а количество обрабатываемых в секунду запросов снижается примерно на 12%. Ожидается, что скорость запуска будет столь высока, так как приложение уже оптимизировано, но при этом оптимизированный код запускается сразу, без этапа динамической компиляции (JIT). Кроме того, в приложениях без нативной AOT-компиляции каждый из пусковых методов, скорее всего, запускается всего один раз, поэтому ни с одним из них не применяется многоуровневая компиляция. Следовательно, пусковые методы не будут оптимизированы до максимума, а вот при NativeAOT-компиляции пусковой метод оптимизируется на полную. Рабочая конфигурация приложения нас немного удивляет, она получается немного меньше, так как по умолчанию нативные AOT-приложения используются с новым сборщиком мусора «DATAS» («Динамическая адаптация к размерам приложений»). Этот сборщик мусора пытается выдерживать баланс между пропускной способностью и общим расходом памяти, и мы видим, что это ему удаётся: он примерно на 87% ужимает рабочую сборку, частично жертвуя тем, сколько откликов можно обработать в секунду. Подробнее об этой новой настройке сборщика мусора можете почитать в одной из статей блогера Maoni0.

Давайте также сравним приложения, использующие и не использующие нативную предварительную компиляцию в том, как они выглядят при серверной сборке мусора. Так что давайте добавим --application.environmentVariables DOTNET_GCDynamicAdaptationMode=0 при запуске приложения с нативной предварительной компиляцией.

На этот раз получаем время запуска ~64 мс, рабочую конфигурацию приложения в 403 МБ, а также можем обработать ~730k запросов в секунду. Запуск по-прежнему крайне быстр, так как замена сборщика мусора на него не влияет. Рабочая конфигурация ближе к показателю, который мы видели у приложения без нативной предварительной компиляции, но всё‑таки немного меньше — ведь нам не приходится загружать и запускать инструмент динамической компиляции. Показатель «запросы в секунду» ближе к приложению без нативной предварительной компиляции, так как мы используем серверный сборщик мусора, оптимизирующий прежде всего пропускную способность, и лишь потом расход памяти.

AOT

Сборщик мусора

Запуск

Рабочая конфигурация

RPS

false

Серверный

293 мс

444 МБ

762k

false

DATAS

303 мс

77 МБ

739k

true

Серверный

64 мс

403 МБ

730k

true

DATAS

67 мс

56 МБ

681k

В приложениях без нативной предварительной компиляции есть код, оптимизированный под динамическую (JIT) компиляцию. Если в .NET 8 запускать JIT по умолчанию, то будет использоваться динамическая оптимизация по профилю (PGO). Это очень крутая штука, пользоваться которой в режиме Native AOT вы не сможете, просто потому, что приложения без нативной предварительной компиляции могут иметь более высокую пропускную способность, чем приложения с Native AOT. Подробнее о динамической оптимизации по профилю можете почитать в статье о производительности в .NET 8.

Если вы готовы частично пожертвовать размером публикуемого приложения, но получить более оптимизированный код, то при сборке и публикации вашего приложения с Native AOT можете передать опцию /p:OptimizationPreference=Speed. Если проделать это для нашего контрольного приложения (при применении серверного сборщика мусора), то имеем опубликованное приложение размером 9,5 МБ вместо 8,9 МБ и с обработкой 745 тысяч, а не 730 тысяч запросов в секунду.

В том приложении, которое мы используем, применяются минимальные API, которые по умолчанию не располагают к обрезке. В них происходит обширная рефлексия и динамическая генерация кода. Эти процессы не поддаются статическому анализу, поэтому триммер не может безопасно работать с приложением. Почему же мы не видим предупреждений, когда публикуем это приложение с применением Native AOT? Потому, что мы уже написали генератор исходного кода «Request Delegate Generator» (RDG), заменяющий методы MapGet, MapPost, т.д. кодом, который приспособлен к обрезке. Этот генератор исходного кода автоматически применяется в приложениях ASP.NET Core при обрезке/aot‑публикации. На этом переходим к следующему разделу, в котором подробно обсудим RDG.

Генератор делегатов основных запросов

Генератор делегатов основных запросов (RDG) — это генератор исходного кода, созданный специально для адаптации минимальных API к обрезке и нативной предварительной компиляции. Если использовать минимальные API без RDG, то мы получим множество предупреждений, а само приложение, вероятно, будет работать не как ожидается. Вот экспресс‑пример: в нём мы покажем конечную точку, которая выдаст исключение при попытке использовать Native AOT без RDG, но с включённым RDG сработает (также сработает, если Native AOT не использовать).

app.MapGet("/test", (Bindable b) => "Hello world!");

public class Bindable
{
    public static ValueTask<Bindable?> BindAsync(HttpContext context, ParameterInfo parameter)
    {
        return new ValueTask<Bindable?>(new Bindable());
    }
}

Эта программа выбрасывает исключение, если отправить запрос GET по адресу /test, так как ссылка на метод Bindable.BindAsync ставится с использованием рефлексии. Поэтому триммер не может методом статического анализа определить, что данный метод в самом деле используется, и удалит его. После этого минимальные API сочтут, что вызову MapGet требуется тело запроса, а по умолчанию для GET‑вызовов это не разрешено. Поэтому, применяя нативную предварительную компиляцию, мы не только избавляемся от множества предупреждений и заставляем программу работать как положено, но и улучшаем время до первого отклика, а также уменьшаем размер публикуемого приложения.

Без RDG первый запрос в приложение поступает на том этапе, когда генерируются все деревья выражений для всех конечных точек приложения. Поскольку RDG генерирует исходный код для конечной точки во время компиляции, генерировать деревья выражений не требуется, а код для конкретной конечной точки уже доступен, и к его выполнению можно сразу же приступать.

Давайте попробуем взять то приложение, с которым мы работали ранее и воспользоваться им для контрольного замера AOT. В таком случае мы получаем ~187 мс до первого запроса, если работаем без AOT и без RDG. Затем получаем ~130 мс, если включаем RDG. При публикации в режиме AOT время до первого отклика составляет ~60 мс, независимо от того, используется RDG или нет. Но у этого приложения всего две конечные точки. Давайте же добавим ещё 1000 конечных точек, чтобы оценить разницу!

2 маршрута:

AOT

RDG

Первый запрос

Размер при публикации

false

false

187 мс  

97 МБ

false

true

130 мс

97 МБ

true

false

60 мс

11,15 МБ

true

true

60 мс

8,89 МБ

1002 маршрута:

AOT

RDG

Первый запрос

Размер при публикации

false

false

1082 мс

97 МБ

false

true

176 мс

97 МБ

true

false

157 мс

11,15 МБ

true

true

84мс

8,89МБ

API времени выполнения

В этом разделе будут рассмотрены такие изменения, которые в основном касаются обновления тех новых API, которые были введены в.NET 8 в рамках базовой библиотеки классов (BCL).

SearchValues

В рамках dotnet/aspnetcore#45 300 от @gfoidl, dotnet/aspnetcore#47 459, dotnet/aspnetcore#49 114 и dotnet/aspnetcore#49 117 используется новый тип SearchValues, благодаря которому во всех этих путях кода можно применять оптимизированные реализации поиска, когда мы ищем конкретные значения. В разделе SearchValues из этой статьи о производительности в.NET 8 подробнее разобраны различные алгоритмы поиска и объяснено, почему этот тип так крут!

Диапазоны (Spans)

В dotnet/aspnetcore#46 098 используется новый метод MemoryExtensions.Split(ReadOnlySpan<char> source, Span<Range> destination, char separator). С его помощью определённые экземпляры string.Split(...) можно заменить версиями, не требующими выделения памяти. Так экономится выделение string[], а также выделение отдельных экземпляров string, входящих в состав string[]. Подробнее об этом новом API рассказано в статье о производительности в.NET 8, а именно в разделе о диапазонах (span).

FrozenDictionary

Ещё один новый тип, появившийся в этой версии — FrozenDictionary. С его помощью можно сконструировать словарь, который оптимизирован под операции чтения — правда, именно поэтому конструируется немного медленно.

В dotnet/aspnetcore#49 714 маршрутизация переориентируется с работы с Dictionary на работу с FrozenDictionary. Этот словарь применяется при маршрутизации http‑запроса на корректную конечную точку, а это происходит практически при каждом запросе в приложении. В следующих таблицах показано, сколько стоит создать словарь, а сколько — замороженный словарь, а затем, сколько стоит использовать словарь и замороженный словарь соответственно. Как видите, создание FrozenDictionary может проходить в 13 раз медленнее, чем создание обычного словаря, но всё равно эти операции занимают считанные микросекунды (1/1000-я миллисекунды). При этом FrozenDictionary для конкретного приложения конструируется всего один раз. Всем нравится наблюдать, что производительность в пересчёте на одну операцию при использовании FrozenDictionary в 2,5x-3,5 раз выше, чем при работе с Dictionary!

[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class JumpTableMultipleEntryBenchmark
{
    private string[] _strings;
    private int[] _segments;

    private JumpTable _dictionary;
    private JumpTable _frozenDictionary;
    private List<(string text, int _)> _entries;

    [Params(1000)]
    public int NumRoutes;

    [GlobalSetup]
    public void Setup()
    {
        _strings = GetStrings(1000);
        _segments = new int[1000];

        for (var i = 0; i < _strings.Length; i++)
        {
            _segments[i] = _strings[i].Length;
        }

        var samples = new int[NumRoutes];
        for (var i = 0; i < samples.Length; i++)
        {
            samples[i] = i * (_strings.Length / NumRoutes);
        }

        _entries = new List<(string text, int _)>();
        for (var i = 0; i < samples.Length; i++)
        {
            _entries.Add((_strings[samples[i]], i));
        }

        _dictionary = new DictionaryJumpTable(0, -1, _entries.ToArray());
        _frozenDictionary = new FrozenDictionaryJumpTable(0, -1, _entries.ToArray());
    }

    [BenchmarkCategory("GetDestination"), Benchmark(Baseline = true, OperationsPerInvoke = 1000)]
    public int Dictionary()
    {
        var strings = _strings;
        var segments = _segments;

        var destination = 0;
        for (var i = 0; i < strings.Length; i++)
        {
            destination = _dictionary.GetDestination(strings[i], segments[i]);
        }

        return destination;
    }

    [BenchmarkCategory("GetDestination"), Benchmark(OperationsPerInvoke = 1000)]
    public int FrozenDictionary()
    {
        var strings = _strings;
        var segments = _segments;

        var destination = 0;
        for (var i = 0; i < strings.Length; i++)
        {
            destination = _frozenDictionary.GetDestination(strings[i], segments[i]);
        }

        return destination;
    }

    [BenchmarkCategory("Create"), Benchmark(Baseline = true)]
    public JumpTable CreateDictionaryJumpTable() => new DictionaryJumpTable(0, -1, _entries.ToArray());

    [BenchmarkCategory("Create"), Benchmark]
    public JumpTable CreateFrozenDictionaryJumpTable() => new FrozenDictionaryJumpTable(0, -1, _entries.ToArray());

    private static string[] GetStrings(int count)
    {
        var strings = new string[count];
        for (var i = 0; i < count; i++)
        {
            var guid = Guid.NewGuid().ToString();

            // От 5 до 36 символов
            var text = guid.Substring(0, Math.Max(5, Math.Min(i, 36)));
            if (char.IsDigit(text[0]))
            {
                // Преобразовать первый символ в букву.
                text = ((char)(text[0] + ('G' - '0'))) + text.Substring(1);
            }

            if (i % 2 == 0)
            {
                // Половина из них – в нижнем регистре
                text = text.ToLowerInvariant();
            }

            strings[i] = text;
        }

        return strings;
    }
}

public abstract class JumpTable
{
    public abstract int GetDestination(string path, int segmentLength);
}

internal sealed class DictionaryJumpTable : JumpTable
{
    private readonly int _defaultDestination;
    private readonly int _exitDestination;
    private readonly Dictionary<string, int> _dictionary;

    public DictionaryJumpTable(
        int defaultDestination,
        int exitDestination,
        (string text, int destination)[] entries)
    {
        _defaultDestination = defaultDestination;
        _exitDestination = exitDestination;

        _dictionary = entries.ToDictionary(e => e.text, e => e.destination, StringComparer.OrdinalIgnoreCase);
    }

    public override int GetDestination(string path, int segmentLength)
    {
        if (segmentLength == 0)
        {
            return _exitDestination;
        }

        var text = path.Substring(0, segmentLength);
        if (_dictionary.TryGetValue(text, out var destination))
        {
            return destination;
        }

        return _defaultDestination;
    }
}

internal sealed class FrozenDictionaryJumpTable : JumpTable
{
    private readonly int _defaultDestination;
    private readonly int _exitDestination;
    private readonly FrozenDictionary<string, int> _dictionary;

    public FrozenDictionaryJumpTable(
        int defaultDestination,
        int exitDestination,
        (string text, int destination)[] entries)
    {
        _defaultDestination = defaultDestination;
        _exitDestination = exitDestination;

        _dictionary = entries.ToFrozenDictionary(e => e.text, e => e.destination, StringComparer.OrdinalIgnoreCase);
    }

    public override int GetDestination(string path, int segmentLength)
    {
        if (segmentLength == 0)
        {
            return _exitDestination;
        }

        var text = path.Substring(0, segmentLength);
        if (_dictionary.TryGetValue(text, out var destination))
        {
            return destination;
        }

        return _defaultDestination;
    }
}

Метод

Число маршрутов

Среднее

Ошибка

Стд.откл

Коэф.

Коэф./Стд. откл

CreateDictionaryJumpTable

25

735,797 нс

8,5503 нс

7,5797 нс

1,00

0,00

CreateFrozenDictionaryJumpTable

25

4 677,927 нс

80,4279 нс

71,2972 нс

6,36

0,11

CreateDictionaryJumpTable

50

1 433,309 нс

19,4435 нс

17,2362 нс

1,00

0,00

CreateFrozenDictionaryJumpTable

50

10 065,905 нс

188,7031 нс

176,5130 нс

7,03

0,12

CreateDictionaryJumpTable

100

2 712,224 нс

46,0878 нс

53,0747 нс

1,00

0,00

CreateFrozenDictionaryJumpTable

100

28 397,809 нс

358,2159 нс

335,0754 нс

10,46

0,20

CreateDictionaryJumpTable

1000

28 279,153 нс

424,3761 нс

354,3733 нс

1,00

0,00

CreateFrozenDictionaryJumpTable

1000

313 515,684 нс

6 148,5162 нс

8 208,0925 нс

11,26

0,33

Dictionary

25

21,428 нс

0,1816 нс

0,1516 нс

1,00

0,00

FrozenDictionary

25

7,137 нс

0,0588 нс

0,0521 нс

0,33

0,00

Dictionary

50

21,630 нс

0,1978 нс

0,1851 нс

1,00

0,00

FrozenDictionary

50

7,476 нс

0,0874 нс

0,0818 нс

0,35

0,00

Dictionary

100

23,508 нс

0,3498 нс

0,3272 нс

1,00

0,00

FrozenDictionary

100

7,123 нс

0,0840 нс

0,0745 нс

0,30

0,00

Dictionary

1000

23,761 нс

0,2360 нс

0,2207 нс

1,00

0,00

FrozenDictionary

1000

8,516 нс

0,1508 нс

0,1337 нс

0,36

0,01

Прочее

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

Регулярные выражения

В рамках разработки предварительной компиляции мы заметили, что регулярное выражение, созданное в RegexRouteConstraint (подробнее об этом рассказано в разделе об ограничениях маршрутов) накидывает ~1 МБ к размеру опубликованного приложения. Дело в том, что ограничения маршрутов являются динамическими (они определяются в коде приложения), а мы используем конструктор Regex, принимающий RegexOptions. Именно поэтому триммер должен постоянно держать в доступе весь код регулярных выражений, который потенциально может пойти в дело. В том числе, это касается движка NonBacktracking, для работы которого требуется ~.8 МБ кода. Триммер, добавляя RegexOptions.Compiled, может убедиться, что код NonBacktracking использоваться не будет, и поэтому избавится от него, сократив размер приложения примерно на.8MB. Кроме того, работать со скомпилированными регулярными выражениями получается быстрее, чем с интерпретируемыми. В данном случае нашлось такое решение на скорую руку: просто добавить RegexOptions.Compiled на этапе создания Regex. Это и сделал в dotnet/aspnetcore#46 192 разработчик @eugeneogongo. Проблема в том, что тогда приложение запускается медленнее, так как именно при пуске приложения приходится разрешать ограничения, а компилируемые регулярные выражения конструируются медленнее.

В dotnet/aspnetcore#46323 это удалось исправить, применив ленивую инициализацию регулярных выражений. В результате приложения стали запускаться даже быстрее, чем в версии 7.0 (там скомпилированные регулярные выражения не применялись). Также было добавлено кэширование для ограничений маршрутов. Этот механизм позволил совместно использовать ограничения сразу во множестве маршрутов, тем самым избавившись от некоторого количества выделений памяти.

Если прогнать микробенчмарк для построителя маршрутов, чтобы измерить производительность при запуске, то при использовании 1000 маршрутов наблюдается улучшение почти на 450%, так как больше не приходится инициализировать регулярные выражения. Этот бенчмарк лежит в репозитории dotnet/aspnetcore. В нём очень много конфигурационного кода, поэтому для этого поста он несколько длинноват.

Вот как было раньше, с интерпретируемыми регулярными выражениями:

Метод

Среднее

Операций в секунду

Поколение 0

Поколение 1

Выделено

Build

6,739 мс

148,4

15,6250

7 МБ

А вот как стало, с компилируемыми и лениво инициализируемыми регулярными выражениями:

Метод

Среднее

Операций в секунду

Поколение 0

Gen 1

Выделено

Build

1,521 мс

657,2

5,8594

1.9531

2 МБ

Ещё одна оптимизация регулярных выражений пришла из dotnet/aspnetcore#44 770, где работу с регулярными выражениями при маршрутизации переориентировали на Regex Source Generator. Так издержки на компиляцию регулярных выражений переносятся на время сборки. Сам код регулярных выражений при этом также ускоряется в силу тех оптимизаций, которыми пользуется генератор исходного кода, но не пользуется внутрипроцессный генератор регулярных выражений.

Приведём упрощённый пример, на котором можно сравнить использование сгенерированных и скомпилированных регулярных выражений.

public partial class AlphaRegex
{
    static Regex Net7Constraint = new Regex(
            @"^[a-z]*$",
            RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.IgnoreCase,
            TimeSpan.FromSeconds(10));

    static Regex Net8Constraint = GetAlphaRouteRegex();

    [GeneratedRegex(@"^[A-Za-z]*$")]
    private static partial Regex GetAlphaRouteRegex();

    [Benchmark(Baseline = true)]
    public bool CompiledRegex()
    {
        return Net7Constraint.IsMatch("Administration") && Net7Constraint.IsMatch("US");
    }

    [Benchmark]
    public bool SourceGenRegex()
    {
        return Net8Constraint.IsMatch("Administration") && Net8Constraint.IsMatch("US");
    }
}

Метод

Среднее

Ошибка

Стд.откл

Коэф.

CompiledRegex

86,92 нс

0,572 нс

0,447 нс

1,00

SourceGenRegex

57,81 нс

0,860 нс

0,805 нс

0,66

Анализаторы

При помощи анализаторов удобно вычленять в коде такие проблемы, о которых сложно сообщить на уровне сигнатур API, подсказать такие паттерны кода, которые удобнее читать. Кроме того, они могут натолкнуть на способы писать более высокопроизводительный код. В разработках dotnet/aspnetcore#44 799 и dotnet/aspnetcore#44 791, обе от пользователя @martincostello, включена возможность CA1854, позволяющая обойтись без 2 операций поиска в словаре, когда нужна только 1 такая операция. В свою очередь, dotnet/aspnetcore#44 269 позволяет включить целую группу анализаторов, многие из которых помогают писать более производительные API. Они подробнее описаны в посте о.NET 7, описывающем ситуацию за последние годы.

Тем разработчикам, которые заинтересованы повысить производительность собственных продуктов, рекомендую обратить внимание на анализаторы, ориентированные на производительность. В этом источнике приведён список из многих анализаторов, позволяющих легко исправлять проблемы с производительностью.

StringBuilder

StringBuilder — исключительно полезный класс, при помощи которого можно собрать строку, когда вы либо не можете предвычислить размер создаваемой строки, либо ищете лёгкий способ собрать строку, но обойтись без тех осложнений, что возникают при работе с string.Create(...).

В комплекте со StringBuilder даётся множество полезных методов, а также собственная реализация InterpolatedStringHandler. Именно поэтому можно «создавать» строки для последующего добавления в StringBuilder, но при этом не выделять память под строку. Например, раньше вам, возможно, пришлось бы написать stringBuilder.Append(FormattableString.Invariant($"{key} = {value}"));. В таком случае вы выделяли бы строку при помощи метода FormattableString.Invariant(...), затем клали её во внутренний буфер  char[] класса StringBuilder, временно выделяя память под эту строку. Теперь же вместо этого можно написать stringBuilder.Append(CultureInfo.InvariantCulture, $"{key} = {value}");. Также ситуация выглядит так, если бы вы выделяли строку при помощи $"{key} = {value}", но, поскольку у StringBuilder есть собственный InterpolatedStringHandler, строка, в принципе, не выделяется, а записывается непосредственно во внутренний char[].

В dotnet/aspnetcore#44 691 исправлены некоторые паттерны использования StringBuilder; это сделано во избежание выделений, а также для того, чтобы воспользоваться перегрузкой (перегрузками) InterpolatedStringHandler. В одном конкретном примере берётся byte[] и преобразуется в строку в шестнадцатеричном формате — так, чтобы в таком виде её можно было отправить как строку запроса.

[MemoryDiagnoser]
public class AppendBenchmark
{
    private byte[] _b = new byte[30];

    [GlobalSetup]
    public void Setup()
    {
        RandomNumberGenerator.Fill(_b);
    }

    [Benchmark]
    public string AppendToString()
    {
        var sb = new StringBuilder();
        foreach (var b in _b)
        {
            sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
        }
        return sb.ToString();
    }

    [Benchmark]
    public string AppendInterpolated()
    {
        var sb = new StringBuilder();
        foreach (var b in _b)
        {
            sb.Append(CultureInfo.InvariantCulture, $"{b:x2}");
        }
        return sb.ToString();
    }
}

Метод

Среднее

Поколение 0

Выделено

AppendToString

748,7 нс

0,1841

1448 Б

AppendInterpolated

739,7 нс

0,0620

488 Б

Итоги

Спасибо, что дочитали! Испробуйте.NET 8 и расскажите, как изменилась производительность вашего приложения! Нас всегда интересует обратная связь, так как мы хотим улучшить продукт и поэтому были бы рады вашему вкладу, будь это даже сообщение о проблеме или пул‑реквест. Если вы хотите почерпнуть ещё больше ценной информации о производительности — почитайте пост Performance Improvements in.NET 8. Кроме того, обратите внимание на истории от разработчиков, где многие команды Microsoft делятся своим опытом о миграции с.NET Framework на.NET или на новые версии.NET, пожиная при этом серьёзный выигрыш в производительности и налаживая ситуацию с эксплуатационными издержками.

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


  1. Miheev2
    14.11.2023 06:53

    Рад что есть улучшения.
    Больше беспокоит, что уже годами нет особых улучшений в самой VS.


    1. mayorovp
      14.11.2023 06:53

      А каких именно особых улучшений вы ждёте? Так-то по мелочам она постоянно улучшается...


      1. Miheev2
        14.11.2023 06:53
        +1

        Прямо хочу переход на .NET 6 хотя бы, соотвественоо совместимость с Linux. (наивно надеюсь)
        Увеличение производительности.
        А так, например больше компиляции в памяти. Что бы не работал постоянно с диском. По моему, каждый раз читает всё с диска.

        Улучшение Razor(Blazor) анализатора.
        А то он постоянно забывает разметку, начинает подчёркивать красным, помогает только перерзагрузка. Или забывает подсказки и тд.
        И это не меняется с первых версий Blazor...
        Больше анализа памяти, утечек, нативной памяти и тд. А то сейчас имеющиеся отчёты не работают.


        1. mayorovp
          14.11.2023 06:53
          +1

          Чтобы была компиляция в памяти - нужен компилятор в памяти :-)

          Тут улучшать надо в первую очередь не студию, а сборочную систему, которая вообще-то часть .NET SDK.


          1. Miheev2
            14.11.2023 06:53

            Это только касаемо сборки.
            А остальное касаается студии.
            Но как раз вчера думал, почему бы самому не разобрать и не начать пул ркеквесты выдавать сначала в простые проекты, и дорасти до пул реквестов в NET SDK через несколько лет.
            Пока много лет ожидается буду с ним работать.