Pull to refresh

Comments 15

В шарпе так же можно ограничить тип генерика, а вот главное отличие шаблонов и генериков другое. Под каждый используемый тип в шаблоне отдельную реализацию класса, в шарпе же объекты, вне зависимости используемого в генерике подтипа, имеют один тип.
UFO just landed and posted this here
Cовершенно верно. Но это это уже jit делает, но на уровне clr это все равно один и тот же тип.
UFO just landed and posted this here
Опечатка CreateDeleate, наверное, имелось ввиду CreateDelegate
Теоретически 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.

бенчмарк конечно я писать не буду :)
Вариант 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 оказался быстрее, но это всё в пределах погрешности.

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


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


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

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

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

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

В своём проекте я использую такой подход:
        [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.

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



То есть, что способ через 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.: Извините за поздний ответ в теме, только заметил апдейт в статье.

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


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

Sign up to leave a comment.

Articles