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. Вот как подготовить такой проект для расстановки контрольных точек (бенчмаркинга): Создаём новое консольное приложение (dotnet new console) Добавляем ссылку Nuget на BenchmarkDotnet (dotnet add package BenchmarkDotnet) версия 0.13.8+ Меняем содержимое файла Program.cs на var summary = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(); Ниже ставим тот фрагмент бенчмаркингового кода, который собираемся выполнить. Выполняем dotnet run -c Release и вводим номер той контрольной точки, которую хотим выполнить, как только получим такое приглашение. Некоторые контрольные точки тестируют внутренние типы, поэтому полностью самодостаточную контрольную точку написать невозможно. В таких случаях мы либо ссылаемся на числа, которые получаем в результате прогона контрольных точек в репозитории (и ставим ссылку на репозиторий), либо предлагаем упрощённый пример, на котором можно особенно ярко продемонстрировать, каков именно эффект от описываемого улучшения. Есть и несколько случаев, в которых мы будем ссылаться на наши сквозные бенчмарки, а эти бенчмарки находятся в открытом доступе и выложены здесь: https://aka.ms/aspnet/benchmarks. Мы выводим данные лишь за несколько последних месяцев — это делается для того, чтобы страница успевала загружаться за приемлемо краткое время. У нас есть 3 серверные реализации, написанные на ASP.NET Core: Kestrel, Http.Sys и IIS. Две последних применимы только в Windows, в значительной степени они используют один и тот же код. Производительность сервера крайне важна, поскольку именно сервер обрабатывает входящие запросы и переадресует их в код вашего приложения. Чем быстрее мы сможем обработать запрос, тем быстрее можно будет перейти к выполнению кода приложения. Синтаксический разбор заголовков — один из первых этапов обработки любого запроса на сервере. Таким образом, для обеспечения производительности критически важно добиться, чтобы запросы как можно быстрее добирались до кода вашего приложения. В 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 они отсутствуют.  Ближе к завершению работы с версией 7.0 мы удалили часть избыточных функций, касающихся диспетчеризации пула потоков в Kestrel, тем самым существенно улучшив производительность. Подробнее об этом рассказано в посте, описывающем достижения по производительности за последние несколько лет. В самом начале существования версии 8.0 схожие изменения были внесены в сервер Http.Sys в dotnet/aspnetcore#44409. В результате наши сквозные контрольные тесты Json удалось улучшить на 11% с ~469k до ~522k запросов в секунду. Другое внесённое нами изменение затрагивает крупные отклики, в особенности при соединениях, для которых характерна высокая задержка. В dotnet/aspnetcore#47 776 добавляется включённая по умолчанию опция, при которой активируется буферизация откликов в режиме ядра. В таком случае записи в приложение можно буферизовать на уровне операционной системы, независимо от того, одобрены или нет со стороны клиентского соединения более ранние записи. После этого операционная система может оптимизировать отправку данных, распараллелив записи и/или отправляя данные более крупными порциями за один раз. В случае использования соединений с повышенной задержкой преимущества ясны. Чтобы продемонстрировать ситуацию на примере, мы разместили сервер в Швеции, а клиент — на западном побережье США, чтобы при соединении возникала некоторая задержка. Использовался следующий серверный код: Задержка (на путь туда-обратно между клиентом и сервером) составляла около 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) впервые появилась в .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. Версия Обрезка Предупреждения Размер приложения Длительность публикации 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: Такой вариант даст нам приложение размером 15,5 МБ. А затем пойдём ещё дальше при помощи CreateEmptyBuilder: В результате получим приложение размером 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. Если запустить приложение без каких-либо дополнительных настроек: то приложение станет запускаться примерно за 293 мс, в рабочем виде будет занимать 444 МБ и при этом будет обрабатывать ~762k запросов в секунду. Если запустить то же самое приложение, но опубликовать его с включённой нативной предварительной компиляцией: В таком случае получаем запуск примерно за 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 не использовать). Эта программа выбрасывает исключение, если отправить запрос 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, которые были введены в.NET 8 в рамках базовой библиотеки классов (BCL). В рамках dotnet/aspnetcore#45 300 от @gfoidl, dotnet/aspnetcore#47 459, dotnet/aspnetcore#49 114 и dotnet/aspnetcore#49 117 используется новый тип SearchValues, благодаря которому во всех этих путях кода можно применять оптимизированные реализации поиска, когда мы ищем конкретные значения. В разделе SearchValues из этой статьи о производительности в.NET 8 подробнее разобраны различные алгоритмы поиска и объяснено, почему этот тип так крут! В dotnet/aspnetcore#46 098 используется новый метод MemoryExtensions.Split(ReadOnlySpan<char> source, Span<Range> destination, char separator). С его помощью определённые экземпляры string.Split(...) можно заменить версиями, не требующими выделения памяти. Так экономится выделение string[], а также выделение отдельных экземпляров string, входящих в состав string[]. Подробнее об этом новом API рассказано в статье о производительности в.NET 8, а именно в разделе о диапазонах (span). Ещё один новый тип, появившийся в этой версии — FrozenDictionary. С его помощью можно сконструировать словарь, который оптимизирован под операции чтения — правда, именно поэтому конструируется немного медленно. В dotnet/aspnetcore#49 714 маршрутизация переориентируется с работы с Dictionary на работу с FrozenDictionary. Этот словарь применяется при маршрутизации http‑запроса на корректную конечную точку, а это происходит практически при каждом запросе в приложении. В следующих таблицах показано, сколько стоит создать словарь, а сколько — замороженный словарь, а затем, сколько стоит использовать словарь и замороженный словарь соответственно. Как видите, создание FrozenDictionary может проходить в 13 раз медленнее, чем создание обычного словаря, но всё равно эти операции занимают считанные микросекунды (1/1000-я миллисекунды). При этом FrozenDictionary для конкретного приложения конструируется всего один раз. Всем нравится наблюдать, что производительность в пересчёте на одну операцию при использовании FrozenDictionary в 2,5x-3,5 раз выше, чем при работе с Dictionary! Метод Число маршрутов Среднее Ошибка Стд.откл Коэф. Коэф./Стд. откл 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. Так издержки на компиляцию регулярных выражений переносятся на время сборки. Сам код регулярных выражений при этом также ускоряется в силу тех оптимизаций, которыми пользуется генератор исходного кода, но не пользуется внутрипроцессный генератор регулярных выражений. Приведём упрощённый пример, на котором можно сравнить использование сгенерированных и скомпилированных регулярных выражений. Метод Среднее Ошибка Стд.откл Коэф. 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 — исключительно полезный класс, при помощи которого можно собрать строку, когда вы либо не можете предвычислить размер создаваемой строки, либо ищете лёгкий способ собрать строку, но обойтись без тех осложнений, что возникают при работе с 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[] и преобразуется в строку в шестнадцатеричном формате — так, чтобы в таком виде её можно было отправить как строку запроса.  Метод Среднее Поколение 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, пожиная при этом серьёзный выигрыш в производительности и налаживая ситуацию с эксплуатационными издержками.