Comments 15
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 тип. Результат бенчмарка
Исходный код:
[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
, дописывается код, и все собирается обратно.
Если будет время, я попробую и посмотрю, что изменится.
[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
тип, который я использую для вычислений.
В любом случае интересно обсудить такие хаки даже с теоретической точки зрения.
Unsafe generic math in C#