C#
— невероятно гибкий язык. На нем можно писать не только бэкэнд или десктопные приложения. Я использую C#
для работы, в том числе, и с научными данными, которые накладывают определенные требования на инструменты, доступные в языке. Хотя netcore
захватывает повестку дня (учитывая, что после netstandard2.0
большинство фич как языков, так и рантайма, не бэк-портируются в netframework
), я продолжаю работать и с легаси-проектами.
В этой статье я рассматриваю одно неочевидное (но, наверное, желаемое?) применение Span<T>
и отличие реализации Span<T>
в netframework
и netcore
из-за особенностей clr
.
Фрагменты кода в данной статье ни в коем случае не предназначены для использования в реальных проектах.
Предлагаемое решение (надуманной?) проблемы — это, скорее, proof-of-concept.
В любом случае, реализуя подобное в своем проекте, вы делаете это на свой страх и риск.
Я абсолютно уверен, что где-то, в каком-то случае это обязательно выстрелит кому-то в колено.
Обход типобезопасности в C#
вряд ли приводит к чему-то хорошему.
По очевидным причинам, я не тестировал данный код во всех возможных ситуациях, однако предварительные результаты выглядят многообещающими.
А зачем мне вообще Span<T>
?
Спэн позволяет работать с массивами unmanaged
-типов в более удобной форме, уменьшая количество необходимых аллокаций. Несмотря на тот факт, что поддержка спэнов в BCL
netframework
практически полностью отсутствует, несколько инструментов можно получить, используя System.Memory
, System.Buffers
и System.Runtime.CompilerServices.Unsafe
.
Использование спэнов в моем легаси-проекте ограничено, однако я нашел им неочевидное применение, попутно наплевав на безопасность типов.
Что же это за применение? В своем проекте я работаю с данными, получаемыми с научного инструмента. Это изображения, которые, в общем случае представляют собой массив T[]
, где T
это один из unmanaged
примитивных типов, например Int32
(он же int
). Для корректной сериализации этих изображений на диск, мне необходимо поддерживать невероятно неудобный легаси-формат, который был предложен в 1981-м, и с тех пор слабо поменялся. Главная проблема этого формата — он BigEndian. Таким образом, чтобы записать (или прочитать) несжатый массив T[]
, нужно поменять endianess каждого элемента. Тривиальная задача.
Какие можно предложить очевидные решения?
- Итерируем по массиву
T[]
, вызываемBitConverter.GetBytes(T)
, разворачиваем эти несколько байт, копируем в целевой массив. - Итерируем по массиву
T[]
, выполняем махинации видаnew byte[] {(byte)((x & 0xFF00) >> 8), (byte)(x & 0x00FF)};
(должно работать на двухбайтовых типах), пишем в целевой массив. - * Но ведь
T[]
это массив? Элементы находятся подряд, да? Значит можно пойти во все тяжкие, напримерBuffer.BlockCopy(intArray, 0, byteArray, 0, intArray.Length * sizeof(int));
. Метод копирует массив в массив игнорируя проверку типов. Нужно лишь не промахнуться с границами и аллокацией. Перемешиваем байты уже в результате. - * Говорят, что
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);
. Что до @this
2, то это инстанс нашего динамического типа, созданный, разумеется, через 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
, ссылающиеся на соответствующие (закэширвоанные) динамические типы, после чего, создает по одному объекту каждого типа, и генерирует четыре дженерик-делегата, а именно
void SetSpan(Span<TIn> span)
чтобы записать исходный спэн в структуруRaw GetRaw()
чтобы считать содержимое спэна какRaw
-структуруvoid SetRaw(Raw raw)
чтобы записать модифицированнуюRaw
структуру во второй объектSpan<TOut> GetSpan()
чтобы вернуть спэн желаемого типа с корректно выставленными и пересчитанными полями.
Интересно что инстансы динамических типов нужно создать один раз. При создании делегата ссылка на эти объекты передается как параметр @this
. Здесь происходит нарушение правил. Activator.CreateInstance
возвращает object
. По всей видимости связано это с тем, что сам по себе динамический тип не получился ref
-like (type.IsByRef
Like == 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
контекста, что может быть бонусом. Очевидным минусом является необходимость рефлексии и генерации типов.
Для тех, кто дочитал до конца.
А насколько это все быстро?
Я сравнил скорость кастов в глупом сценарии, который не отражает реальное/потенциальное использование таких кастов и спэнов, но хотя бы дает представление о скорости.
Cast_Explicit
использует преобразование через явно декларируемый тип, как в Методе 2. Каждый каст требует аллокации двух небольших структур и доступов к полям;Cast_IL
реализует Метод 3, но каждый раз заново создает экземплярGenerator<TIn, TOut>
, что приводит к постоянным поискам по словарям, после того как первый проход генерирует все типы;Cast_IL_Cached
кэширует непосредственно инстанс конвертераGenerator<TIn, TOut>
, из-за чего в среднем оказывается быстрее, т.к. весь каст сводится к вызовам четырех делегатов;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 |
Спасибо 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: Борьба с очепятками