Замеры к статье «А чё, так можно было? Приватное поле за 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 | Комп 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 раза.
| Способ | Комп 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.
| Способ | Комп 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 на десятке.
| Способ | Комп 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\ выгрузки прогона, по папке на машину
- UnsafeAccessorAttribute в исходниках
- UnsafeAccessorType: доступ к типу по имени
- Как этим пользуется ResourceReader
- И AppDomain
- FieldInfo.GetValue
- DynamicMethod
- Настройки сборщика мусора
Числа в этом файле взяты только из отчётов. Отношения посчитаны из исходных значений, а не из округлённых.
UnsafeAccessor не отменяет проверок доступа при сборке чужого кода: он
снимает их только у того метода, который объявлен с этим атрибутом.
Ссылки на исходники .NET даны на тег v10.0.0, коммит зафиксирован: main
уедет, и номера строк перестанут совпадать.