Unsafe.AsSpan: Span<T> как замена указателям?

в 20:23, , рубрики: .net, C#, MSIL, reflection, ненормальное программирование

Unsafe.AsSpan: Span<T> как замена указателям? - 1

C# — невероятно гибкий язык. На нем можно писать не только бэкэнд или десктопные приложения. Я использую C# для работы, в том числе, и с научными данными, которые накладывают определенные требования на инструменты, доступные в языке. Хотя netcore захватывает повестку дня (учитывая, что после netstandard2.0 большинство фич как языков, так и рантайма, не бэк-портируются в netframework), я продолжаю работать и с легаси-проектами.
В этой статье я рассматриваю одно неочевидное (но, наверное, желаемое?) применение Span<T> и отличие реализации Span<T> в netframework и netcore из-за особенностей clr.

Дисклеймер 1

Фрагменты кода в данной статье ни в коем случае не предназначены для использования в реальных проектах.
Предлагаемое решение (надуманной?) проблемы — это, скорее, proof-of-concept.
В любом случае, реализуя подобное в своем проекте, вы делаете это на свой страх и риск.

Дисклеймер 2

Я абсолютно уверен, что где-то, в каком-то случае это обязательно выстрелит кому-то в колено.
Обход типобезопасности в C# вряд ли приводит к чему-то хорошему.
По очевидным причинам, я не тестировал данный код во всех возможных ситуациях, однако предварительные результаты выглядят многообещающими.

А зачем мне вообще Span<T>?

Спэн позволяет работать с массивами unmanaged-типов в более удобной форме, уменьшая количество необходимых аллокаций. Несмотря на тот факт, что поддержка спэнов в BCL netframework практически полностью отсутствует, несколько инструментов можно получить, используя System.Memory, System.Buffers и System.Runtime.CompilerServices.Unsafe.
Использование спэнов в моем легаси-проекте ограничено, однако я нашел им неочевидное применение, попутно наплевав на безопасность типов.
Что же это за применение? В своем проекте я работаю с данными, получаемыми с научного инструмента. Это изображения, которые, в общем случае представляют собой массив T[], где T это один из unmanaged примитивных типов, например Int32 (он же int). Для корректной сериализации этих изображений на диск, мне необходимо поддерживать невероятно неудобный легаси-формат, который был предложен в 1981-м, и с тех пор слабо поменялся. Главная проблема этого формата — он BigEndian. Таким образом, чтобы записать (или прочитать) несжатый массив T[], нужно поменять endianess каждого элемента. Тривиальная задача.
Какие можно предложить очевидные решения?

  1. Итерируем по массиву T[], вызываем BitConverter.GetBytes(T), разворачиваем эти несколько байт, копируем в целевой массив.
  2. Итерируем по массиву T[], выполняем махинации вида new byte[] {(byte)((x & 0xFF00) >> 8), (byte)(x & 0x00FF)}; (должно работать на двухбайтовых типах), пишем в целевой массив.
  3. * Но ведь T[] это массив? Элементы находятся подряд, да? Значит можно пойти во все тяжкие, например Buffer.BlockCopy(intArray, 0, byteArray, 0, intArray.Length * sizeof(int));. Метод копирует массив в массив игнорируя проверку типов. Нужно лишь не промахнуться с границами и аллокацией. Перемешиваем байты уже в результате.
  4. * Говорят, что C# это (C++)++. Поэтому включаем /unsafe, вооружаемся fixed(int* p = &intArr[0]) byte* bPtr = (byte*)p; и вот уже можно бегать по байтовому представлению исходного массива, на лету менять endianess и писать блоками на диск (добавив stackalloc byte[] или ArrayPool<byte>.Shared для промежуточного буфера), не выделяя память на целый новый массив байт.

Казалось бы, 4 пункт позволяет решить все проблемы, но явное использование unsafe-контекста и работа с указателями — это как-то совсем не то. Тут нам на помощь и приходит Span<T>.

Span<T>

Span<T> технически должен предоставлять инструменты для работы с участками памяти практически как работа через указатели, при этом исключая необходимость "закреплять" массив в памяти. Такой GC-aware указатель с границами массива. Все отлично и безопасно.
Одно лишь но — несмотря на богатство System.Runtime.CompilerServices.Unsafe, Span<T> гвоздями прибит к типу T. Учитывая, что спэн это, по сути, указатель1 + длина, а что если вытащить этот ваш указатель, преобразовать его к другому типу, пересчитать длину и сделать новый спэн? Благо у нас есть public Span<T>(void* pointer, int length).
Напишем простой тест:

[Test]
public void Test()
{
    void Flip(Span<byte> span) {/* тут вращаем endianess */}
    Span<int> x = new [] {123};
    Span<byte> y = DangerousCast<int, byte>(x);

    Assert.AreEqual(123, x[0]);
    Flip(y);
    Assert.AreNotEqual(123, x[0]);
    Flip(y);
    Assert.AreEqual(123, x[0]);
}

Более продвинутые чем я разработчики должны сразу сообразить, что здесь не так. Провалится ли тест? Ответ, как это обычно бывает, — зависит.
В данном случае зависит в первую очередь от рантайма. На netcore тест должен работать, а на netframework — как получится.
Интересно, что, если убрать часть эссертов, тест начинает корректно работать в 100% случаев.
Давайте разбираться.

1 Я ошибался.

Правильный ответ: зависит

Почему же результат — зависит?
Уберем все лишнее и напишем вот такой вот код:

private static void Main() => Check();

private static void Check()
{
    Span<int>  x = new[] {999, 123, 11, -100};
    Span<byte> y = As<int, byte>(ref x);

    Console.WriteLine(@"FRAMEWORK_NAME");
    Write(ref x);
    Write(ref y);
    Console.WriteLine();

    Write<int, int>(ref x, "Span<int> [0]");
    Write<byte, int>(ref y, "Span<byte>[0]");
    Console.WriteLine();

    Write<int, int>(ref Offset<int, object>(ref x[0], 1), "Span<int> [0] offset by size_t");
    Write<byte, int>(ref Offset<byte, object>(ref y[0], 1), "Span<byte>[0] offset by size_t");
    Console.WriteLine();

    GC.Collect(0, GCCollectionMode.Forced, true, true);

    Write<int, int>(ref x, "Span<int> [0] after GC");
    Write<byte, int>(ref y, "Span<byte>[0] after GC");
    Console.WriteLine();
    Write(ref x);
    Write(ref y);
}

Метод Write<T, U> принимает спэн типа T, считает адрес первого элемента, и считывает через этот указатель один элемент типа U. Иными словами, Write<int, int>(ref x) выведет адрес в памяти + число 999.
Обычный Write печатает массив.
Теперь про метод As<,>:

 private static unsafe Span<U> As<T, U>(ref Span<T> span)
            where T : unmanaged
            where U : unmanaged
        {
            fixed(T* ptr = span)
                return new Span<U>(ptr,
                span.Length * Unsafe.SizeOf<T>() / Unsafe.SizeOf<U>());
        }

Сейчас синтаксис C# поддерживает такую запись fixed-стэйтмента через неявный вызов метода Span<T>.GetPinnableReference().
Запустим этот метод на netframework4.8 в x64 режиме. Смотрим, что получается:

LEGACY
[ 999, 123, 11, -100 ]
[ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ]

0x|00|00|02|8C|00|00|2F|B0         999  Span<int> [0]
0x|00|00|02|8C|00|00|2F|B0         999  Span<byte>[0]

0x|00|00|02|8C|00|00|2F|B8          11  Span<int> [0] offset by size_t
0x|00|00|02|8C|00|00|2F|B8          11  Span<byte>[0] offset by size_t

0x|00|00|02|8C|00|00|2B|18         999  Span<int> [0] after GC
0x|00|00|02|8C|00|00|2F|B0     6750318  Span<byte>[0] after GC

[ 999, 123, 11, -100 ]
[ 110, 0, 103, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]

Изначально оба спэна (несмотря на разный тип) ведут себя идентично, а Span<byte>, по сути, представляет byte-view исходного массива. То, что нужно.
Окей, попробуем сдвинуть начало спэна на размер одного IntPtr (или 2 X int на x64) и прочитать. Получаем третий элемент массива и корректный адрес. А потом соберем мусор...

GC.Collect(0, GCCollectionMode.Forced, true, true);

Последний флаг в этом методе просит GC уплотнить кучу. После вызова GC.Collect GC перемещает исходный локальный массив. Span<int> отражает эти изменения, а вот наш Span<byte> продолжает указывать на старый адрес, где теперь непонятно что. Отличный способ прострелить себе все колени сразу!

Теперь посмотрим на результат точно такого же фрагмента кода, вызванного на netcore3.0.100-preview8.

CORE
[ 999, 123, 11, -100 ]
[ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ]

0x|00|00|01|F2|8F|BD|C6|90         999  Span<int> [0]
0x|00|00|01|F2|8F|BD|C6|90         999  Span<byte>[0]

0x|00|00|01|F2|8F|BD|C6|98          11  Span<int> [0] offset by size_t
0x|00|00|01|F2|8F|BD|C6|98          11  Span<byte>[0] offset by size_t

0x|00|00|01|F2|8F|BD|BF|38         999  Span<int> [0] after GC
0x|00|00|01|F2|8F|BD|BF|38         999  Span<byte>[0] after GC

[ 999, 123, 11, -100 ]
[ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ]

Все работает, и работает стабильно, насколько я смог убедиться. После уплотнения, оба спэна меняют свой указатель. Отлично! Но как теперь заставить работать это в легаси-проекте?

JIT intrinsic

Я абсолютно забыл, что поддержка спэнов реализована в netcore через интринсик. Иными словами, netcore умеет создавать внутренние указатели даже на фрагмент массива и корректно обновлять ссылки, когда GC его шевелит. В netframework же nuget-реализация спэна — это костыль. По сути, у нас есть два разных спэна: один создается из массива и отслеживает свои ссылки, второй — из указателя и понятия не имеет, на что он указывает. После перемещения исходного массива, спэн-указатель продолжает указывать туда, куда указывал указатель, переданный в его конструктор. Для сравнения, это примерная реализация спэна в netcore:

readonly ref struct Span<T> where T : unmanaged
{
    private readonly ByReference<T> _pointer; // Внутри - магия интринсика
    private readonly int _length;
}

и в netframework:

readonly ref struct Span<T> where T : unmanaged
{
    private readonly Pinnable<T> _pinnable;
    private readonly IntPtr _byteOffset;
    private readonly int _length;
}

_pinnable содержит ссылку на массив, если таковой был передан в конструктор, _byteOffset содержит сдвиг (даже спэн по всему массиву имеет некий ненулевой сдвиг, связанный с тем, как массив представлен в памяти, наверное). Если в конструктор передать указатель void*, его просто преобразуют в абсолютный _byteOffset. Спэн будет прибит намертво к участку памяти, а все инстанс методы изобилуют условиями типа if(_pinnable is null) {/* верни по указателю */} else {/* посчитай сдвиг от _pinnable */}. Что делать в такой ситуации?

Как делать не стоит, но я все же сделал

Этот раздел посвящен различным реализациям, поддерживаемым netframework, которые позволяют осуществить каст Span<T> -> Span<U>, сохраняя все нужные ссылки.
Предупреждаю: это зона ненормального программирования с, возможно, фундаментальными ошибками и Undefined Behavior в конце

Метод 1: Наивный

Как показал пример, преобразование указателей не даст нужного результата на netframework. Нам нужно значение _pinnable. Окей, расчехлим рефлексию, вытащим приватные поля (очень плохо и не всегда возможно), запишем в новый спэн, порадуемся. Есть только одна ма-аленькая проблема: спэн это ref struct, он не может быть ни аргументом дженерика, ни упакованным в object. Стандартные методы рефлексии потребуют, так или иначе, запихать спэн в ссылочный тип. Простого способа (еще и учитывая рефлексию по приватным полям) я не нашел.

Метод 2: We need to go deeper

Все уже было сделано до меня ([1], [2], [3]). Спэн — структура, вне зависимости от T три поля занимают одинаковое количество памяти (на одной архитектуре). А что если [FieldOffset(0)]? Сказано — сделано.

[StructLayout(LayoutKind.Explicit)]
ref struct Exchange<T, U>
    where T : unmanaged
    where U : unmanaged
    {
        [FieldOffset(0)]
        public Span<T> Span_1;
        [FieldOffset(0)]
        public Span<U> Span_2;
    }

Но при запуске программы (а точнее при попытке использовать тип) нас встречает TypeLoadException — дженерик не может быть LayoutKind.Explicit. Окей, не беда, пойдем по сложному пути:

[StructLayout(LayoutKind.Explicit)]
public ref struct Exchange
{
    [FieldOffset(0)]
    public Span<byte> ByteSpan;
    [FieldOffset(0)]
    public Span<sbyte> SByteSpan;
    [FieldOffset(0)]
    public Span<ushort> UShortSpan;
    [FieldOffset(0)]
    public Span<short> ShortSpan;
    [FieldOffset(0)]
    public Span<uint> UIntSpan;
    [FieldOffset(0)]
    public Span<int>  IntSpan;
    [FieldOffset(0)]
    public Span<ulong> ULongSpan;
    [FieldOffset(0)]
    public Span<long> LongSpan;
    [FieldOffset(0)]
    public Span<float> FloatSpan;
    [FieldOffset(0)]
    public Span<double> DoubleSpan;
    [FieldOffset(0)]
    public Span<char> CharSpan;
}

Теперь можно сделать так:

private static Span<byte> As2(Span<int> span)
{
    var exchange = new Exchange()
    {
        IntSpan = span
    };
    return exchange.ByteSpan;
}

Метод работает с одной лишь проблемой — поле _length копируется как есть, поэтому при касте int -> byte длина байт-спэна в 4 раза меньше реального массива.
Не проблема:

[StructLayout(LayoutKind.Sequential)]
public ref struct Raw
{
    public object Pinnable;
    public IntPtr Pointer;
    public int Length;
}
[StructLayout(LayoutKind.Explicit)]
public ref struct Exchange
{
    /* */
    [FieldOffset(0)]
    public Raw RawView;
}

Теперь через RawView можно получить доступ к каждому отдельному полю спэна.

private static Span<byte> As2(Span<int> span)
{
    var exchange = new Exchange()
    {
        IntSpan = span
    };

    var exchange2 = new Exchange()
    {
        RawView = new Raw()
        {
            Pinnable = exchange.RawView.Pinnable,
            Pointer = exchange.RawView.Pointer,
            Length = exchange.RawView.Length * sizeof<int> / sizeof<byte>
        }
    };

    return exchange2.ByteSpan;
}

И это работает так, как надо, если игнорировать применение грязных трюков. Минус — дженерик версию конвертера создать нельзя, придется довольствоваться предопределенными типами.

Метод 3: Безумный

Как и любой нормальный программист, я люблю автоматизировать вещи. Необходимость написания конвертеров для любой пары unmanaged типов меня не радовала. Какое решение можно предложить? Правильно, заставить CLR написать код за вас.

Как этого добиться? Есть разные способы, есть статьи. Если коротко, процесс выглядит так:
Создаем билдер сборки -> создаем билдер модуля -> строим тип -> {Поля, Методы, и т.д.} -> на выходе получаем инстанс типа Type.
Чтобы точно понять, как должен выглядеть тип (ведь это ref struct), используем любой инструмент типа ildasm. В моем случае это был dotPeek.
Создание type builder выглядит примерно так:

var typeBuilder = _mBuilder.DefineType($"Generated_{typeof(T).Name}",
                TypeAttributes.Public
                | TypeAttributes.Sealed
                | TypeAttributes.ExplicitLayout   // <- Вот это важно
                | TypeAttributes.AnsiClass
                | TypeAttributes.BeforeFieldInit, typeof(ValueType));

Теперь — поля. Так как напрямую скопировать Span<T> в Span<U> из-за разницы длин мы не можем, нам нужно на каждый каст создавать по два типа вида

[StructLayout(LayoutKind.Explicit)]
ref struct Generated_Int32
{
    [FieldOffset(0)]
    public Span<Int32> Span;
    [FieldOffset(0)]
    public Raw Raw;
}

Здесь Raw мы можем объявить руками и переиспользовать. Не забываем про IsByRefLikeAttribute. С полями все просто:

var spanField = typeBuilder.DefineField("Span", typeof(Span<T>), FieldAttributes.Private);
spanField.SetOffset(0);
var rawField = typeBuilder.DefineField("Raw", typeof(Raw), FieldAttributes.Private);
rawField.SetOffset(0);

На этом все, простейший тип готов. Теперь кэшируем сборку, модуль. Кастомные типы кэшируем, например, в словарь (T -> Generated_{nameof(T)}). Создаем обертку, которая по двум типам TIn и TOut генерирует два типа-хэлпера и выполняет нужные операции над спэнами. Есть одно но. Как и в случае с рефлексией, использовать ее на спэнах (или на других ref struct) практически невозможно. Либо я не нашел простого решения. Как же быть?

Delegates to the rescue

Методы рефлексии выглядят обычно примерно так:

 object Invoke(this MethodInfo mi, object @this, object[] otherArgs)

Они не несут в себе информацию о типах, поэтому если боксинг (= упаковка) для вас приемлемы — проблем нет.
В нашем случае, @this и otherArgs должны содержать в себе ref struct, что мне обойти не удалось.
Однако есть способ проще. Давайте представим что у типа есть геттер и сеттер методы (не свойства, а вручную созданные простейшие методы).
Например:

void Generated_Int32.SetSpan(Span<Int32> span) => this.Span = span;

В дополнение к методу мы можем объявить тип делегата (явно в коде):

delegate void SpanSetterDelegate<T>(Span<T> span) where T : unmanaged;

На такое нам приходится идти, потому что стандартный экшен должен был бы иметь сигнатуру Action<Span<T>>, но спэны нельзя использовать как дженерик-аргументы. SpanSetterDelegate, однако, абсолютно валидный делегат.
Создадим себе нужные делегаты. Для этого нужно провести стандартные манипуляции:

var mi = type.GetMethod("Method_Name"); // Предполагая, что наш метод public & instance
var spanSetter =  (SpanSetterDelegate<T>) mi.CreateDelegate(typeof(SpanSetterDelegate<T>), @this); 

Теперь spanSetter можно использовать как, например, spanSetter(Span<T>.Empty);. Что до @this2, то это инстанс нашего динамического типа, созданный, разумеется, через Activator.CreateInstance(type), ведь у структуры есть дефолтный конструктор без аргументов.

Итак, последний рубеж — нам нужно динамически сгенерировать методы.

2 Можно обратить внимание, что здесь происходит что-то не то — Activator.CreateInstance() упаковывает ref struct-инстанс. См. конец следующего раздела.

Знакомьтесь, Reflection.Emit

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

Если посмотреть на IL-код тривиального геттера, то можно увидеть что-то типа (Debug, X86, netframework4.8)

nop
ldarg.0
ldfld /* что-то */
stloc.0
br.s /* адрес */
ldloc.0
ret

Здесь куча мест для остановки и отладки.
В релизной же версии остается только самое важное:

ldarg.0
ldfld /* что-то */
ret

Нулевым аргументом инстанс-метода является… this. Таким образом, в IL написано следующее:
1) Загрузи this
2) Загрузи значение поля
3) Верни

Просто, да? В Reflection.Emit есть специальная перегрузка, принимающая, кроме оп-кода, еще и параметр-дескриптор поля. Как раз такой, как мы получали ранее, например spanField.

var getSpan = type.DefineMethod("GetSpan",
                    MethodAttributes.Public
                    | MethodAttributes.HideBySig,
                    CallingConventions.Standard,
                    typeof(Span<T>), Array.Empty<Type>());

gen = getSpan.GetILGenerator();
gen.Emit(OpCodes.Ldarg_0);
gen.Emit(OpCodes.Ldfld, spanField);
gen.Emit(OpCodes.Ret);

Для сеттера немного сложнее, нужно загрузить на стэк this, загрузить первый аргумент функции, затем вызвать инструкцию записи в поле и вернуть ничего:

ldarg.0
ldarg.1
stfld /* идентификатор поля */
ret

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

Пишем класс-обертку, который по двум дженерик-параметрам (TIn, TOut) получает инстансы типа Type, ссылающиеся на соответствующие (закэширвоанные) динамические типы, после чего, создает по одному объекту каждого типа, и генерирует четыре дженерик-делегата, а именно

  1. void SetSpan(Span<TIn> span) чтобы записать исходный спэн в структуру
  2. Raw GetRaw() чтобы считать содержимое спэна как Raw-структуру
  3. void SetRaw(Raw raw) чтобы записать модифицированную Raw структуру во второй объект
  4. Span<TOut> GetSpan() чтобы вернуть спэн желаемого типа с корректно выставленными и пересчитанными полями.

Интересно что инстансы динамических типов нужно создать один раз. При создании делегата ссылка на эти объекты передается как параметр @this. Здесь происходит нарушение правил. Activator.CreateInstance возвращает object. По всей видимости связано это с тем, что сам по себе динамический тип не получился ref-like (type.IsByRefLike == false), однако ref-like поля создать удалось. Видимо, такое ограничение присутствует в языке, но CLR это переваривает. Возможно, именно здесь будут простреливаться колени в случае нестандартного использования. 3

Итак, получаем инстанс дженерик типа, содержащий в себе четыре делегата и две неявные ссылки на инстансы динамических классов. Делегаты и структуры можно переиспользовать при выполнении одинаковых кастов подряд. Для пущего быстродействия, снова кэшируем (уже тип-конвертер) по паре (TIn, TOut) -> Generator<TIn, TOut>.

Штрих последний: приводим типы, Span<TIn> -> Span<TOut>

public Span<TOut> Cast(Span<TIn> span)
{
    // Быстрый путь если спэн пуст
    if (span.IsEmpty)
        return Span<TOut>.Empty;

    // Caller ответственен за то, чтобы размеры спэнов совпадали в байтах
    if (span.Length * Unsafe.SizeOf<TIn>() % Unsafe.SizeOf<TOut>() != 0)
        throw new InvalidOperationException();

    // Загружаем спэн в первую структуру
    // Span<TIn> _input.Span = span;
    _spanSetter(span);

    // Считываем Raw
    // Raw raw = _input.Raw;
    var raw = _rawGetter();

    var newRaw = new Raw()
    {
        Pinnable = raw.Pinnable, // Вточности тот же Pinnable
        Pointer = raw.Pointer, // Идентичный сдвиг
        Length = raw.Length * Unsafe.SizeOf<TIn>() / Unsafe.SizeOf<TOut>() // Новая длина
    };

    // Загружаем новый Raw во вторую структуру
    // Raw _output.Raw = newRaw;
    _rawSetter(newRaw);

    // Вытаскиваем спэн нужного типа
    // Span<TOut> _output.Span
    return _spanGetter();
}

Вывод

Иногда — ради спортивного интереса — можно обойти некоторые ограничения языка и реализовать нестандартный функционал. Разумеется, на свой страх и риск. Стоит отметить, что динамический метод позволяет полностью отказаться от указателей и unsafe / fixed контекста, что может быть бонусом. Очевидным минусом является необходимость рефлексии и генерации типов.

Для тех, кто дочитал до конца.

Результаты наивного бенчмарка

А насколько это все быстро?
Я сравнил скорость кастов в глупом сценарии, который не отражает реальное/потенциальное использование таких кастов и спэнов, но хотя бы дает представление о скорости.

  1. Cast_Explicit использует преобразование через явно декларируемый тип, как в Методе 2. Каждый каст требует аллокации двух небольших структур и доступов к полям;
  2. Cast_IL реализует Метод 3, но каждый раз заново создает экземпляр Generator<TIn, TOut>, что приводит к постоянным поискам по словарям, после того как первый проход генерирует все типы;
  3. Cast_IL_Cached кэширует непосредственно инстанс конвертера Generator<TIn, TOut>, из-за чего в среднем оказывается быстрее, т.к. весь каст сводится к вызовам четырех делегатов;
  4. Buffer достигает аналогичного функционала, копируя побайтово исходный массив в массив байтов, после чего выполняя аналогичный пэйлоад. Целевой массив байтов всегда переиспользуется.

В качестве пэйлоада — подсчет суммы байтов в байтовом представлении части массива int[N] размера порядка N/2 со случайным сдвигом.

Из таблицы следует, что копирование данных в соседний массив оказывается быстрее, чем пляски со спэнами. Каст спэнов с помощью кэшированного конвертера не сильно отстает от жадной реализации через копирование, выигрывая у двух других реализаций за счет отсутствия аллокаций и поиска по словарям. В общем и целом, если первый вызов каста однозначно будет долгим за счет рефлексии, последующие вызовы преобразования из и в уже известные типы происходят довольно быстро. Таким образом, можно почти эффективно немножечко нарушая правила игры модифицировать байтовое представление элементов unmanaged типа без этих ваших указателей.

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362
Intel Core i7-2700K CPU 3.50GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores
  [Host] : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0
  Clr    : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0

Job=Clr  Runtime=Clr  InvocationCount=1  
UnrollFactor=1  

Method N Mean Error StdDev Median Ratio RatioSD
Cast_Explicit 100 362.2 ns 18.0967 ns 52.7888 ns 400.0 ns 1.00 0.00
Cast_IL 100 1,237.9 ns 28.5954 ns 67.4027 ns 1,200.0 ns 3.47 0.51
Cast_IL_Cached 100 522.8 ns 25.2640 ns 71.2576 ns 500.0 ns 1.46 0.27
Buffer 100 300.0 ns 0.0000 ns 0.0000 ns 300.0 ns 0.78 0.11
Cast_Explicit 1000 2,628.6 ns 54.0688 ns 64.3650 ns 2,600.0 ns 1.00 0.00
Cast_IL 1000 3,216.7 ns 49.8568 ns 38.9249 ns 3,200.0 ns 1.21 0.03
Cast_IL_Cached 1000 2,484.6 ns 44.9717 ns 37.5534 ns 2,500.0 ns 0.94 0.02
Buffer 1000 2,055.6 ns 43.9695 ns 73.4631 ns 2,000.0 ns 0.78 0.03
Cast_Explicit 1000000 2,515,157.1 ns 11,809.8538 ns 10,469.1278 ns 2,516,050.0 ns 1.00 0.00
Cast_IL 1000000 2,263,826.7 ns 23,724.4930 ns 22,191.9054 ns 2,262,000.0 ns 0.90 0.01
Cast_IL_Cached 1000000 2,265,186.7 ns 19,505.5913 ns 18,245.5422 ns 2,266,300.0 ns 0.90 0.01
Buffer 1000000 1,959,547.8 ns 39,175.7435 ns 49,544.7719 ns 1,959,200.0 ns 0.78 0.02
Cast_Explicit 100000000 255,751,392.9 ns 2,595,107.7066 ns 2,300,495.3873 ns 255,298,950.0 ns 1.00 0.00
Cast_IL 100000000 228,709,457.1 ns 527,430.9293 ns 467,553.7809 ns 228,864,100.0 ns 0.89 0.01
Cast_IL_Cached 100000000 227,966,553.8 ns 355,027.3545 ns 296,463.9203 ns 227,903,600.0 ns 0.89 0.01
Buffer 100000000 213,216,776.9 ns 1,198,565.1142 ns 1,000,856.1536 ns 213,517,800.0 ns 0.83 0.01

Acknowledgements

Спасибо JetBrains (у вас классный офис в СПБ :-)) и команде R# за отличные инструменты VS и standalone-приложение dotPeek, а также за студенческую лицензию. Спасибо BenchmarkDotNet за BenchmarkDotNet, youtube-каналам NDC Conferences и DotNext за доступ к докладам, и вам, за то что потратили время и прочитали до конца.

P.S.

Работа над ошибками

3 Уже после написания статьи я осознал проблему с тем, что динамический тип оказался не ref, и что это не совсем то, что было обещано. Решением этой проблемы (и проблемы упаковки спэна) может быть следующим. Учитывая все ограничения ref structs, можно генерировать вместо аксессоров статические методы вида

static Raw Generated_Int32.GetRaw(Span<int> span)
{
    var inst = new Generated_Int32()
    {
        Span = span
    };
    return inst.Raw;
}

Это все еще валидный код, который можно записать через Reflection.Emit. Здесь потребуется уже целая локальная переменная, но нам должен помочь ILGenerator.DeclareLocal. Добавив в пару метод

static Span<int> Generated_Int32.GetSpan(Raw raw);

и пару делегатов

delegate Raw GetRaw<T>(Span<T> span) where T : unmanaged;
delegate Span<T> GetSpan<T>(Raw raw) where T : unmanaged;

можно, я полагаю, добиться корректно работающего кода в случае ref — структур. Т.к. статические методы не требуют инстансов, генерация делегатов будет выглядеть как

var getter = type.GetMethod(@"GetRaw", BindingFlags.Static | BindingFlags.Public).CreateDelegate(typeof(GetRaw<T>), null) as GetRaw<T>;

а вызов — как

Raw raw = getter(Span<TIn>.Empty);
Raw newRaw = convert(raw);
Span<TOut> = setter(newRaw);

UPD01: Борьба с очепятками

Автор: BkmzSpb

Источник


* - обязательные к заполнению поля