Пустой наследник Random меняет реализацию генератора. Массив строк хранится в переменной типа object[] до первой записи числа. А Matrix4×4 до сих пор работает на шестнадцати полях float вместо четырёх векторов.
Процессор | Система |
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. Пустой наследник меняет реализацию
Исходник
Пустой наследник:
internal sealed class EmptyRandom : Random { } internal sealed class SeededRandom : Random { internal SeededRandom(int seed) : base(seed) { } }
Ни переопределённых методов, ни полей. И тем не менее конструктор Random подставит ему другую реализацию:
public Random() { // Note: if this is a derived type, the CompatDerivedImpl is used _impl = GetType() == typeof(Random) ? new XoshiroImpl() : new CompatDerivedImpl(this); }
Что выводит отчёт
как создан реализация new Random() XoshiroImpl new EmptyRandom() Net5CompatDerivedImpl new Random(42) Net5CompatSeedImpl new SeededRandom(42) Net5CompatDerivedImpl Первые 5 чисел с начальным значением 42: Random 668 140 125 522 168 SeededRandom 668 140 125 522 168
Числа те же. Отличается путь вызова.
Что показывает замер
Метод | i9-10900KF | Ryzen 9 5950X | Xeon W-2255 | Xeon Silver 4314 |
Next | 1,816 / 2,608 | 2,333 / 3,190 | 2,318 / 3,138 | 3,196 / 4,104 |
NextDouble | 2,618 / 3,937 | 3,104 / 4,434 | 2,020 / 4,940 | 3,316 / 6,034 |
NextBytes, 256 байт | 37,025 / 537,487 | 35,885 / 572,608 | 50,093 / 648,626 | 51,063 / 889,873 |
Наносекунды, базовый тип и пустой наследник,.NET 10
На Next наследник отстаёт в 1,28–1,44 раза. На заполнении буфера — в 12,95–17,43 раза.
Причина
Проверка в конструкторе нужна ради переопределённых методов. Скажем, наследник переопределил Sample. Тогда NextBytes обязан вызывать именно его, а не свою внутреннюю версию — иначе в старом коде числа поменяются.
Отличить один случай от другого в конструкторе невозможно: пустой наследник и наследник с переопределением выглядят одинаково. Поэтому все потомки получают Net5CompatDerivedImpl.
На буфере разница самая большая: Net5CompatDerivedImpl пишет по одному байту за раз, а XoshiroImpl — сразу по восемь.
Где легко ошибиться
Унаследоваться, чтобы добавить один метод. Класс с единственным NextGuid получит Net5CompatDerivedImpl для всех остальных вызовов. Тот же самый, в котором живёт опечатка возрастом больше 20 лет.
Вместо наследования тут подходит метод расширения или обёртка с полем типа Random внутри.
2. string[] и object[] — один и тот же объект
Исходник
Массив строк можно присвоить переменной типа object[], и компилятор не выдаст ошибку:
string[] strings = new string[4]; object[] objects = strings; objects[0] = "строка"; // работает objects[0] = 42; // System.ArrayTypeMismatchException
Что выводит отчёт
objects.GetType() String[] ReferenceEquals(strings, objects) True objects[0] = "строка" прошло objects[0] = 42 ArrayTypeMismatchException new Span<object>(objects) ArrayTypeMismatchException new Span<string>(strings) прошло
Это один и тот же объект: ReferenceEquals возвращает true, а тип остаётся String[].
Причина
Массивы ссылочных типов в.NET ковариантны: string[] считается наследником object[]. Так решили в первой версии: обобщённых типов тогда не было, и без ковариантности не получилось бы написать метод, работающий с массивом любого типа.
Цена — проверка при каждой записи. Рантайм сверяет тип значения с настоящим типом массива и при расхождении бросает исключение.
Из‑за этого Span при создании проверяет, что массив действительно того типа, что объявлен:
public Span(T[]? array) { if (array == null) { this = default; return; } if (!typeof(T).IsValueType && array.GetType() != typeof(T[])) { ThrowHelper.ThrowArrayTypeMismatchException(); } // ... }
Проверка нужна только для ссылочных типов: int[] никогда не окажется массивом другого типа.
Где легко ошибиться
Решить, что эта проверка дорого обходится. Замер разницы не показал: 0,3966 наносекунды у string[] против 0,4034 у int[] на i9-10900KF, и так же на остальных трёх машинах. Тип массива известен в точке вызова, а потом джит проверку убирает.
Зато при записи в сам массив проверка остаётся. Поэтому массив один раз оборачивают в Span и уже с ним работают, так как в нём проверок при записи нет.
3. В Matrix4×4 шестнадцать float вместо четырёх Vector4
Исходник
Причина указана в комментарии к Matrix4×4:
// In an ideal world, we'd have 4x Vector4 fields. However, Matrix4x4 was // shipped with 16x public float fields and as such we cannot change the // size or layout of the type without it being a breaking change.
Что выводит отчёт
тип размер открытых полей все поля float Matrix4x4 64 байт 16 да Matrix3x2 24 байт 6 да Vector4 16 байт 4 да Вложенные непубличные типы у Matrix4x4: Impl
Причина
Четыре Vector4 укладываются в регистры процессора, и операции над матрицей выполняются векторными командами. Шестнадцать отдельных float так не умеют.
Поля публичные, к ним обращаются напрямую, а размер и раскладка структуры входят в двоичную совместимость. Их замена нарушает совместимость с уже написанным кодом.
Поэтому в соседнем файле объявили вложенную структуру Impl того же размера, но с четырьмя векторами, и приводят публичный тип к ней и обратно через Unsafe.As. В машинный код это приведение не попадает: типы одинаковы по размеру и раскладке, джит работает с тем же значением.
Что показывает замер
Действие | i9-10900KF | Ryzen 9 5950X | Xeon W-2255 | Xeon Silver 4314 |
умножение 4×4 | 6,306 | 6,405 | 7,081 | 11,601 |
сложение 4×4 | 2,353 | 4,464 | 3,044 | 10,795 |
умножение 3×2 | 4,921 | 6,184 | 3,912 | 6,101 |
Наносекунды,.NET 10
Второй реализации Matrix4×4 в.NET нет, поэтому сравнивать эти числа не с чем. Четыре колонки показывают разброс по железу: на Xeon Silver 4314 те же операции идут почти вдвое дольше, чем на i9-10900KF.
Где легко ошибиться
Объявить публичные поля в структуре, которой будут пользоваться другие разработчики. После первого же релиза набор полей и их порядок становятся частью двоичной совместимости, и переписать их уже нельзя.
Со свойствами такого нет: внутри у них методы, а реализацию метода можно поменять в любой версии.
Границы замеров
Все замеры сняты на x64 под Windows, на.NET 8, 9 и 10.
У всех измеряемых методов запрещено встраивание. Наследник объявлен пустым, и сверка это подтверждает: у него нет ни одного объявленного метода.
Проверка типа массива в Span в замере не проявилась. Она в коде есть, но джит её выбрасывает, когда тип массива известен в точке вызова.
Код из статьи
LegacyProof — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!

