А чё, так можно было? Три недооценённых атрибута .NET

от автора

Три атрибута, которые меняют результат компиляции. Один запускает метод раньше Main. Второй передаёт текст выражения вместо результата. Третий ускоряет stackalloc в 50 раз.

Все три описаны в документации и применяются внутри .NET. За его пределами их практически не найти. 

Процессор

Система

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Все машины x64

Рантаймы .NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8.

1. Код, который выполняется до Main

Исходник

Метод помечен атрибутом и больше нигде не встречается:

internal static class Startup{    [ModuleInitializer]    internal static void Init()    {        Order.Add("инициализатор модуля");    }}

В Main его нет. Там только своя отметка первой строкой:

private static int Main(string[] args){    Startup.Order.Add("точка входа");    // ...}

Что выводит отчёт

Порядок событий:   1. инициализатор модуля  2. точка входа

Одинаково на четырёх машинах и трёх рантаймах.

Причина

Компилятор находит все методы с этим атрибутом и вызывает их из инициализатора модуля. До этого момента ни одна строка кода сборки не выполняется.

К методу есть требования: статический, без параметров, возвращает void, не обобщённый и не внутри обобщённого типа, доступность internal или public. Любое нарушение — ошибка сборки.

Где встречается

В библиотеке классов такой метод один, в EventSource. Основное применение — генераторы исходного кода: настройка выполняется до Main.

Где легко ошибиться

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

Бросить исключение. Программа падает до точки входа, и Main не выполняется:

Unhandled exception. System.TypeInitializationException:  The type initializer for '<Module>' threw an exception. ---> System.InvalidOperationException: из инициализатора

Применять в библиотеке. Начиная с .NET 10 включено правило CA2255: библиотека меняет порядок запуска приложения и мешает выбросить неиспользуемый код при публикации.

2. Текст выражения вместо значения

Исходник

Второй параметр помечен атрибутом и получает значение по умолчанию:

static void Check(bool condition,    [CallerArgumentExpression(nameof(condition))] string? text = null){    Console.WriteLine(text + " = " + condition);}

Что выводит отчёт

Что попадает в text при разных вызовах:   a + b > 10                            ложь  a * b == 6                            истина  empty is null                         истина  !string.IsNullOrEmpty(empty)          ложь

В параметр приходит не результат, а то, что написано в месте вызова.

Причина

Подстановка происходит при сборке: компилятор берёт исходный текст аргумента и передаёт его строковой константой. Программа получает готовую строку и ничего с ней не делает.

Замер это подтверждает. Библиотечная проверка и такая же, написанная явно, исключений в замере нет: 

Способ

i9-10900KF

Ryzen 9 5950X

Xeon W-2255

Xeon Silver 4314

из библиотеки

0,3944

0,2656

0,7058

0,6679

вручную

0,3969

0,2646

0,6427

0,7231

Наносекунды, .NET 10

Где встречается

В 21-м месте библиотеки классов. Например, ArgumentNullException.ThrowIfNull:

public static void ThrowIfNull([NotNull] object? argument,    [CallerArgumentExpression(nameof(argument))] string? paramName = null){    if (argument is null)    {        Throw(paramName);    }}

Поэтому исключение показывает имя переменной из места вызова, а не имя параметра argument. Отчёт это подтверждает:

  ArgumentNullException             имя аргумента: empty  ArgumentOutOfRangeException       имя аргумента: a - 5

Во втором случае именем аргумента стало целое выражение — то, что передали.

Где легко ошибиться

Передать текст вторым аргументом. Тогда компилятор ничего не подставляет и берёт то, что написано:

Check(a + b > 10);                       // текст: a + b > 10Check(a + b > 10, "передано вручную");   // текст: передано вручную

Забыть про размер сборки. Текст каждого аргумента строкой попадает в метаданные: после шести вызовов сборка увеличилась с 4608 байт до 5120.

3. Отказ от обнуления памяти

Исходник

Два одинаковых метода, различаются одной строкой:

[MethodImpl(MethodImplOptions.NoInlining)]internal static int StackWithInit(int size){    Span<byte> buffer = stackalloc byte[size];    buffer[0] = 1;    buffer[^1] = 2;     return buffer[0] + buffer[^1];} [SkipLocalsInit][MethodImpl(MethodImplOptions.NoInlining)]internal static int StackWithoutInit(int size){    // тело то же самое}

Что показывает замер

Буфер

i9-10900KF

Ryzen 9 5950X

Xeon W-2255

Xeon Silver 4314

64 байта

2,452 / 1,765

2,082 / 4,695

2,793 / 2,419

5,409 / 4,512

256 байт

6,475 / 1,729

5,741 / 4,691

8,530 / 2,208

13,015 / 4,501

1024 байта

25,505 / 1,769

17,712 / 4,703

31,281 / 2,574

48,282 / 4,566

4096 байт

103,474 / 2,043

71,811 / 4,939

126,066 / 2,685

189,264 / 4,827

Наносекунды, с обнулением и без, .NET 10 

На четырёх килобайтах разница от 14,5 до 50,6 раза. С обнулением время растёт вместе с буфером, без обнуления — не меняется.

Причина

У метода есть флаг от компилятора, и по нему джит заполняет нулями всю память под локальные переменные, включая ту, что выделена через stackalloc. Атрибут его убирает.

Отсюда и числа в таблице: чем больше буфер, тем дольше заполнение, а без него размер не важен.

Где встречается

Атрибут прописан не в коде, а в общем файле сборки: свойство SkipLocalsInit включено для каждого проекта, входящего в .NET.

Где легко ошибиться

Поставить атрибут ради небольшого буфера. На 64 байтах выигрыш в лучшем случае 1,39 раза, а на Ryzen 9 5950X версия без обнуления медленнее: 4,695 против 2,082.

На этой машине результат отличается от остальных: без заполнения нулями с ростом буфера время не меняется и составляет около 4,7 наносекунды, а на других процессорах оно в пределах 1,7–2,7.

Прочитать буфер раньше, чем в него что-то записали. Атрибут не заполняет память нулями и не требует этого от рантайма — в буфере будут данные от предыдущих вызовов.

Забыть про файл проекта. Без небезопасного контекста атрибут не работает: сборка упадёт с ошибкой CS0227.

Границы замеров

Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.

У всех измеряемых методов запрещено встраивание. Иначе компилятор перенесёт код метода в замер, а вместе с ним заполнение памяти нулями.

SkipLocalsInit проставлен на отдельных методах, а не на классе: на классе он подействует и на тот вариант, который заполняет память нулями.

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

Код из статьи

  • AttributeProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

Всем удачи и до новых встреч!

ссылка на оригинал статьи https://habr.com/ru/articles/1082206/