Unsafe generic math in C#


    К сожалению, адекватно перевести название затеянного мной безобразия на русский язык оказалось не просто. С удивлением я обнаружил, что официальная документация MSDN называет "дженерики" "шаблонами" (по аналогии с C++ templates, я полагаю). В попавшемся мне на глаза 4-м издании "CLR via C#" Джеффри Рихтера, переведенном издательством "Питер", дженерики именуются "обобщениями", что гораздо лучше отражает суть понятия. В этой статье речь пойдет о небезопасных обобщенных математических операциях в C#. Учитывая, что C# не предназначен для высокопроизводительных вычислений (хотя, безусловно, на это способен, но не в состоянии тягаться с тем же C/C++), математическим операциям в BCL уделено не так много внимания. Давайте попробуем упростить работу с базовыми арифметическими типами силами C# и CLR.


    Постановка задачи


    Дисклеймер: в статье будет много фрагментов кода, часть из которых я проиллюстрирую ссылками на прекрасный ресурс SharpLab (GirtHub) авторства Андрея Щёкина.


    Большинство вычислений так или иначе сводится к базовым операциям. Сложение, вычитание (инверсия, отрицание), умножение и деление можно дополнить операциями сравнения и проверкой на равенство. Разумеется, все эти действия можно легко и просто выполнить над переменными любых базовых арифметических типов C#. Единственная проблема — C# должен знать на этапе компиляции, что операции выполняются над конкретными типами, и создается впечатление, что написать метод, который одинаково эффективно (и прозрачно) складывает два целых числа и два числа с плавающей точкой — невозможно.


    Давайте конкретизируем наши пожелания к гипотетическому обобщенному методу, выполняющему какую-либо простейшую математическую операцию:


    1. Метод должен иметь ограничения обобщенного типа, защищающие нас от попытки сложения (или умножения, деления) двух произвольных типов. Нам нужен некий generic type constraint.
    2. Для чистоты эксперимента, принимаемые и возвращаемые типы должны быть одинаковыми. Например, бинарный оператор должен иметь сигнатуру вида (T, T) => T.
    3. Метод должен быть хотя бы частично оптимизирован. Например, повсеместная упаковка (boxing) неприемлема.

    А что там у соседей?


    Давайте посмотрим на F#. Я не силен в F#, но большинство ограничений C# продиктовано ограничениями CLR, а значит F# страдает от тех же проблем. Можно попробовать объявить явный обобщенный метод сложения и обычный метод сложения и посмотреть, что система вывода типов F# скажет на это:


    let add_gen (x : 'a) (y : 'a) =
        x + y
    
    let add x y =
        x + y
    
    add_gen 5.0 6.0 |> ignore
    
    add 5.0 6.0 |> ignore

    В данном случае оба метода окажутся необобщенными, и сгенерирированный код будет идентичным. С учетом жесткости системы типов F#, где отсутствуют неявные преобразования вида int -> double, после первого вызова этих методов с параметрами типа double (в терминах C#), вызвать методы с параметрами других типов (даже с возможной потерей точности за счет преобразования типа) больше не удастся.


    Стоит отметить, что если заменить оператор + на оператор равенства =, картина становится несколько иной: оба метода превращаются в обобщенные (с точки зрения C#), а для выполнения сравнения вызывается специальный метод-хэлпер, доступный в F#.


    let eq_gen (x : 'a) (y : 'a) =
        x = y
    
    let eq x y =
        x = y
    
    eq_gen 5.0 6.0 |> ignore
    eq_gen 5 6 |> ignore
    
    eq 5.0 6.0 |> ignore
    eq 5 6 |> ignore

    Что насчет Java?


    Про Java мне говорить сложно, но, насколько я могу судить, значимые типы там отсутствуют в привычном для нас виде, но все же есть примитивные типы. Для работы с примитивами в Java есть обертки (например, ссылочный Long для примитивного by-value long), которые имеют общий базовый класс Number. Таким образом, частично обобщить операции можно иcпользуя Number, но это ссылочный тип, что вряд ли положительно скажется на производительности.


    Поправьте меня если я не прав.


    C++?


    C++ — язык для читеров.
    C++ открывает путь к таким возможностям, которые кое-кто считает… неестественными.
    Шаблоны (aka templates), в отличие от обобщений (generics), являются, в прямом смысле, шаблонами. При объявлении шаблона можно явно ограничить типы, для которых этот шаблон доступен. По этой причине, в C++ валиден, например, такой код:


    #include <iostream>
    
    template<typename T, std::enable_if_t<std::is_arithmetic<T>::value>* = nullptr>
    T Add (T left, T right)
    {
        return left + right;
    }
    
    int main()
    {
        std::cout << Add(5, 6) << std::endl;
        std::cout << Add(5.0, 6.0) << std::endl;
        // std::cout << Add("a", "b") << std::endl; Does not compile
    }

    is_arithmetic, к сожалению, допускает и char, и bool в качестве параметров. С другой стороны, char может быть эквивалентен sbyte в терминологии C#, хотя фактические размеры целочисленных типов зависят от платформы/компилятора/фазы луны.


    Языки с динамической типизацией


    Напоследок рассмотрим пару динамически типизированных (и интерпретируемых) языков, заточенных под вычисления. У таких языков обычно обобщение вычислений проблем не вызывает: если тип параметров подходит для выполнения, условно, сложения, то операция будет выполнена, иначе — падение с ошибкой.


    В Python (3.7.3 x64):


    def add (x, y):
        return x + y
    type(add(5, 6))
    # <class 'int'>
    type(add(5.0, 6.0))
    # <class 'float'>
    type(add('a', 'b')
    # <class 'str'>

    В R (3.6.1 x64)


    add <- function(x, y) x + y
    # Or typeof()
    vctrs::vec_ptype_show(add(5, 6))
    # Prototype: double
    vctrs::vec_ptype_show(add(5L, 6L))
    # Prototype: integer
    vctrs::vec_ptype_show(add("5", "6"))
    # Error in x + y : non-numeric argument to binary operator

    Обратно, в мир C#: ограничиваем обобщенный тип математической функции


    К сожалению, этого сделать мы не можем. В C# примитивные типы являются значимыми типами (by-value), т.е. структурами, которые, хоть и унаследованы от System.ObjectSystem.ValueType), не имеют между собой много общего. Естественным и логичным ограничением выглядит where T : struct. Начиная с C# 7.3 нам доступно ограничение where T : unmanaged, которое означает, что T это неуправляемый тип, не являющийся указателем и не принимающий значение null. Этим требованиям удовлетворяют, кроме необходимых нам примитивных арифметических типов, еще и char, bool, decimal, любой Enum и любая структура, все поля которой имеют такой-же unmanaged-тип. Т.е. вот такой тип пройдет проверку:


    public struct Coords<T> where T : unmanaged
    {
        public T X;
        public T Y;
    }

    Таким образом, мы не можем написать обобщенную функцию, принимающую только желаемые арифметические типы. Отсюда и Unsafe в заголовке статьи — нам придется положиться на программистов, использующих наш код. Попытка вызова гипотетического обобщенного метода T Add<T>(T left, T right) where T : unmanaged будет приводить к непредсказуемым результатам, если программист передаст в качестве аргументов объекты несовместимого типа.


    Эксперимент первый, наивный: dynamic


    dynamic является первым и очевидным инструментом, который может помочь нам решить нашу задачу. Разумеется, использовать dynamic для вычислений абсолютно бесполезно — dynamic эквивалентен object, а вызываемые методы с dynamic-переменной превращаются компилятором в монструозную рефлексию. В качестве бонуса — упаковка/распаковка наших by-value типов. Вот вам пример:


    public class Class {
        public static void Method() {
            var x = Add(5, 6);
            var y = Add(5.0, 6.0);
        }
    
        private static dynamic Add(dynamic left, dynamic right)
            => left + right;
    }

    Достаточно взглянуть на IL метода Method:


    .method public hidebysig static 
            void Method () cil managed 
        {
            // Method begins at RVA 0x2050
            // Code size 53 (0x35)
            .maxstack 8
    
            IL_0000: ldc.i4.5
            IL_0001: box [System.Private.CoreLib]System.Int32
            IL_0006: ldc.i4.6
            IL_0007: box [System.Private.CoreLib]System.Int32
            IL_000c: call object Class::Add(object, object)
            IL_0011: pop
            IL_0012: ldc.r8 5
            IL_001b: box [System.Private.CoreLib]System.Double
            IL_0020: ldc.r8 6
            IL_0029: box [System.Private.CoreLib]System.Double
            IL_002e: call object Class::Add(object, object)
            IL_0033: pop
            IL_0034: ret
        } // end of method Class::Method

    Загрузил 5, упаковал, загрузил 6, упаковал, вызвал object Add(object, object).
    Вариант явно нам не подходит.


    Эксперимент второй, "в лоб"


    Ладно, dynamic это не для нас, но ведь количество наших типов конечно, и они известны заранее. Давайте вооружимся ломом ветвлением и так и запишем: если тип наш, вычислим что-нибудь, иначе — вот вам исключение.


    public static T Add<T>(T left, T right) where T : unmanaged
    {
        if(left is int i32Left && right is int i32Right)
        {
            // ???
        }
        // ...
        throw new NotSupportedException();
    }

    Ииии тут мы натыкаемся на проблему. Если понять, с какими типами мы работаем, еще можно, применить к ним операцию — тоже, то полученный условный int нужно преобразовать в неизвестный тип T и сделать это не очень просто. Вариант return (T)(i32Left + i32Right) не компилируется — нет гарантии, что T это int (хоть мы-то знаем, что это так). Можно попробовать двойное преобразование return (T)(object)(i32Left + i32Right). Сначала сумма упаковывается, затем — распаковывается в T. Это будет работать только если типы до упаковки и после упаковки совпадают. Нельзя упаковать int, а распаковать в double, даже если существует неявное преобразование int -> double. Проблема такого кода — гигантское ветвление и обилие упаковок-распаковок, даже в if условиях. Этот вариант тоже не годится.


    Рефлексия и метаданные


    Ну, поиграли и хватит. Все же знают, что в C# существуют операторы, которые можно переопределить. Вон, есть +, -, ==, != и так далее. Все, что нам нужно — вытащить по типу T статический метод, соответствующий оператору, например, сложения — и все. Ну да, снова пара упаковок, но уже никакого ветвления и никаких проблем. Все это дело можно закэшировать по типу T и вообще всячески ускорить процесс, сведя одну математическую операцию к вызову единственного метода рефлексии. Ну как-то так:


    public static T Add<T>(T left, T right) where T : unmanaged
    {
        // Simple example without cache.
        var method = typeof(T)
            .GetMethod(@"op_Addition", new [] {typeof(T), typeof(T)})
            ?.CreateDelegate(typeof(Func<T, T, T>)) as Func<T, T, T>;
    
        return method?.Invoke(left, right) 
            ?? throw new InvalidOperationException();
    
    }

    К сожалению, это не работает. Дело в том, что у арифметических типов (но не decimal) нет такого статического метода. Все операции реализованы посредством IL-операций, таких как add. Обычной рефлексией нашу проблему не решить.


    System.Linq.Expressions


    Решение на основе Expressions описано в блоге Джона Скита вот здесь (автор — Marc Gravell).
    Идея довольно простая. Пусть у нас есть тип T, который поддерживает операцию +. Давайте создадим выражение примерно такого вида:


    (x, y) => x + y;

    После чего, закэшировав, будем его использовать. Построить такое выражение довольно легко. Нам нужно два параметра и одна операция. Так и запишем.


    private static readonly Dictionary<(Type Type, string Op), Delegate> Cache =
                new Dictionary<(Type Type, string Op), Delegate>();
    
    public static T Add<T>(T left, T right) where T : unmanaged
    {
        var t = typeof(T);
        // If op is cached by type and function name, use cached version
        if (Cache.TryGetValue((t, nameof(Add)), out var del))
            return del is Func<T, T, T> specificFunc
                ? specificFunc(left, right)
                : throw new InvalidOperationException(nameof(Add));
    
        var leftPar = Expression.Parameter(t, nameof(left));
        var rightPar = Expression.Parameter(t, nameof(right));
        var body = Expression.Add(leftPar, rightPar);
    
        var func = Expression.Lambda<Func<T, T, T>>(body, leftPar, rightPar).Compile();
    
        Cache[(t, nameof(Add))] = func;
    
        return func(left, right);
    }

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


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


    Нарушаем все правила


    А можно ли добиться чего-то еще, используя силы CLR/C#? Давайте посмотрим, какой годкод генерируют методы сложения для разных типов:


    public class Class 
    {
        public static double Add(double x, double y) => x + y;
        public static int Add(int x, int y) => x + y;  
        // Decimal only to show difference
        public static decimal Add(decimal x, decimal y) => x + y;
    }

    Соответствующий IL-код содержит один и тот же набор инструкций:


    ldarg.0
    ldarg.1
    add
    ret

    Это — тот самый op-код add, в который компилируется сложение арифметических примитивных типов. decimal в этом месте вызывает static decimal decimal.op_Addition(decimal, decimal). А что если написать метод, который будет обобщенным, но содержать именно этот IL-код? Ну, Джон Скит предупреждает, что так делать не стоит. В его случае он рассматривает все типы (включая decimal), а так же их nullable-аналоги. Это потребует довольно нетривиальных IL-операций и обязательно приведет к ошибке. Но мы все же можем попробовать реализовать базовые операции.


    К моему удивлению, Visual Studio не содержит шаблонов для IL-проектов и IL-файлов. Нельзя просто взять и описать часть кода в IL и включить в свою сборку. Естественно, open source приходит нам на помощь. Проект ILSupport содержит шаблоны IL-проектов, а так же набор инструкций, которые можно добавить в *.csproj для включения IL-кода в проект. Разумеется, описывать в IL все — довольно сложно, поэтому автор проекта использует встроенный атрибут MethodImpl с флагом ForwardRef. Этот атрибут позволяет объявить метод как extern и не описывать тело метода. Выглядит примерно так:


    [MethodImpl(MethodImplOptions.ForwardRef)]
    public static extern T Add<T>(T left, T right) where T : unmanaged;

    Следующий шаг — в файле *.il с IL-кодом написать реализацию метода:


    .method public static hidebysig !!T Add<valuetype .ctor (class [mscorlib]System.ValueType modreq ([mscorlib]System.Runtime.InteropServices.UnmanagedType)) T>(!!T left, !!T right) cil managed
    {
        .param type [1]
        .custom instance void System.Runtime.CompilerServices.IsUnmanagedAttribute::.ctor()
        = (01 00 00 00 )
        ldarg.0
        ldarg.1
        add
        ret
    }

    Нигде явно не обращаясь к типу !!T, мы предлагаем CLR сложить два аргумента и вернуть результат. Здесь отсутствуют любые проверки типов и все на совести разработчика. Удивительно, но это работает, и относительно быстро.


    Немного бенчмарка


    Наверное, честный бенчмарк был бы построен на каком-то достаточно сложном выражении, вычисление которого "в лоб" сравнивалось бы с вот этими опасными IL-методами. Я написал простой алгоритм, который суммирует квадраты заранее вычисленных и сохраненных в массив double чисел и делит конечную сумму на количество чисел. Для выполнения операции я использовал C# операторы +, * и /, как это делают здоровые люди, функции, построенные с помощью Expressions, и IL-функции.


    Результаты примерно такие:
    • DirectSum это сумма с использование стандартных операторов +, * и /;
    • BranchSum использует ветвление по типу и каст через object;
    • UnsafeBranchSum использует ветвление по типу и каст через Unsafe.As<,>();
    • ExpressionSum использует кэшируемые выражения для каждой операции (Expression);
    • UnsafeSum использует IL-небезопасный код, представленный в статье

    Пэйлоад бенчмарка — суммирование квадратов элементов предзаполненного случайным образом массива типа double и размера N с последующим делением суммы на N и ее сохранением; оптимизации включены.


    BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362
    Intel Core i7-2700K CPU 3.50GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores
    .NET Core SDK=3.1.100
      [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT
      Job-POXTAH : .NET Core 3.1.0 (CoreCLR 4.700.19.56402, CoreFX 4.700.19.56404), X64 RyuJIT
    
    Runtime=.NET Core 3.1  
    

    Method N Mean Error StdDev Ratio RatioSD
    DirectSum 1000 2.128 us 0.0341 us 0.0303 us 1.00 0.00
    BranchSum 1000 57.468 us 0.4478 us 0.3496 us 26.97 0.46
    UnsafeBranchSum 1000 72.924 us 0.4131 us 0.3864 us 34.28 0.50
    ExpressionSum 1000 144.555 us 2.5182 us 2.2323 us 67.94 1.29
    UnsafeSum 1000 5.054 us 0.0324 us 0.0303 us 2.37 0.03
    DirectSum 10000 21.174 us 0.3092 us 0.2741 us 1.00 0.00
    BranchSum 10000 573.972 us 2.9274 us 2.5951 us 27.11 0.40
    UnsafeBranchSum 10000 735.031 us 9.1016 us 8.0683 us 34.72 0.53
    ExpressionSum 10000 1,462.593 us 9.0932 us 8.0609 us 69.09 1.02
    UnsafeSum 10000 50.388 us 0.3956 us 0.3701 us 2.38 0.03
    DirectSum 100000 210.021 us 1.9832 us 1.7581 us 1.00 0.00
    BranchSum 100000 6,046.340 us 86.9740 us 77.1002 us 28.79 0.42
    UnsafeBranchSum 100000 7,406.489 us 65.7415 us 58.2782 us 35.27 0.27
    ExpressionSum 100000 14,021.642 us 189.2625 us 167.7763 us 66.77 0.88
    UnsafeSum 100000 505.551 us 2.3662 us 2.2133 us 2.41 0.03
    DirectSum 1024000 2,306.751 us 22.4173 us 20.9692 us 1.00 0.00
    BranchSum 1024000 61,643.224 us 610.3048 us 570.8795 us 26.72 0.28
    UnsafeBranchSum 1024000 75,644.639 us 494.4096 us 462.4711 us 32.80 0.39
    ExpressionSum 1024000 154,327.137 us 1,267.2469 us 1,185.3835 us 66.91 0.55
    UnsafeSum 1024000 5,295.990 us 14.9537 us 12.4871 us 2.29 0.02

    Наш небезопасный код примерно в 2.5 раза медленнее (в пересчете на одну опперацию). Связать это можно с тем фактом, что в случае вычисления "в лоб" компилятор компилирует a + b в op-код add, а в случае с небезопасным методом происходит вызов статической функции, что естественно медленнее.


    Вместо заключения: когда true != true


    Несколько дней назад я наткнулся на такой твит Джареда Парсонса:


    There are cases where the following will print "false"
    bool b =…
    if (b) Console.WriteLine(b.IsTrue());

    Это был ответ на вот эту запись, где показан код проверки bool на true, который выглядит примерно так:


    public static bool IsTrue(this bool b)
    {
        if (b == true)
            return true;
        else if (b == false)
            return false;
        else
            return !true && !false;
    }

    Проверки кажутся избыточными, да? Джаред привел контр-пример, который демонстрирует некоторые особенности поведения bool. Идея заключается в том, что bool это byte (sizeof(bool) == 1), при этом false соответствуте 0, а true соответствует 1. Пока вы не размахиваете указателями, bool ведет себя однозначно и предсказуемо. Однако, как показал Джаред, можно создать bool, используя 2 в качестве начального значения, и часть проверок выполнится некорректно:


    bool b = false;
    byte* ptr = (byte*)&b;
    *ptr = 2;

    Аналогичного эффекта мы можем добиться с помощью наших небезопасных математических операций (это не работает с Expressions):


    var fakeTrue = Subtract<bool>(false, true);
    var val = *(byte*)&fakeTrue;
    
    if(fakeTrue)
        Assert.AreNotEqual(fakeTrue, true);
    else
        Assert.Fail("Clause not entered.");

    Да-да, мы внутри true-ветки проверяем, является ли условие true, и ожидаем, что на самом деле оно не равно true. Почему это так? Если вы без проверок вычтите из 0 (=false) 1 (=true), то для byte это будет равно 255. Естественно, 255 (наш fakeTrue) не равно 1 (настоящий true), поэтому assert выполняется. Ветвление же работает по-другому.


    Происходит инверсия if: вставляется условный переход; если условие ложно, то происходит переход в точку после окончания if-блока. Проверка выполняется оператором brfalse/brfalse_S. Он сравнивает последнее значение на стэке с нулем. Если значение равно нулю, то это false, перешагиваем через if-блок. В нашем случае fakeTrue как раз и не равен нулю, поэтому проверку проходит и выполнение продолжается внутри if-блока, где мы сравниваем fakeBool с настоящим значением true и получаем отрицательный результат.


    UPD01:
    После обсуждения в комментариях с shai_hulud и blowin, добавил к бенчмаркам еще один метод, который реализует ветвление вида if(typeof(T) == typeof(int)) return (T)(object)((int)(object)left + (int)(object)right);. Несмотря на то, что JIT должен оптимизировать проверки, по крайней мере когда T это struct, работают такие методы все равно на порядок медленнее. Не очевидно, оптимизируются ли преобразования T -> int -> T, или же используется boxing/unboxing. На результаты бенчмарка флаги MethodImpl значимым образом не влияют.


    UPD02:
    xXxVano в комментариях показал пример использования ветвления по типу и кастов T <--> конкретный тип с использованием Unsafe.As<TFrom, TTo>(). По аналогии с обычным ветвлением и кастом через object, я написал три операции (сложение, умножение и деление) с ветвлением по всем арифметическим типам, после чего добавил еще один бенчмарк (UnsafeBranchSum). Несмотря на то, что все методы (кроме выражений) генерируют практически идентичный asm-код (насколько мои ограниченные познания ассембера позволяют мне судить), по неизвестной мне причине оба метода с ветвлением сильно тормозят по сравнению как с прямым суммированием (DirectSum), так и с использованием дженериков и IL-кода. У меня нет объяснения данному эффекту, тот факт, что затраичваемое время растет пропорционально N указывает на то, что существует какой-то постоянный оверхед на каждую операцию, не смотря на всю магию JIT. Этот оверхед отсутствует в IL-версии методов. В любом случае, мой небезопасный IL-вариант выигрывает хотя бы потому, что не требует написания/генерации простыней кода с ветвлением/свитчами по типу, хоть я и не могу гарантировать его корректность в 100% случаев (по крайней мере, в данный момент).
    Я вполне допускаю, что мой бенчмарк не вполне корректен, из-за чего варианты с ветвлением оказываются систематически медленнее.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 15

      0
      В шарпе так же можно ограничить тип генерика, а вот главное отличие шаблонов и генериков другое. Под каждый используемый тип в шаблоне отдельную реализацию класса, в шарпе же объекты, вне зависимости используемого в генерике подтипа, имеют один тип.
        +2

        Если я вас правильно понял, то это неверно. Каждый тип-параметр генерика, являющийся value type, получает отдельную реализацию всех членов на уровне машинного кода.

          0
          Cовершенно верно. Но это это уже jit делает, но на уровне clr это все равно один и тот же тип.
        +1

        Вариант с Linq.Expressions по идее можно сделать значительно быстрее, если вместо Add Of T в классе Adder сделать Add в классе Adder of T, и хранить делегат полученный компиляцией лямбды в static readonly поле.

          0
          Опечатка CreateDeleate, наверное, имелось ввиду CreateDelegate
            +2
            Теоретически JIT оптимизируем дженерик методы при компиляции в ассембер. Условия вроде
            if (typeof(T) != typeof(string))

            удаляются в сгенерированном ассемблере. То самый быстрый код будет через кодогенерацию всех операций на всех типах.
            что-то вроде:

            T Add(T value1, T value2)
            {
                // лишние ветки будут убраны JIT компилятором
                if (typeof(T) == typeof(int)) {
                    // боксинг тоже убирается JIT
                    return ((int)(object)value1) + ((int)(object)value2);
                } 
                if (typeof(T) == typeof(byte)) { ... }
            }

            кодогенерацию можно сделать прямо в проекте на T4.

            бенчмарк конечно я писать не буду :)
              +1
              Есть C# champion на добавление Type Classes в C#: ссылка
                +3
                Вариант return (T)(i32Left + i32Right) не компилируется — нет гарантии, что T это int (хоть мы-то знаем, что это так). Можно попробовать двойное преобразование return (T)(object)(i32Left + i32Right). Сначала сумма упаковывается, затем — распаковывается в T. Это будет работать только если типы до упаковки и после упаковки совпадают.


                Вы не правы. В Net очень известный трюк с проверкой generic тип. Результат бенчмарка

                image

                Исходный код:
                Исходный код
                [MethodImpl(MethodImplOptions.NoInlining)]
                        private static T ThrowInvalidOperation<T>() => throw new InvalidOperationException("Not support type");
                        
                        [MethodImpl(MethodImplOptions.AggressiveInlining)]
                        public static T Add<T>(T lft, T rgt) where T : struct 
                        {
                            if(typeof(T) == typeof(int))
                                return (T)(object)((int)(object)lft + (int)(object)rgt);
                            if(typeof(T) == typeof(float))
                                return (T)(object)((float)(object)lft + (float)(object)rgt);
                            if(typeof(T) == typeof(decimal))
                                return (T)(object)((decimal)(object)lft + (decimal)(object)rgt);
                            if(typeof(T) == typeof(double))
                                return (T)(object)((double)(object)lft + (double)(object)rgt);
                
                            return ThrowInvalidOperation<T>();
                        }
                    
                        [Benchmark]
                        public int GenericAdd() => Add(10, 20);
                
                        [Benchmark]
                        public int DirectAdd() => 10 + 20;
                



                В данном случае generic оказался быстрее, но это всё в пределах погрешности.
                  0

                  (сс shai_hulud)
                  Ну у меня есть два комментария по этому поводу.
                  Во-первых, оптимизация, о которой я не знал, возможно и позволит сделать вычисления такими же быстрыми, как обычный +, но потребует написания доволльно громоздкого кода или кодогенерации. Я же постарался упростить процесс написания для себя, да и мне стало интересно, как поведет себя CLR при работе с методом, тело когторого описано в IL таким топорным образом.


                  Во-вторых, интересно посмотреть на сгенерированный IL код вашего примера. DirectAdd вообще возвращает 30 и не выполняет никаких действий. Я также немного скептически отношусь к бенчмарку из одной операции — это как минимум сложно измерить из-за очень малого временного масштаба. Мне сложно поверить, что метод, возвращающий константу 30, работает медленнее метода, который, как минимум, вызывает другой метод, даже учитывая любые JIT-оптимизации (куда уж оптимальней 30-то?). Да и значение 0 ± 0 выглядит странным.


                  Для чистоты эксперимента я добавлю такую реализацию арифм. операций к своему бенчмарку и сравню поведение спорных методов в одинаковых условиях.

                  0

                  А если [MethodImpl(MethodImplOptions.AggressiveInlining)]?

                    0

                    Для IL-костылей я полагался на сторонний проект, который позволяет описывать тело метода непосредственно в IL. Для идентификации переписываемых методов используется MethodImplOptions.ForwardRef. Честно говоря, я не пробовал скомбинировать это с AgressiveInlining, мне не очень понятно, как это будет инлайниться.
                    Для подстановки IL сборка сначала собирается, потом декомпилируется в IL, дописывается код, и все собирается обратно.
                    Если будет время, я попробую и посмотрю, что изменится.

                      +1

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

                    +1
                    В своём проекте я использую такой подход:
                            [MethodImpl(MethodImplOptions.AggressiveInlining)]
                            public static T Add<T>(T left, T right)
                                where T : struct
                            {
                                if (typeof(T) == typeof(int))
                                {
                                    int sum = Unsafe.As<T, int>(ref left) + Unsafe.As<T, int>(ref right);
                                    return Unsafe.As<int, T>(ref sum);
                                }
                                // if (typeof(T) ==...
                              
                                throw new NotSupportedException();
                            }
                    


                    В release сборке jit превратит это ровно в 1 процессорную инструкцию (lea) и к тому же заинлайнит. Так что перфоманс должен быть абсолютно идентичен. Proof

                    Unsafe (System.Runtime.CompilerServices) — пакет от microsoft, специально для таких целей. Есть версия и под .Net Framework, но удобно использовать только для .Net Core.

                    P.S.: Кстати ровно такой же подход использует и сама microsoft в недрах span, например что бы сделать кастомную логику для копирования char и bool.
                      +1

                      Добавил ещё бенчмарк код тут, можете у себя проверить. Результаты у меня выглядят так:



                      То есть, что способ через Unsafe (Sum_Unsafe), что через каст в Оbject (Sum_Оbject) что просто вызов оператора + (Sum_Operator) — все дают одинаковый результат (на уровне погрешности). Но в боевом коде думаю вполне возможна ситуации когда Jit не сможет заинлайнить метод и не выкинет каст в object, тогда как unsafe не создаёт дополнительных инструкций.


                      Метод через каст экземпляра (Sum_Cast) — в ~36-43 раза медленнее чем вызов оператора.


                      Описанный вариант вызова через expressions (Sum_Expression) самый сложный и самый медленный — в ~237 раз медленнее обычного вызова (а на CoreRT и вовсе в 872 раза медленнее, вероятно стоит им тикет по этому поводу поставить). Это можно объяснить тем что на каждой итерации делается обращение к словарю и после вызов делегата.


                      Ещё набросал более оптимальную версию через expressions (Sum_Expression_NoDict). Она с кешированием, но без словаря и немного оптимизирована для инлайнинга (вынес основную логику в отдельную функцию). Она всего в 10 раз медленнее (и в ~482 раза на CoreRT), то есть примерно как virtual call вместо простого сложения.


                      P.S.: Извините за поздний ответ в теме, только заметил апдейт в статье.

                        0

                        Спасибо за бенчмарк! Я практически на 100% уверен, что я мог что-то где-то упустить.
                        Единственная фундаментальная разница — я не поленился записать полное ветвление во всех методах, основанных на проверке типов. У вас же я увидел буквально "либо int, либо Exception". Не берусь предсказать, повлияет ли большее ветвление на перфоманс, но мой бенч показывает именно это.
                        Еще один момент — это double тип, который я использую для вычислений.


                        В любом случае интересно обсудить такие хаки даже с теоретической точки зрения.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое