Три атрибута, которые меняют результат компиляции. Один запускает метод раньше 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 > 10
Check(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 — замеры, отчёты и выгрузки с четырёх машин

Ссылки

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали про эти три атрибута?
0%Все знают0
0%Знал, но не пробовал0
100%А чё, так можно было что ли!4
Проголосовали 4 пользователя. Воздержавшихся нет.