Пустой наследник 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 — замеры, отчёты и выгрузки с четырёх машин

Ссылки

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали, что пустой наследник Random работает медленнее?
0%Все знают0
0%Знал, но про разницу в 17 раз не думал0
100%А чё, так можно было что ли!4
Проголосовали 4 пользователя. Воздержались 2 пользователя.