Skip to content

Repository files navigation

PrivateAccessProof

Замеры к статье «А чё, так можно было? Приватное поле за 0,25 наносекунды» — доступ к приватным членам чужого типа: рефлексия, скомпилированное дерево выражений, метод из инструкций IL и UnsafeAccessor из .NET 8.

BenchmarkDotNet 0.15.8, Release, .NET 8, .NET 9 и .NET 10 в одном запуске, со снятием машинного кода.

Статья: «А чё, так можно было? Приватное поле за 0,25 наносекунды»

Что проверяется

Раздел Что смотрим Что сравнивается
1 чтение приватного поля со строкой прямой доступ, рефлексия с поиском и без, дерево выражений, IL, UnsafeAccessor
2 запись в приватное поле рефлексия, дерево выражений, IL, UnsafeAccessor
3 приватное поле значимого типа упаковка у рефлексии против её отсутствия
4 вызов приватного метода рефлексия, делегат, UnsafeAccessor

Стенд

Машина Процессор Система
Комп 1 Intel Core i9-10900KF 3.70GHz, 10 ядер Windows 10 22H2
Комп 2 AMD Ryzen 9 5950X 3.39GHz, 16 ядер Windows 10 1809
Комп 3 Intel Xeon W-2255 3.70GHz, 10 ядер Windows Server 2022
Комп 4 Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра Windows Server 2022

Все машины x64. Выгрузки лежат в Results: Comp_1 — i9-10900KF, Comp_2 — Ryzen 9 5950X, Comp_3 — Xeon W-2255, Comp_4 — Xeon Silver 4314. Журналы прогона в репозиторий не вошли: они по мегабайту и нужны только при разборе сбоя.

Результаты

1. Чтение приватного поля со строкой

Способ Комп 1 Комп 2 Комп 3 Комп 4
прямое чтение внутри класса 0,3006 0,8650 0,9838 0,7647
UnsafeAccessor 0,2898 0,7547 1,0182 0,7140
дерево выражений 1,5103 1,6780 2,2035 2,7431
метод из инструкций IL 2,2986 2,1556 3,1487 4,3177
рефлексия с готовым полем 2,2552 3,0206 2,9642 3,5407
рефлексия с поиском поля 11,6615 11,1472 16,3642 15,4772

Наносекунды, .NET 10. UnsafeAccessor работает за то же время, что прямое чтение внутри самого класса. Рефлексия с готовым описанием поля медленнее в 2,9–7,8 раза, с поиском поля на каждом обращении — в 14,8–40,2 раза.

2. Запись в приватное поле

Способ Комп 1 Комп 2 Комп 3 Комп 4
UnsafeAccessor 2,029 2,152 1,993 2,459
дерево выражений 2,639 2,941 3,257 3,591
метод из инструкций IL 3,524 3,428 4,302 5,283
рефлексия 6,748 7,477 9,730 10,301

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

3. Приватное поле значимого типа

Способ Комп 1 Комп 2 Комп 3 Комп 4
прямое чтение внутри класса 0,3987 0,3354 0,4634 0,7005
UnsafeAccessor 0,4053 0,2504 0,4826 0,7247
рефлексия 7,5632 8,9606 11,7065 13,2518

Наносекунды, .NET 10. У рефлексии дополнительно 24 байта в куче на каждое обращение — столбец Allocated одинаков на всех четырёх машинах. Разница от 18,3 до 35,8 раза.

По рантаймам разница разная:

Рантайм Комп 1 Комп 2 Комп 3 Комп 4
.NET 8 91,7 108,3 97,6 78,2
.NET 9 76,6 117,4 84,9 57,2
.NET 10 18,7 35,8 24,3 18,3

Во сколько раз рефлексия медленнее при чтении числа. В .NET 10 её ускорили: на Ryzen 9 5950X чтение занимало 33,74 наносекунды на восьмёрке и 8,96 на десятке.

4. Вызов приватного метода

Способ Комп 1 Комп 2 Комп 3 Комп 4
прямой вызов внутри класса 8,886 11,70 14,05 15,76
UnsafeAccessor 9,041 10,54 13,95 17,68
делегат от описания метода 11,463 14,43 16,12 20,65
рефлексия 25,876 33,45 40,27 47,96

Наносекунды, .NET 10. Разница 2,7–3,2 раза, у рефлексии 104 байта в куче против 48 у остальных.

Ошибка в имени члена

Отчёт failure показывает, чем кончается опечатка:

Метод доступа объявлен так:

  [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_tokenn")]
  internal static extern ref string Missing(Secret instance);

Сборка прошла: True
  вызов не прошёл
  тип исключения: System.MissingFieldException
  сообщение:      Field not found: 'PrivateAccessProof.Types.Secret._tokenn'.

Рефлексия в той же ситуации ведёт себя иначе:
  GetField вернул: null

Как воспроизвести

Нужны SDK .NET 8, 9 и 10: BenchmarkDotNet поднимает по процессу на рантайм.

dotnet --list-sdks

В пути к проекту не должно быть запятых и точек с запятой. BenchmarkDotNet собирает вспомогательный проект и передаёт путь в MSBuild без кавычек, тот разбирает эти знаки как разделители списка свойств и падает с MSB1006. Отчёты при этом снимаются нормально, а замеры молча не выполняются. Батник проверяет путь и останавливается сразу.

Весь прогон одной командой:

all.bat

Вручную, без скрипта:

dotnet run -c Release -f net10.0 -- checks
dotnet run -c Release -f net10.0 -- failure
dotnet run -c Release -f net10.0 -- --filter *
dotnet run -c Release -f net10.0 -- --filter *ReadBench*

Аргумент noasm выключает снятие машинного кода.

Причина любого сбоя записывается в Bdn\PrivateAccessProof.log.

Как устроен замер

Все измеряемые методы лежат в Subjects.cs, у каждого NoInlining.

У всех способов подготовка вынесена за пределы замера: описание поля найдено один раз, дерево выражений скомпилировано, метод из инструкций IL собран, делегат создан. Всё это делается в статическом конструкторе Reflectors.

Отдельным вариантом замеряется рефлексия с поиском поля на каждом обращении. Так писать не надо, но именно так её чаще всего и применяют, поэтому вариант в таблице есть. В сравнении с UnsafeAccessor он не участвует.

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

Для поля значимого типа замер отдельный. Рефлексия возвращает object, поэтому число упаковывается, и это видно в столбце Allocated.

Для структуры первый аргумент метода доступа объявлен по ссылке. Без этого доступ достался бы копии, и запись потерялась бы. Сверка это проверяет.

Имя члена в UnsafeAccessor задаётся строкой. Отчёт failure показывает, что бывает при опечатке: сборка проходит, а при первом вызове летит MissingFieldException. Рефлексия в той же ситуации возвращает null, и проверить это можно сразу.

Что проверялось и чем

Что проверялось Чем
Все способы читают одно значение отчёт checks сравнивает пять способов с прямым доступом
Запись доходит до объекта, а не до копии отчёт checks читает поле после записи каждым способом
Статические члены доступны так же отчёт checks проверяет статическое поле и статический метод
У структуры нужен первый аргумент по ссылке отчёт checks пишет в поле структуры и читает результат
Опечатка в имени ловится в рантайме отчёт failure показывает тип исключения
Поиск члена не попадает в замер всё готовится один раз в статическом конструкторе
Упаковка учтена столбец Allocated в разделе 3
Дело не в сборщике мусора режим DOTNET_gcServer=1
Дело не в конкретной машине четыре машины, выгрузки всех четырёх в Results
Дело не в конкретной версии .NET 8, 9 и 10 в одном запуске

Все замеры сняты на x64 под Windows.

Дизассемблер на Linux требует установленного perf. Выключается аргументом noasm.

Отчёты

Bdn\results\                    отчёты BenchmarkDotNet: csv, md, html, машинный код
Bdn\PrivateAccessProof.log      журнал прогона: причины сбоев только здесь
Results\Comp_N\
    checks_netN.0.txt           сверка, роняет прогон при расхождении
    checks_netN.0_servergc.txt  то же на серверном сборщике
    failure_netN.0.txt          что бывает при опечатке в имени члена
    probe_noasm.txt             проба на одном классе без дизассемблера
    probe_asm.txt               та же проба с дизассемблером
    bench\                      отчёты BenchmarkDotNet

Что где лежит

PrivateAccessProof.csproj    многоцелевой: net8.0, net9.0, net10.0
PrivateAccessProof.slnx
Program.cs                   точка входа, разбор аргументов
Subjects.cs                  все измеряемые способы доступа
README.md
all.bat                      весь прогон одной командой
Benchmarks\
    ReadBench.cs             раздел 1, чтение поля
    WriteBench.cs            раздел 2, запись в поле
    NumberBench.cs           раздел 3, поле значимого типа
    InvokeBench.cs           раздел 4, вызов метода
Types\
    BenchmarkConfig.cs       три рантайма, дизассемблер, колонки и журнал
    Shapes.cs                тип с приватными членами и структура
    Accessors.cs             методы доступа через UnsafeAccessor
    Reflectors.cs            рефлексия, дерево выражений, метод из IL
Diagnostics\
    Checks.cs                сверка, код возврата
    Failure.cs               ошибка в имени члена
Results\
    Comp_1 .. Comp_4\        выгрузки прогона, по папке на машину

Ссылки

Границы

Числа в этом файле взяты только из отчётов. Отношения посчитаны из исходных значений, а не из округлённых.

UnsafeAccessor не отменяет проверок доступа при сборке чужого кода: он снимает их только у того метода, который объявлен с этим атрибутом.

Ссылки на исходники .NET даны на тег v10.0.0, коммит зафиксирован: main уедет, и номера строк перестанут совпадать.

About

Доступ к приватным полям и методам чужого типа: рефлексия, дерево выражений, генерация IL и UnsafeAccessor из .NET 8. Замеры на .NET 8/9/10, четыре машины.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages