А чё, так можно было? Три места в .NET, которые не развидеть

от автора

Пустой наследник Random меняет реализацию генератора. Массив строк хранится в переменной типа object[] до первой записи числа. А Matrix4x4 до сих пор работает на шестнадцати полях 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. В Matrix4x4 шестнадцать float вместо четырёх Vector4

Исходник

Причина указана в комментарии к Matrix4x4:

// 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 

Второй реализации Matrix4x4 в .NET нет, поэтому сравнивать эти числа не с чем. Четыре колонки показывают разброс по железу: на Xeon Silver 4314 те же операции идут почти вдвое дольше, чем на i9-10900KF.

Где легко ошибиться

Объявить публичные поля в структуре, которой будут пользоваться другие разработчики. После первого же релиза набор полей и их порядок становятся частью двоичной совместимости, и переписать их уже нельзя.

Со свойствами такого нет: внутри у них методы, а реализацию метода можно поменять в любой версии.

Границы замеров

Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.

У всех измеряемых методов запрещено встраивание. Наследник объявлен пустым, и сверка это подтверждает: у него нет ни одного объявленного метода.

Проверка типа массива в Span в замере не проявилась. Она в коде есть, но джит её выбрасывает, когда тип массива известен в точке вызова.

Код из статьи

  • LegacyProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

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

ссылка на оригинал статьи https://habr.com/ru/articles/1082672/