Настройка бенчмарков
Как и в прошлые годы, материал содержит множество микробенчмарков, демонстрирующих отдельные улучшения. Почти все они используют BenchmarkDotNet, и каждый написан так, чтобы его можно было самостоятельно проверить.
Для начала убедитесь, что установлены .NET 10 и .NET 11 (в большинстве бенчмарков сравнивается один и тот же код на обеих версиях), и создайте новый консольный проект в свежей директории benchmarks:
dotnet new console -o benchmarks
cd benchmarks
Замените содержимое файла benchmarks.csproj на следующее, которое компилирует под обе версии, позволяя BenchmarkDotNet тестировать каждую:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net11.0;net10.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
<PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
</ItemGroup>
</Project>
Для каждого бенчмарка скопируйте его полный текст в Program.cs и запустите. Каждый бенчмарк содержит комментарий с точной командой для запуска. В большинстве случаев это:
dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
который компилирует в Release и запускает бенчмарк на обеих версиях .NET 10 и .NET 11, выдавая сравнение. Другая распространённая форма — для бенчмарков, сравнивающих два подхода на одном runtime (а не одинаковый код на двух версиях):
dotnet run -c Release -f net11.0 --filter "*"
Обычное предупреждение: это микробенчмарки, многие измеряют операции настолько краткие, что моргание не заметит. Результаты зависят от железа, ОС, конфигурации runtime, того, что ещё делает машина в данный момент, и положения Меркурия.
Каждая строка управляемого кода в итоге попадает в JIT-компилятор, так что начнём с него.
JIT
Из всех мест для оптимизации производительности .NET немногие имеют такой широкий охват, как just-in-time (JIT) компилятор. C#, F# и Visual Basic обычно сначала компилируются в промежуточный язык (IL), а JIT в итоге преобразует этот IL в нативные инструкции, которые выполняет процессор. Улучшение JIT может поэтому помочь коду приложения и библиотек везде, где встречается оптимизированный паттерн, часто без изменения исходного кода или переcompилирования приложения. Даже удаление одной инструкции или доказательство того, что одна проверка ненужна, даёт результат, когда код находится на очень «горячем» пути.
Деабстракция
Как разработчики мы любим наши абстракции. Они позволяют писать чистый, переиспользуемый, объектно-ориентированный код, но мы не хотим платить за каждую абстракцию во время выполнения. Runtime часто может отменить абстракцию, когда доказано, что побочные эффекты не наблюдаемы. Он может посмотреть на виртуальный вызов и определить, какой конкретный метод будет вызван, посмотреть на выделение объекта в куче и распознать, что объект никогда не покидает текущий стек-фрейм, или посмотреть на приведение интерфейса и переиспользовать информацию о типе, уже установленную ранее в методе. Этот процесс называется «деабстракция». .NET совершенствуется в этой области на протяжении многих лет, и это продолжается в .NET 11.
Каждый раз, когда вы пишете interface в C#, вы создаёте контракт — обещание, что любой тип, реализующий этот интерфейс, может быть подставлен вместо любого другого. Такая гибкость невероятно ценна, потому что, например, именно она позволяет нам писать IEnumerable<T> и использовать его одинаково хорошо с массивами, списками, другими коллекциями, LINQ, пользовательскими итераторами и так далее. Но CPU ничего не знает об этих контрактах; он просто знает, как выполнять инструкции. Превращение «вызови метод, на который указывает эта ссылка интерфейса» в реальные машинные инструкции требует специального механизма. Рассмотрим этот пример:
// dotnet run -c Release -f net11.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat();
[Benchmark]
public int Speak() => _animal.Speak();
public abstract class Animal
{
public abstract int Speak();
}
private sealed class Dog : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 1;
}
private sealed class Cat : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 2;
}
}
Во время компиляции при прочих равных JIT не знает, является ли _animal типом Dog или Cat. Он генерирует код, который загружает «указатель на таблицу методов» объекта (дескриптор типа объекта), иногда называемый «vtable pointer», хранящийся в начале каждого объекта .NET, индексирует в таблицу методов на известной позиции для Speak и вызывает найденный там указатель функции:
; x64
mov rcx, [rcx+8] ; load _animal
mov rax, [rcx] ; load method table
mov rax, [rax+40] ; load vtable chunk
call qword ptr [rax+20]
Для одного вызова Speak мы платим три зависимых разыменования памяти и косвенный вызов, потому что процессор точно не знает, куда идёт вызов (он может угадать или «спекулятивно выполнить», но должен быть готов к тому, что он ошибся), а поскольку целевой адрес вызова косвенный, JIT не может встроить вызываемый метод. Неважно, что делает Speak — его код не может быть свёрнут в вызывающий метод.
Это проблема производительности. Эти разыменования имеют издержки, но бóльшая цена — потеря возможности встраивания. Встраивание не только экономит расходы вызова функции, но, что более важно, открывает код вызываемого метода для тех же оптимизаций, что и вызывающий, таких как распространение констант, устранение мёртвого кода, устранение проверок границ, дальнейшая девиртуализация и т. д. Это означает, что серия малых виртуальных вызовов, которые каждый в отдельности выглядят безобидно, могут при девиртуализации и встраивании свернуться в несколько инструкций, которые будут неузнаваемы и намного дешевле в сравнении с исходным исходным кодом. Без встраивания каждый вызываемый метод — непрозрачный чёрный ящик; с ним JIT может видеть сквозь слои.
Мы как разработчики .NET постоянно полагаемся на сложные эвристики JIT для встраивания, которые взвешивают размер IL вызываемого метода, точную работу, которую он выполняет, частоту вызовов метода, ожидаемую пользу от постоянных аргументов и дюжины других факторов. Для виртуальных вызовов JIT должен знать, каким будет фактический целевой адрес вызова; он должен «девиртуализировать». В некоторых случаях он может определить это статически, где имеет знание точного типа. Например, если JIT может доказать, что animal всегда Dog, либо потому что он был только что выделен с помощью new Dog():
Animal animal = GetSomeAnimal();
animal.Speak();
...
static Animal GetSomeAnimal() => new Dog(); // inlineable
либо потому что тип переменной — запечатанный класс:
Dog animal = GetSomeAnimal();
animal.Speak();
...
sealed class Dog { ... } // невозможно чтобы `animal` был чем-то другим, чем `Dog`
либо с NativeAOT и компиляцией всей программы, если оно видит, что Animal абстрактен и единственный тип во всём приложении, который наследуется от Animal, это Dog:
Animal animal = GetSomeAnimal();
animal.Speak();
...
abstract class Animal { ... }
class Dog : Animal { ... } // нет других производных типов
либо другая такая валидация, он может эмитировать вызов Dog.Speak() напрямую, и встраиватель может попытаться встроить.
Но для других случаев, где статический анализ не может это доказать, JIT обращается к оптимизации, управляемой профилем (PGO). PGO звучит причудливо, но концептуально просто. С «tiered compilation», когда метод впервые вызывается, он может быть скомпилирован «just in time» с минимум оптимизаций (это называется Tier 0). JIT может включить в эту компиляцию дополнительные зонды (думайте «printf debugging»), которые позволяют отслеживать много интересной информации о природе кода, записывая, что на самом деле происходит при его выполнении: какие ветки берутся, какие конкретные типы появляются на сайтах виртуальных вызовов или попыток приведения типов, и так далее. Если метод вызывается достаточно или цикл выполняется достаточно раз, runtime может попросить JIT произвести новую оптимизированную версию (именуемую Tier 1). Эта компиляция может затем учесть все полученные знания из профилирования.
JIT, конечно, всё ещё должен генерировать код, который всегда корректен. Даже если динамический профиль говорит, что animal был Dog 100% времени, это не гарантирует, что animal всегда будет Dog в будущем; может быть, первые 1000 вызовов передали Dog, но вызов 1001 передаст Dolphin. Как JIT может включить это обучение тогда? Путём эмитирования проверки во время выполнения. Путь для Dog может получить прямой вызов, который затем может быть встраиваемым, а другой путь сохраняет исходный виртуальный вызов как fallback. Скорость приходит из создания обычного случая крошечным, а корректность из оставления необычного случая нетронутым.
// Примерно то, что эмитирует JIT
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // девиртуализировано, встраиваемо
}
else
{
animal.Speak(); // исходный виртуальный вызов, надеемся редкий
}
Этот паттерн «угадай и проверь», называемый «охраняемой девиртуализацией» (GDV), учитывает много из самых больших выигрышей пропускной способности в реальных рабочих нагрузках. Это применимо не только к виртуальной диспетчеризации, но и к диспетчеризации интерфейсов, которая также оказывается немного дороже виртуальной диспетчеризации, потому что тип может реализовать любое количество интерфейсов, и это означает, что слоты интерфейсов просто не отображаются на фиксированные позиции vtable.
Деабстракция может также сделать создание объектов более эффективным, когда она раскрывает, какой вид объекта задействован. В общем случае объекты в .NET выделяются в управляемой GC куче, отслеживаются сборщиком мусора (GC) и собираются, когда на них нет больше ссылок. Выделение кучи обычно быстро, часто это просто смещение указателя. Однако, когда нет достаточно места для смещения указателя, это может стать намного дороже, включая необходимость выполнить сборку мусора. Каждый выделяемый объект также эффективно несёт амортизированную стоимость всех сборок, так как каждый выделяемый объект в итоге нужно очистить.
«Анализ утечек» — это техника компилятора, которая позволяет спросить, утекает ли этот объект из текущего метода. Если ссылка на объект на новоотчисленный объект доказуемо не утекает, то JIT может более эффективно его выделить. Ему не нужно хранить его на GC куче, потому что ничто не может ссылаться на этот объект, так что вместо этого он может выделить объект на стеке, делая как выделение, так и очистку практически бесплатными. Выделение на стеке даже быстрее, чем выделение на куче с просто смещением указателя; это просто декремент указателя стека, который обычно уже находится в регистре. И что более важно, это означает нулевой GC impact, потому что стек-фрейм освобождается атомарно при возврате функции.
JIT постепенно расширяет анализ утечек на протяжении нескольких релизов .NET, причём .NET 9 и 10 видели значительные инвестиции в выделение делегатов и замыканий на стеке, временные значения Nullable<T> и малые вспомогательные объекты. Ключевая тема состоит в том, что каждый ложноположительный результат утечки, каждый раз, когда JIT неправильно заключает, что объект может утечь, когда на самом деле не утечёт, представляет выделение в куче, которое могло быть избежано, и мы хотим постепенно уменьшать этот список ложноположительных результатов. В .NET 11 JIT сокращает этот список несколькими способами.
Начнём с упаковки nullable. Рассмотрим этот бенчмарк:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int? _nullableNull;
private int? _nullableValue = 42;
[Benchmark]
public object? BoxNullableNull() => (object?)_nullableNull;
[Benchmark]
public object? BoxNullableValue() => (object?)_nullableValue;
[Benchmark]
public string? FormatNullableInt() => Format(_nullableValue);
private static string? Format<T>(T value)
{
if (value is IFormattable formattable)
return formattable.ToString(null, null);
return null;
}
}
| Метод | Runtime | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|
| BoxNullableNull | .NET 10.0 | 2.095 ns | 1.00 | – | – |
| BoxNullableNull | .NET 11.0 | 1.764 ns | 0.84 | – | – |
| BoxNullableValue | .NET 10.0 | 9.213 ns | 1.00 | 24 B | 1.00 |
| BoxNullableValue | .NET 11.0 | 4.126 ns | 0.45 | 24 B | 1.00 |
| FormatNullableInt | .NET 10.0 | 9.583 ns | 1.00 | 24 B | 1.00 |
| FormatNullableInt | .NET 11.0 | 1.987 ns | 0.21 | – | 0 |
dotnet/runtime#122167 разворачивает упаковку nullable внутри JIT, открывая временный box для анализа утечек; ранее helper runtime скрывал это. Для input null нет выделения на обеих версиях, потому что ничего не упаковывается. И на обеих версиях BoxNullableValue возвращает упакованный объект, что означает объект утекает, так что 24-байтовое выделение остаётся. Однако для FormatNullableInt, JIT в .NET 11 теперь может видеть, что временный 24-байтовый box не утекает и полностью исключает это выделение в куче.
Анализ утечек улучшился дальше для перечислителей благодаря механизму, называемому Conditional Escape Analysis (CEA). Поддержка CEA была введена в .NET 10, но .NET 11 расширяет набор паттернов, которые этот анализ может безопасно распознавать. Существующий анализ утечек спрашивает, может ли ссылка, созданная выделением, потечь куда-то, где JIT больше не может её отслеживать, например неизвестный вызов. Если может, объект должен оставаться на куче. Этот анализ обязательно консервативен и в основном flow-insensitive: если объект может быть передан вызову интерфейса на любом пути, он не пытается доказать, что путь, содержащий этот вызов, взаимоисключающ с путём, содержащим выделение.
К сожалению, это ровно то, что GDV производит, когда оптимизирует foreach над IEnumerable<T>. Как отмечалось ранее, GDV превращает вызов интерфейса в проверку типа с двумя ветками: быструю ветку для вероятного типа коллекции и fallback ветку, содержащую исходный вызов интерфейса. Девиртуализация и встраивание вдоль быстрой ветки часто раскроют выделение перечислителя для типа коллекции, в то время как более поздние защиты перечислителя сохраняют fallback вызовы, такие как IEnumerator<T>.MoveNext. Существующий анализ видит эти вызовы и заключает, что локально выделяемый перечислитель может утечь. CEA вместо этого записывает отношение между выделением на быстром пути и локальным перечислителем, проверяемым более поздними guards. Если каждая видимая утечка происходит только за неудачной проверкой типа, JIT может клонировать регион в горячую версию, где известно, что эти проверки успешны. В этом клоне объект не может достичь fallback вызовов, так что он может быть выделен на стеке и часто продвинут в отдельные скалярные локали. Исходный регион остаётся как общий медленный путь.
Один случай, который .NET 10 не обрабатывал, хотя, это была реализация GetEnumerator(), которая возвращает результат другого вызова GetEnumerator(). Выражение коллекции, преобразованное в IEnumerable<int>, например, использует компилятором-сгенерированную wrapper для массива, только для чтения, с точно такой структурой: GetEnumerator() wrapper'а делегирует GetEnumerator() базового массива. С dotnet/runtime#122946, JIT в .NET 11 обрабатывает эту «цепочку»:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly IEnumerable<int> s_readOnlyStatic = [1, 2, 3, 4, 5];
private readonly IEnumerable<int> _readOnlyInstance = [1, 2, 3, 4, 5];
[Benchmark]
public int ReadOnlyStatic()
{
int sum = 0;
foreach (int item in s_readOnlyStatic) sum += item;
return sum;
}
[Benchmark]
public int ReadOnlyInstance()
{
int sum = 0;
foreach (int item in _readOnlyInstance) sum += item;
return sum;
}
}
| Метод | Runtime | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|
| ReadOnlyStatic | .NET 10.0 | 2.665 ns | 1.00 | – | – |
| ReadOnlyStatic | .NET 11.0 | 2.666 ns | 1.00 | – | – |
| ReadOnlyInstance | .NET 10.0 | 13.874 ns | 1.00 | 32 B | 1.00 |
| ReadOnlyInstance | .NET 11.0 | 2.674 ns | 0.19 | – | 0 |
ReadOnlyStatic, чьё static readonly поле JIT может эффективно рассматривать как константу, уже был оптимизирован в .NET 10. В .NET 11 случай field instance также теряет своё 32-байтовое выделение перечислителя и сходится на той же пропускной способности.
dotnet/runtime#121918 от @MichalPetryka исправляет другой способ, которым адрес мог ненужно сделать объект похожим на утекающий. IL префикс constrained. позволяет одной последовательности обобщённого callvirt работать как для типов значений, так и для типов ссылок: он может избежать упаковки типа значения, в то время как для типа ссылки он разыменовывает получателя и выполняет нормальную виртуальную диспетчеризацию. ObjectEqualityComparer<T>.Equals, используемый в следующем бенчмарке EqualityComparer<T>.Default, содержит такой вызов для value.Equals(other). Получатель был представлен как косвенное чтение через адрес локали. Просто принятие этого адреса отметило локаль как открытую, предотвращая новоотчисленный Value от рассмотрения для выделения на стеке. Получатель теперь представлен как прямая загрузка значения вместо этого, и 24-байтовое выделение в куче исчезает.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Value s_other = new(42);
[Benchmark]
public bool Equals() => EqualityComparer<Value>.Default.Equals(new Value(42), s_other);
private sealed class Value(int value)
{
private readonly int _value = value;
public override bool Equals(object? obj) => obj is Value other && _value == other._value;
public override int GetHashCode() => _value;
}
}
| Метод | Runtime | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|
| Equals | .NET 10.0 | 3.874 ns | 1.00 | 24 B | 1.00 |
| Equals | .NET 11.0 | 1.786 ns | 0.46 | – | 0 |
Пока CEA может переместить не-утекающий объект с GC кучи, иногда JIT может идти дальше и доказать, что выделение в принципе не должно существовать. Обобщённый код предоставляет распространённый источник таких возможностей через упаковку. Например, метод ArgumentNullException.ThrowIfNull принимает object value. Это означает, когда у вас есть метод вроде этого:
static void Test<T>(T value)
{
ArgumentNullException.ThrowIfNull(value);
...
}
когда T ограничен ненулевым структурным типом, упаковка несётся, чтобы передать value как object. ThrowIfNull здесь это no-op, если value не-null (так как метод просто if (value is null) Throw();), и предыдущие релизы успешно оптимизировали эту упаковку в оптимизированном коде. Однако в Tier 0 эта оптимизация не применялась, и ThrowIfNull заканчивался выделением. Хотя это не повлияло бы на пропускную способность в стабильном состоянии, это привело бы к раздражающему шуму в профилировании, так же как дополнительные издержки во время запуска, где такое использование ещё не было продвинуто из Tier 0. В .NET 11, dotnet/runtime#129392 добавляет поддержку этого в Tier 0 также.
На стороне виртуальной диспетчеризации, множество PR'ов вносят вклад в улучшение обобщённых виртуальных методов (GVMs). dotnet/runtime#120866 от @hez2010 прекращает спешный пролив целевых адресов вызова ldvirtftn в временную, и позволяет разрешению обобщённого виртуального целевого адреса двигаться вперёд настройки аргументов, когда это правомерно. dotnet/runtime#122023 от @hez2010 затем разрешает JIT девиртуализировать non-shared GVMs, переносящий обобщённый контекст, нужный чтобы превратить косвенную диспетчеризацию в прямую, потенциально встраиваемую, ветку вызова. И dotnet/runtime#128702 от @hez2010 расширяет эту поддержку на shared GVMs и default interface implementations, которые требуют instantiating stub. Эти оптимизации могут увеличить общий размер кода, когда вновь прямые вызовы встраиваются, но это обычно желаемый трейд-офф: больше реальной работы становится видимым оптимизатору. Рассмотрим следующий бенчмарк:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Benchmark]
public int NonShared() => ((IProcessor)new Processor()).SizeOf(42);
[Benchmark]
public int Shared() => ((IProcessor)new Processor()).SizeOf("hello");
private interface IProcessor
{
int SizeOf<T>(T value);
}
private sealed class Processor : IProcessor
{
public int SizeOf<T>(T value) => Unsafe.SizeOf<T>();
}
}
Приведение свежевыделяемого Processor к IProcessor несёт вызов интерфейса обобщённого виртуального в IL, но JIT теперь способен видеть точный тип получателя, даже в общем случае string, такой что .NET 11 девиртуализирует и встраивает оба вызова. Это в свою очередь раскрывает Unsafe.SizeOf<T>() как константу и доказывает, что недолговечный Processor не нужно выделять вообще.
| Метод | Runtime | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|
| NonShared | .NET 10.0 | 6.678 ns | 1.00 | 24 B | 1.00 |
| NonShared | .NET 11.0 | 1.764 ns | 0.26 | – | 0 |
| Shared | .NET 10.0 | 7.166 ns | 1.00 | 24 B | 1.00 |
| Shared | .NET 11.0 | 1.764 ns | 0.25 | – | 0 |
Опираясь на этого, dotnet/runtime#123183 от @hez2010 разрешает ReadyToRun компиляции разрешать и девиртуализировать больше non-shared generic virtual вызовов, которые иначе остались бы косвенными, и dotnet/runtime#130202 от @hez2010 расширяет эту поддержку на NativeAOT. NativeAOT представляет некоторые обобщённые виртуальные целевые адреса как «fat pointers» (указатели, которые больше чем просто адрес, обычно адрес и связанные метаданные, и которые в этом случае переносят как адрес кода, так и обобщённый контекст); отсрочивая это преобразование до после того, как точное-тип девиртуализация имела шанс запустить, JIT может повернуть сайт вызова интерфейса с одним известным целевым адресом обобщённого виртуального в прямой вызов, который может затем быть встроен.
Информация о типе также должна сохраняться через преобразования, которые JIT выполняет внутренне. Если JIT пролагает ссылку выражения в временную, пока переструктурирует дерево, потеря информации о точном классе выражения может повернуть вызов, который был девиртуализируемым, обратно в непрозрачный виртуальный вызов. Это то, что происходит здесь в .NET 10: Value упаковывается и SetValue вызывается через IValue. dotnet/runtime#128485 от @hez2010 сохраняет дескриптор класса и точность на временной. С этой информацией всё ещё доступной, .NET 11 девиртуализирует и встраивает вызов, исключая box и его 24-байтовое выделение.
Отдельно, dotnet/runtime#127433 смягчает эвристику бюджета встраивателя для вызываемых на типах [Intrinsic], таких как Span и Vector. Эти типы намеренно открывают много малых, компонуемых методов, которые служат шлюзами к операциям, распознаваемым JIT'ом. Если wrapper остаётся как вызов, вызывающий платит издержки вызова и оптимизации вокруг него видят непрозрачную границу. Если встраивается, importer может заменить его тело на intrinsic узел и оптимизировать этот узел вместе с окружающей индексацией, проверками границ и векторными операциями. Предоставление таким wrapper'ам более благоприятного бюджета поэтому держит больше их встраиваемыми и открывает больше реальной операции для остальной части оптимизатора.
Один из основных абстрактных механизмов в .NET это делегаты: они позволяют передавать объекты, представляющие функции для вызова, переносящие с собой ассоциированное требуемое состояние. Деабстракция разрешает избегать платить издержки, ассоциированные с делегатами в некоторых случаях. Для остальных мы все ещё хотим те делегаты быть максимально дешевыми. dotnet/runtime#99200 от @MichalPetryka упрощает представление делегата CoreCLR, удаляя одно размер-указателя поле из каждого объекта делегата. Это экономит 8 байт на делегат в 64-битном процессе CoreCLR. dotnet/runtime#129304 от @MichalPetryka улучшает раскладку делегата Native AOT отдельно, переупорядочивая его существующие четыре поля так, чтобы связанные значения были смежными. Обновлённые раскладки также дают операциям равенства и хеш-кода более прямой доступ к идентификации метода, которая им нужна.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Target s_target = new();
private static readonly Func<int> s_first = s_target.GetValue;
private static readonly Func<int> s_second = s_target.GetValue;
[Benchmark]
public Func<int> ClosedInstance() => s_target.GetValue;
[Benchmark]
public bool DelegateEquals() => s_first.Equals(s_second);
[Benchmark]
public int DelegateGetHashCode() => s_first.GetHashCode();
private sealed class Target
{
public int GetValue() => 42;
}
}
| Метод | Runtime | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|
| ClosedInstance | .NET 10.0 | 7.395 ns | 1.00 | 64 B | 1.00 |
| ClosedInstance | .NET 11.0 | 6.844 ns | 0.93 | 56 B | 0.88 |
| DelegateEquals | .NET 10.0 | 3.254 ns | 1.00 | – | – |
| DelegateEquals | .NET 11.0 | 2.215 ns | 0.68 | – | – |
| DelegateGetHashCode | .NET 10.0 | 5.623 ns | 1.00 | – | – |
| DelegateGetHashCode | .NET 11.0 | 3.741 ns | 0.67 | – | – |
dotnet/runtime#129410 от @MichalPetryka следует за раскладкой CoreCLR, размещая целевой объект и указатель метода рядом. Они обычно потребляются вместе во время вызова, и смежность разрешает парные загрузки на архитектурах, таких как Arm64.
Runtime Async
Более десяти лет async и await позволяли нам писать асинхронный код, который выглядит примечательно похоже на синхронный: мы можем положить try/catch вокруг await, использовать локали по обе стороны от него, возвращать значение и в общем рассуждать о методе в порядке исходного кода. Когда выполнение достигает await чего-то, что ещё не полно, однако, метод не может просто оставить его текущий стек-фрейм на месте и ждать операции завершения. Нить должна быть освобождена, чтобы делать другую работу, пока работа после await, включающая какое-либо локальное состояние, которое ему понадобится позже, должна выжить где-то. В C#, компилятор традиционно был ответственен за преобразование метода в представление, которое разрешает это продолжение.
Я вошёл в историю и механику этого преобразования в Как async/await на самом деле работает. Очень короткая версия состоит в том, что компилятор традиционно заменяет async метод на малый метод входа и сгенерированный state machine, чей MoveNext метод содержит преобразованный код пользователя. Параметры, локали, которые должны пережить неполный await, пролитые значения выражений, awaiters, текущий номер состояния и builder метода все становятся полями на объекте выделённом в куче. Сгенерированный метод MoveNext запускает код пользователя до awaiter сообщает, что он ещё не полон. Он сохраняет достаточно информации, чтобы знать где и с какими значениями возобновить, регистрирует MoveNext как продолжение и возвращает. Когда операция завершается, MoveNext вызывается снова, прыгает в правильное место на основе сохранённого номера состояния (думайте goto и метка), извлекает результат из производящего значение awaiter и продолжает. Если каждый awaiter уже полный, MoveNext может запустить весь путь синхронно. Когда метод завершается или выбрасывает, builder публикует результат, отмену или исключение через возвращённый Task, Task<T>, ValueTask или ValueTask<T> (или, в редком случае, пользовательский тип похожий на task).
Например, рассмотрим эту крошечную метод:
static async Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return bytesRead;
}
Пока код, который получается для этого, меняется со временем и отличается между debug и release сборками, понижение на C# компилятором имело выглядеть примерно так:
[AsyncStateMachine(typeof(<ReadLengthAsync>d__0))]
static Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
<ReadLengthAsync>d__0 stateMachine = default;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.stream = stream;
stateMachine.cancellationToken = cancellationToken;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
struct <ReadLengthAsync>d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
public Stream stream;
public CancellationToken cancellationToken;
private TaskAwaiter<int> awaiter;
public void MoveNext()
{
int result;
try
{
TaskAwaiter<int> localAwaiter;
if (state != 0)
{
byte[] buffer = new byte[4096];
localAwaiter = stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).GetAwaiter();
if (!localAwaiter.IsCompleted)
{
state = 0;
awaiter = localAwaiter;
builder.AwaitUnsafeOnCompleted(ref localAwaiter, ref this);
return;
}
}
else
{
localAwaiter = awaiter;
awaiter = default;
state = -1;
}
result = localAwaiter.GetResult();
}
catch (Exception e)
{
state = -2;
builder.SetException(e);
return;
}
state = -2;
builder.SetResult(result);
}
}
Это довольно много сгенерированного кода для трёх строк C#. Компилятор должен принять решения до того, как программа запустится о раскладке state-machine, какие значения могут нужно выжить, сколько awaiter полей требуется и как все точки приостановки подогнут в один MoveNext диспатч. Runtime и JIT оптимизировали получающийся паттерн тяжело на протяжении многих лет, включая комбинирование task, state machine, продолжение и ExecutionContext в одно выделение, но к тому времени, как JIT видит IL, преобразование уже произошло, оставляя его с очень сложной системой, чтобы попытаться оптимизировать.
.NET 11 вводит новый путь разделения этой ответственности, переимплементация инфраструктуры async/await, именуемая «runtime async». Вместо компилятора C# ответственным за преобразование, это JIT. Компилятор C# эмитирует намного меньший контракт IL, осведомлённый о приостановке, для каждого подходящего async метода и отмечает метод как async в метаданных. Runtime и JIT тогда делают работу, которая зависит от знаний runtime: создание внешне видимого Task или ValueTask, распознавание прямых async вызовов, решение, какие значения фактически живы в каждой точке приостановки, раскладка объектов продолжения и генерирование потока управления, который приостанавливает и возобновляет метод. Эффективно преобразование переходит от C# к runtime, где больше информации доступно для оптимизации этого.
Модель программирования не изменилась. Это всё ещё C# async/await; await всё ещё подчиняется паттерну awaiter, исключения и отмена всё ещё поверхность через возвращённый task-подобный объект, ConfigureAwait всё ещё имеет его обычное значение, синхронное завершение всё ещё синхронное завершение и так далее. Явная цель для функции была 100% совместимостью поведения: является ли async метод понижен языком компилятора или runtime это деталь реализации и любое наблюдаемое семантическое различие это баг.
В .NET 11, код приложения оптирует in с переключателем функции компилятора:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
Заметьте, что нет новой C# синтаксис вовлечён, так что LangVersion=preview не требуется, ни EnablePreviewFeatures. Хотя это opt-in на уровне приложения, большинство in-box shared framework уже построено этим способом для .NET 11. Цель производительности async/await для .NET 11 это паритет с .NET 10 и в общем runtime async уже столь же хорошо как или лучше, чем более старая реализация на множестве важных путей. Это ещё не полностью оптимизировано, хотя и есть известные случаи, где это всё ещё производит менее эффективный код. Я бы поощрил вас экспериментировать в .NET 11 с оптированием in ваших приложений и сервисов; просто удостоверьтесь, что измеряете. Моя надежда это то, что это будет on по умолчанию начиная с .NET 12.
Переместить преобразование от компилятора C# к runtime имеет добавленное благо уменьшения размера бинарного файла. Как отмечено, традиционное понижение эмитирует метод входа, сгенерированный тип state-machine, поля для захваченного состояния и тело MoveNext для каждого async метода. Runtime async оставляет намного меньший body метода для runtime трансформации. Следующее крошечное приложение содержит десять Task<int>-возвращающих async методов, каждый awaiting следующий, и компилирует один и тот же исходный один раз с понижением компилятора и один раз с runtime async:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<AssemblyName>SizeProbe</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Features Condition="'$(RuntimeAsync)' == 'true'">$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
// dotnet build -c Release -p:RuntimeAsync=false -o classic --no-incremental; dotnet build -c Release -p:RuntimeAsync=true -o runtime --no-incremental; Get-Item .\classic\SizeProbe.dll, .\runtime\SizeProbe.dll | Select-Object Directory, Length
Console.WriteLine(await Benchmarks.Layer0());
public class Benchmarks
{
public static async Task<int> Layer0() => await Layer1();
private static async Task<int> Layer1() => await Layer2();
private static async Task<int> Layer2() => await Layer3();
private static async Task<int> Layer3() => await Layer4();
private static async Task<int> Layer4() => await Layer5();
private static async Task<int> Layer5() => await Layer6();
private static async Task<int> Layer6() => await Layer7();
private static async Task<int> Layer7() => await Layer8();
private static async Task<int> Layer8() => await Layer9();
private static async Task<int> Layer9()
{
await Task.Yield();
return 42;
}
}
| Понижение | SizeProbe.dll | Ratio |
|---|---|---|
| Компилятор | 10,752 байт | 1.00 |
| Runtime async | 5,632 байт | 0.52 |
Для метода, такого как:
static async Task<int> CallerAsync() => await CalleeAsync();
с runtime async включённым, компилятор C# генерирует IL вроде следующего:
; MSIL
.method private hidebysig static
class System.Threading.Tasks.Task`1<int32> CallerAsync() cil managed async
{
call class System.Threading.Tasks.Task`1<int32> CalleeAsync()
call int32 System.Runtime.CompilerServices.AsyncHelpers::Await<int32>(
class System.Threading.Tasks.Task`1<int32>)
ret
}
Нет генерируемого типа <CallerAsync>d__0, нет IAsyncStateMachine, нет MoveNext, нет AsyncTaskMethodBuilder<int> и нет AsyncStateMachineAttribute. Ранее, async на методе C# испарялась во время компиляции. Теперь метод имеет новый MethodImpl async бит, представленный в IL сборке синтаксиса этим async модификатором и body вызывает helpers в System.Runtime.CompilerServices.AsyncHelpers.
С первого взгляда ret выглядит невозможным, потому что объявленная сигнатура возвращает Task<int> пока значение на IL стеке оценки это int. Это ясно не нормальная вызывающая конвенция. VM может дать Task-возвращающему методу две связанные идентичности или MethodDescs, где одна имеет нормальную сигнатуру, что остаток управляемого кода видит, Task<int> CallerAsync(). Другая это AsyncCall вариант, который эффективно возвращает int и имеет неявный канал для продолжения. Обе ссылаются на один логический метод и маркер метаданных, но они имеют разные вызывающие конвенции и разные работы. Если обычный управляемый код вызывает CallerAsync, VM-генерируемый outer thunk сохраняет публичный контракт и возвращает Task<int>. Если другой runtime async метод прямо awaits это, JIT может вместо этого вызвать AsyncCall вариант и получить результат прямо, когда вызов завершается синхронно или продолжение, когда приостанавливается. Другими словами, он может вернуть T прямо и избежать выделения Task<T>.
Это паринг работает в обе стороны. Для метода, скомпилированного с runtime async, AsyncCall вариант владеет сгенерированным (вновь компактным) IL, пока публичная точка входа Task-возвращающая это adapter thunk; для традиционно скомпилированного метода, публичный метод владеет его обычным IL, пока VM может создать AsyncCall адаптер вокруг этого. Это означает runtime async код остаётся способным awaiting существующих библиотек и кода, скомпилированного старыми компиляторами, критическая способность для нашей цели 100% совместимости. Самые большие выигрыши естественно появляются, так как больше async цепочки вызовов скомпилировано с runtime async.
Это где JIT получает возможность, которая просто не существовала, когда каждая граница была уже выражена как task и сгенерированный state machine. Предположим A awaits B, который awaits C:
static async Task<int> A(bool yield) => await B(yield);
static async Task<int> B(bool yield) => await C(yield);
static async Task<int> C(bool yield)
{
if (yield)
await Task.Yield();
return 42;
}
Традиционно, каждый метод имеет его собственный компилятором-сгенерированный state machine и его собственный task-подобный результат. C приостанавливается и в итоге завершает его task, который пробуждает state machine B; B тогда завершает его task, который пробуждает state machine A; и A завершает root task наблюдаемый вызывающим. Было огромное количество работы сделано на протяжении многих лет, чтобы уменьшить издержки этих объектов и переходов.
С runtime async, importer распознаёт смежный паттерн из «вызови Task-возвращающий метод, тогда await эту task». В простом случае это может вызвать AsyncCall вариант вызываемого вместо этого. Когда yield ложно и C завершается синхронно, int потоки обратно через B и A как простое значение и только самая внешняя граница нужна, чтобы повернуть это в Task<int>, обещанный оригинальному вызывающему. Когда yield истинно и C приостанавливается, runtime связывает состояние продолжения для цепочки и в итоге возобновляет это без требования промежуточного Task<int> в каждой прямой слитой границе. Контракт Task не исчез, он просто переместился в место, где Task фактически нужен.
Runtime async не делает каждую асинхронную операцию выделением-свободной, хотя. Вместо этого, это даёт JIT достаточно информации, чтобы избежать материализации некоторых task объектов, которые существовали только, чтобы нести результат от одного async метода прямо в следующий. Если потребитель хранит task в коллекции, вручную прикрепляет продолжение или иначе наблюдает task как объект, этот объект всё ещё нужен. Оптимизация об этом не платить за границы, которые не наблюдаемо границы.
Влияние уже видимо с просто двумя слоями:
// dotnet run -c Release -f net11.0 --filter "*"
// Проект также нужен переключатель функции `runtime-async=on` установленный.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Task<int> s_completed = Task.FromResult(42);
[Benchmark(Baseline = true), BenchmarkCategory("Completed")]
public Task<int> ClassicCompleted() => ClassicCompletedOuter();
[Benchmark, BenchmarkCategory("Completed")]
public Task<int> RuntimeCompleted() => RuntimeCompletedOuter();
[Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
public Task<int> ClassicYielding() => ClassicYieldingOuter();
[Benchmark, BenchmarkCategory("Yielding")]
public Task<int> RuntimeYielding() => RuntimeYieldingOuter();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedOuter() => await ClassicCompletedInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedInner() => await s_completed;
private static async Task<int> RuntimeCompletedOuter() => await RuntimeCompletedInner();
private static async Task<int> RuntimeCompletedInner() => await s_completed;
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingOuter() => await ClassicYieldingInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingInner()
{
await Task.Yield();
return 42;
}
private static async Task<int> RuntimeYieldingOuter() => await RuntimeYieldingInner();
private static async Task<int> RuntimeYieldingInner()
{
await Task.Yield();
return 42;
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
| Метод | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|
| ClassicCompleted | 21.221 ns | 1.00 | 144 B | 1.00 |
| RuntimeCompleted | 6.151 ns | 0.29 | 0 B | 0.00 |
| ClassicYielding | 254.139 ns | 1.00 | 248 B | 1.00 |
| RuntimeYielding | 116.927 ns | 0.46 | 168 B | 0.68 |
Синхронно завершающаяся цепочка более чем в 3 раза быстрее и избегает обоих промежуточных выделений task. Даже после реальной приостановки, та же цепочка двух слоёв занимает менее половины времени и выделяет на 80 байт меньше.
Обработка исключений усиливает разницу. Снова рассмотрим async метод A, вызывающий async метод B, вызывающий async метод C. Преобразование, сгенерированное компилятором C# каждого метода, результируется в блоке try/catch вокруг целого тела метода MoveNext так, чтобы любое необработанное исключение могло быть сохранено в возвращаемый Task. Давайте скажем код в C выбрасывает необработанное исключение. Это тогда поймано этим производимым блоком catch и сохранено в Task, возвращённый B. Awaiter в B тогда извлекает то исключение из object Task и выбрасывает его. Это тогда поймано B's сгенерированным catch и сохранено в его Task. И так далее. Исключение пересекающие десять таких async helpers может быть поэтому выброшено, поймано и сохранено десять раз, даже хотя ни один из методов источника имеет явный handler. Это супер дорого. Но runtime async не нуждается переходить в pass-through фрейм с no handler. На синхронном пути исключение разворачивает через слитые вызовы нормально и после реальной приостановки, один dispatch-loop catch проходит через записи продолжения, у которых нет handler и ошибок видимый root task один раз.
Следующий бенчмарк измеряет обе полностью синхронные выбросы и исключение после одной реальной Task.Yield приостановки. Это использует компилятором-распознаваемый per-метод escape hatch (RuntimeAsyncMethodGeneration) так, что классический и runtime async методы запускают в одном процессе на одном .NET 11 runtime и отличаются только в как компилятор понижает их. (Заметьте, что этот атрибут экспериментален и не публичный API открыт из основных библиотек; как с другими атрибутами, известными компилятору C#, это распознаёт их по имени и сигнатуре.)
// dotnet run -c Release -f net11.0 --filter "*"
// Проект также нужен переключатель функции `runtime-async=on` установленный.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Params(1, 10, 30)]
public int Depth;
[Params(false, true)]
public bool Yield;
[Benchmark(Baseline = true)]
public int Classic() => Invoke(ClassicThrowAsync(Depth));
[Benchmark]
public int Runtime() => Invoke(RuntimeThrowAsync(Depth));
private static int Invoke(Task<int> task)
{
try
{
return task.GetAwaiter().GetResult();
}
catch (InvalidOperationException)
{
return -1;
}
}
[RuntimeAsyncMethodGeneration(false)]
private async Task<int> ClassicThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await ClassicThrowAsync(depth - 1);
}
private async Task<int> RuntimeThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await RuntimeThrowAsync(depth - 1);
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
| Depth | Yield | Метод | Среднее | Ratio | Выделено | Alloc Ratio |
|---|---|---|---|---|---|---|
| 1 | False | Classic | 4.727 μs | 1.00 | 1.6 KB | 1.00 |
| 1 | False | Runtime | 3.558 μs | 0.75 | 1.16 KB | 0.72 |
| 1 | True | Classic | 6.308 μs | 1.00 | 1.68 KB | 1.00 |
| 1 | True | Runtime | 8.211 μs | 1.30 | 1.42 KB | 0.85 |
| 10 | False | Classic | 19.469 μs | 1.00 | 15.13 KB | 1.00 |
| 10 | False | Runtime | 5.885 μs | 0.30 | 2.13 KB | 0.14 |
| 10 | True | Classic | 24.923 μs | 1.00 | 15.63 KB | 1.00 |
| 10 | True | Runtime | 6.122 μs | 0.25 | 2.88 KB | 0.18 |
| 30 | False | Classic | 51.302 μs | 1.00 | 84.2 KB | 1.00 |
| 30 | False | Runtime | 10.721 μs | 0.21 | 5.71 KB | 0.07 |
| 30 | True | Classic | 65.974 μs | 1.00 | 85.53 KB | 1.00 |
| 30 | True | Runtime | 11.254 μs | 0.17 | 7.72 KB | 0.09 |
Runtime async поддерживает Task, Task<T>, ValueTask и ValueTask<T> как типы возврата методов, но на сегодняшний день это не поддерживает async void, async итераторы или произвольные пользовательские task-подобные типы возврата с пользовательскими builders; те продолжают использовать традиционное преобразование компилятора. Для ValueTask<T>, существующие причины использовать тип всё ещё применяются. ValueTask<T> может нести результат прямо, обёртывать Task<T> или ссылаться на IValueTaskSource<T>. Это сделало это полезным для API'ей, где синхронное завершение достаточно обычно, что избежание выделения Task перевешивает большее значение возврата и более ограничительные правила потребления или где асинхронное завершение может иметь его издержки амортизированы через повторно используемый объект поддержки. Runtime async тогда адресирует некоторые сценарии, которые привели бы разработчиков использовать ValueTask<T>. Означает ли это, что все должны прекратить использовать ValueTask<T>? Нет. Выбирание Task против ValueTask остаётся решением дизайна API, основанным на паттернах завершения, аллокационной чувствительности, частоте вызова и как потребители нуждаются использовать результат. Напишите тип возврата, который имеет смысл для API, тогда позвольте компилятору, VM и JIT оптимизировать его столь хорошо, как они могут.
Рабочие нагрузки с многими слоями малых async методов могут получить наибольшую пользу от runtime async, потому что те слои ровно где промежуточные tasks и state machines часто накапливаются. Код shared framework, например, полон этого паттерна: публичный метод валидирует аргументы и awaits приватный helper, который awaits транспортный helper, который awaits операцию операционной системы. Сервисы приложения подобно композируют аутентификацию, повторную попытку, логирование, сериализацию и I/O helpers. Runtime async может сделать разложение на исходном уровне дешевле без просящих разработчика сплющить код в один гигантский метод, чтобы избежать издержек «детали реализации».
Работа, требуемая достичь этот пункт, была обширной. GitHub поиск runtime async отслеживающего метки на 14 сентября 2026 возвращённой 235 pull запросов, далеко слишком много для меня, чтобы перечислить один за одним. Так я не буду пытаться; вы можете просмотреть ту метку в вашем запасном времени. Работа также не только о прямых улучшениях производительности, но также об улучшениях для диагностики и инструментов производительности, которые помогают вам делать лучший использование async в вашем собственном коде. Когда async метод приостанавливает, его физический стек нити разворачивает. Продолжение этого метода может позже запустить на другой нити, чей физический стек начинается в пуле нитей, с методами, которые привели к оригинальному await, нигде найденными. Sampling CPU профилировщик может видеть где процессор тратит время, но без дополнительной информации, это не может надёжно связать те трассировки обратно через логический async цепочку вызовов, делая это трудно ответить вопросам о том, какие async пути вызова на самом деле стоили. Инструменты профилирования как async профилировщик в Visual Studio традиционно восстановили те цепочки из событий, эмитированных инфраструктурой Task, но async-тяжелые приложения могут генерировать огромные объёмы тех очень разговорчивых событий. Получающиеся издержки легко возмущают рабочую нагрузку, измеряемую, делая это всё но непригодным в производстве. dotnet/runtime#127238 добавил новый лёгкий поток async-профилировщика события для .NET 11 и runtime async. Вместо отправления каждого малого перехода через событие систему как его собственное полное событие, runtime пишет компактные записи в per-нить буферы, дельта-кодирование временных меток и указателей инструкций и очистка данных в батчах. Это также кладёт малый идентифицируемый wrapper фрейм в физический стек, когда вызывая продолжение. Профилировщик может использовать тот фрейм как якорь, присоединяющий обычные CPU пробы к логическому async цепочке вызовов, представленной потоком события. В некоторых измерениях, этот новый подход добавил менее 1% издержек и сократил отслеженные данные на порядок. dotnet/runtime#129043 и несколько follow-up PR'ов расширили же подход к компилятором-сгенерированным state machines, используемым существующим async кодом. Таким образом это не полезно только приложениям, которые оптирует в runtime async; инструменты получают одно согласованное представление через обе реализации.
Что должны вы как разработчик делать по-другому с runtime async в картине? В основном ничего. Продолжите писать асинхронный код, как вы хотите это читать и разломайте большую операцию в helpers, когда это делает код яснее. Используйте Task по умолчанию и выбирайте ValueTask, где его API и использование трейд-оффов действительно подходят. И не искажайте исходный код, чтобы удалить чистый await, просто потому что сегодняшняя реализация может выделить промежуточный Task. Стратегия понижения должна «просто работать» как деталь реализации, сохранять поведение и делать существующий исходный получить лучше, как runtime улучшается.
Проверки границ
C# это язык, безопасный по памяти. Доступы к массивам, строкам и spans гарантированы runtime быть в-границах; если вы попытаетесь получить доступ someArray[i], someString[i] или someSpan[i] с индексом менее 0 или больше или равным длине массива/строки/span, вы получите исключение, не молча искажённую память или крах процесса. Runtime гарантирует, что все разрешённые доступы находятся в границах и это означает ему нуждаться быть способным доказать доступ находится в границах. Главный метод JIT имеет для достижения того это путём инъектирования кода, который выполняет проверку границ, как если бы вместо:
int[] array = ...;
int value = array[i];
вы написали:
int[] array = ...;
if ((uint)i >= array.Length) throw new IndexOutOfRangeException();
int value = array[i];
На уровне сборки, проверка границ выглядит примерно так:
; x64
cmp ecx, dword ptr [rax+8] ; сравнить индекс с длиной массива
jae THROW ; неподписанный индекс >= длина
mov edx, dword ptr [rax+rcx*4+16] ; загрузить элемент
JIT мог просто инъектировать такой код на каждый доступ и называть это днём, но такой код добавляет издержки, так что JIT работает, чтобы исключить те проверки и те издержки везде, где это может доказать индекс валидный. Доказание индекса валидный означает JIT нуждаться быть способным видеть из другого доказательства, что это не мог возможно быть вне границ.
Квинтэссенциальный пример того это for цикл над полным содержимым массива или span:
for (int i = 0; i < array.Length; i++)
{
Use(array[i]);
}
JIT распознаёт из этого идиома, что, в теле цикла, i гарантирован быть в диапазоне [0, array.Length) и избегает эмитирования проверки границ для доступа array[i]. JIT имеет долгое время обрабатывал этот частный случай. Другие случаи, не столько. Исключение проверок границ улучшилось в практически каждом релизе .NET; более недавние релизы добавили распространение диапазона для производных выражений (.NET 7 и .NET 8 видели значительные улучшения здесь), SSA-основанное рассуждение (.NET 9) и лучшую обработку Span<T>, чья длина находится в поле, а не заголовке объекта, осложняя отслеживание. Каждый год разработчики, вносящие вклад в JIT, находят новые паттерны, которые пропускались, которые показываются в мире и которые исправимы. .NET 11 улучшает несколько таких паттернов.
Анализ диапазона в JIT отслеживает интервалы для каждой переменной верхнюю границу и нижнюю границу. Например, принятие истинной ветки x < 5 даёт диапазон для x в той ветке верхнюю границу 4, пока принятие истинной ветки x > 2 делает нижнюю границу 3. Что насчёт x != 5? На истинном краю мы знаем x не 5 и если текущий диапазон для x это [5, 10], тогда мы знаем диапазон должен фактически быть [6, 10]… нижняя граница может быть затянута, потому что единственное значение на нижнем конце исключено. Подобно, если диапазон это [0, 5], утверждение x != 5 говорит нам диапазон фактически уже [0, 4]. Или, по крайней мере, это то, что вы надеялись бы оно было. JIT имел этот релевантный комментарий:
// Мы имеем != утверждение, но это не говорит нам много об интервале. Так просто пропустите это.
continue;
В .NET 11, dotnet/runtime#121273 заменяет ту логику с производительным рассуждением. Это проверяет, является ли исключённая константа в любом краю текущего отслеживаемого диапазона, добавляя в новые проницательности, если так. Паттерны списка C#, введённые в C# 11, генерируют ровно такие последовательности сравнения. Например, паттерн name is [] or [':'] or [':', not ':', ..] понижает к чему-то вроде этого:
if (name != null)
{
int num = name.Length;
if (num == 0) return true;
if (num == 1)
{
if (name[0] == ':') return true;
}
else if (name[0] == ':' && name[1] != ':')
{
return true;
}
return false;
}
Анализ диапазона тогда продолжает примерно с этого:
- Мы знаем, что
Array.Lengthникогда не отрицательна, так это имеет диапазон[0, Array.MaxLength]. - На ложном краю
num == 0, мы знаем, чтоnum != 0, так диапазон теперь сужен к[1, Array.MaxLength]. - Подобно, на ложном краю
num == 1, мы знаем, чтоnum != 1, так диапазон теперь сужен к[2, Array.MaxLength]. - Мы тогда получаем доступ
name[0]иname[1], обе из которых гарантированы в границах на основе нижней границы 2, которая была установлена.
Без != constant затягивания, то сужение не произойдёт и проверки границ на шаге 4 не могли быть исключены. К счастью, они теперь могут быть в .NET 11. Рассмотрим этот пример:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private string[] _inputs = ["", ":", ":x", "abc", ":ab", "x", "ab:cd"];
[Benchmark]
public int ClassifyAll()
{
int total = 0;
foreach (string s in _inputs) total += Classify(s);
return total;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Classify(ReadOnlySpan<char> name) =>
name switch
{
[] => 0,
[':'] => 1,
[':', not ':', ..] => 10 + name[0] + name[1],
_ => 3
};
}
В .NET 10, мы можем видеть вызов CORINFO_HELP_RNGCHKFAIL в нижнем конце метода. Это знак-говоритель было по крайней мере одна проверка границ в методе. С .NET 11, то знак удалён.
; Arm64
--- .NET 10
+++ .NET 11
@@ -10,17 +10,15 @@
beq G_M000_IG08
G_M000_IG04:
- ldrh w2, [x0]
- cmp w2, #58
+ ldrh w1, [x0]
+ cmp w1, #58
bne G_M000_IG06
G_M000_IG05:
- cmp w1, #1
- bls G_M000_IG11
ldrh w0, [x0, #0x02]
cmp w0, #58
beq G_M000_IG06
- add w0, w2, w0
+ add w0, w1, w0
add w0, w0, #10
b G_M000_IG07
@@ -44,8 +42,4 @@
mov w0, wzr
b G_M000_IG07
-G_M000_IG11:
- bl CORINFO_HELP_RNGCHKFAIL
- brk #0
-
-; Total bytes of code 112
+; Total bytes of code 96
Машинерия «утверждение» в JIT распространяет выученные факты (как выше упомянутая информация диапазона) между «базовыми блоками» (последовательность инструкций с одной точкой входа, одной точкой выхода и нет ветвлений в или из середины этого), так информация, установленная в блоке A потоки к блоку B, если A «доминирует» B (означающий единственный путь к B это через A). Но что про факты, установленные ранее в том же самом блоке? Это пробел, что dotnet/runtime#121527 адресирует. Рассмотрим этот код:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int[] _arr = new int[512];
[Benchmark]
public int RunMany()
{
int touched = 0;
for (int i = 0; i < _arr.Length - 2; i++)
{
Test(_arr, i);
touched++;
}
return touched;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Test(int[] arr, int i)
{
arr[i] = 0; // 1: устанавливает 'i >= 0 && i < arr.Length'
i++; // 2: же блок
if (i < arr.Length) arr[i] = 0; // 3: доказано безопасно из утверждения 1
}
}
Выписи 1, 2 и 3 все в том же самом базовом блоке, вплоть до условного; после выписи 1 выполняется, если мы достигаем выписи 2, проверка границ на выписи 1 прошла, мы знаем i >= 0 и i < arr.Length и после вычисления i + 1, мы знаем i находится в диапазоне [1, arr.Length]. Однако JIT в .NET 10 эмитирует проверку границ на выписи 3 и не может видеть, что она ненужна:
; x64
add ecx, 1 ; i++
cmp ecx, dword ptr [rax+8] ; bounds check
jae rngchkfail
mov dword ptr [rax+rcx*4+16], 0
С инфра-блочным утверждением, .NET 11 вместо этого распознаёт, что, так как утверждение i < arr.Length был установлен рано в блоке и ничего с тем времени не инвалидировало это (переменные arr и i не изменены способом, который инвалидировал бы утверждение), утверждение все ещё верно:
; x64
add ecx, 1 ; i++
mov dword ptr [rax+rcx*4+16], 0 ; no bounds check
Паттерн и оптимизация были расширены, чтобы работать в пределах основного блока рецидива. Когда цикл имеет несколько бивариантов (цикловых переменных) с независимыми диапазонами (например, for (int i = 0, j = 0; ...)), значения как i + 1 и j + 1 могут быть в диапазоне, что позволяет проверкам границ быть исключёнными, даже если ни i ни j отдельно не были достаточно, чтобы доказать безопасность доступа.