Три атрибута, которые меняют результат компиляции. Один запускает метод раньше 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/