Как избежать накладных расходов на виртуальные вызовы С#
У меня есть несколько сильно оптимизированных математических функций, выполнение которых занимает 1-2 nanoseconds
. Эти функции вызываются сотни миллионов раз в секунду, поэтому проблемы с вызовами вызывают беспокойство, несмотря на и без того отличную производительность.
Чтобы обеспечить возможность сопровождения программы, классы, предоставляющие эти методы, наследуют интерфейс IMathFunction
, так что другие объекты могут напрямую хранить определенную математическую функцию и использовать ее при необходимости.
public interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
public SomeObject
{
// Note: There are cases where this is mutable
private readonly IMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
Этот интерфейс вызывает огромные накладные расходы по сравнению с прямым вызовом из-за того, как его использует потребительский код. Прямой вызов занимает 1-2 нс, а вызов виртуального интерфейса - 8-9 нс. Очевидно, что наличие интерфейса и его последующая трансляция виртуального вызова является узким местом для этого сценария.
Я хотел бы сохранить и ремонтопригодность и производительность, если это возможно. Есть ли способ разрешить виртуальную функцию для прямого вызова, когда создается экземпляр объекта, чтобы все последующие вызовы могли избежать накладных расходов? Я предполагаю, что это будет связано с созданием делегатов с IL, но я не знаю, с чего начать.
Ответы
Ответ 1
Так что это имеет очевидные ограничения и не должно использоваться постоянно, где бы у вас ни был интерфейс, но если у вас есть место, где перфом действительно нужно максимизировать, вы можете использовать дженерики:
public SomeObject<TMathFunction> where TMathFunction: struct, IMathFunction
{
private readonly TMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
И вместо передачи интерфейса передайте свою реализацию как TMathFunction. Это позволит избежать поиска vtable из-за интерфейса, а также позволит встроить.
Обратите внимание, что использование struct
здесь важно, так как дженерики будут иначе обращаться к классу через интерфейс.
Некоторая реализация:
Я сделал простую реализацию IMathFunction для тестирования:
class SomeImplementationByRef : IMathFunction
{
public double Calculate(double input)
{
return input + input;
}
public double Derivate(double input)
{
return input * input;
}
}
... а также структурная версия и абстрактная версия.
Итак, вот что происходит с версией интерфейса. Вы можете видеть, что это относительно неэффективно, потому что он выполняет два уровня косвенности:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980020h ; load vtable address of the IMathFunction.Calculate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Calculate function which will call the actual Calculate via vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980028h ; load vtable address of the IMathFunction.Derivate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Derivate function which will call the actual Derivate via vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
Здесь абстрактный класс. Это немного более эффективно, но лишь незначительно:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+20h] ; call Calculate via offset 0x20 of vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+28h] ; call Derivate via offset 0x28 of vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
Таким образом, и интерфейс, и абстрактный класс в значительной степени зависят от целевого прогнозирования ветвления для достижения приемлемой производительности. Даже в этом случае вы можете увидеть гораздо больше, так что лучший вариант все еще относительно медленный, а худший случай - остановленный конвейер из-за неправильного прогноза.
И, наконец, здесь общая версия со структурой. Вы можете видеть, что это значительно более эффективно, потому что все полностью встроено, поэтому прогноз ветвления не используется. У него также есть приятный побочный эффект - удаление большей части управления стека/параметров, которое было там, поэтому код становится очень компактным:
return obj.SomeWork(input, step);
push rax
vzeroupper
movsx rax,byte ptr [rcx+8]
vmovaps xmm0,xmm1
vaddsd xmm0,xmm0,xmm1 ; Calculate - got inlined
vmulsd xmm1,xmm1,xmm1 ; Derivate - got inlined
vmulsd xmm1,xmm1,xmm2 ; dv * step
vsubsd xmm0,xmm0,xmm1 ; f -
add rsp,8
ret
Ответ 2
Я бы назначил методы для делегатов. Это позволяет вам все равно программировать на интерфейсе, избегая разрешения метода интерфейса.
public SomeObject
{
private readonly Func<double, double> _calculate;
private readonly Func<double, double> _derivate;
public SomeObject(IMathFunction mathFunction)
{
_calculate = mathFunction.Calculate;
_derivate = mathFunction.Derivate;
}
public double SomeWork(double input, double step)
{
var f = _calculate(input);
var dv = _derivate(input);
return f - (dv * step);
}
}
В ответ на комментарий @CoryNelson я провел тесты, чтобы увидеть, как это на самом деле влияет. Я запечатал класс функций, но это, кажется, не имеет никакого значения.
Результаты теста (среднее время 100 миллионов итераций в нс) с пустым временем метода, вычтенным в фигурных скобках:
Пустой метод работы: 1,48
Интерфейс: 5,69 (4,21)
Делегаты: 5,78 (4,30)
Запечатанный класс: 2.10 (0.62)
Класс: 2,12 (0,64)
Время версии делегата примерно такое же, как и для версии интерфейса (точное время варьируется от выполнения теста к выполнению теста). В то время как работа с классом примерно в 6,8 раза быстрее (сравнение времени минус время пустого метода работы)! Это означает, что мое предложение по работе с делегатами не помогло!
Что меня удивило, так это то, что я ожидал гораздо большего времени выполнения для версии интерфейса. Поскольку этот вид теста не представляет точный контекст кода OP, его валидность ограничена.
static class TimingInterfaceVsDelegateCalls
{
const int N = 100_000_000;
const double msToNs = 1e6 / N;
static SquareFunctionSealed _mathFunctionClassSealed;
static SquareFunction _mathFunctionClass;
static IMathFunction _mathFunctionInterface;
static Func<double, double> _calculate;
static Func<double, double> _derivate;
static TimingInterfaceVsDelegateCalls()
{
_mathFunctionClass = new SquareFunction();
_mathFunctionClassSealed = new SquareFunctionSealed();
_mathFunctionInterface = _mathFunctionClassSealed;
_calculate = _mathFunctionInterface.Calculate;
_derivate = _mathFunctionInterface.Derivate;
}
interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
sealed class SquareFunctionSealed : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
class SquareFunction : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
public static void Test()
{
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < N; i++) {
double result = SomeWorkEmpty(i);
}
stopWatch.Stop();
double emptyTime = stopWatch.ElapsedMilliseconds * msToNs;
Console.WriteLine($"Empty Work method: {emptyTime:n2}");
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkInterface(i);
}
stopWatch.Stop();
PrintResult("Interface", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkDelegate(i);
}
stopWatch.Stop();
PrintResult("Delegates", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClassSealed(i);
}
stopWatch.Stop();
PrintResult("Sealed Class", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClass(i);
}
stopWatch.Stop();
PrintResult("Class", stopWatch.ElapsedMilliseconds, emptyTime);
}
private static void PrintResult(string text, long elapsed, double emptyTime)
{
Console.WriteLine($"{text}: {elapsed * msToNs:n2} ({elapsed * msToNs - emptyTime:n2})");
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkEmpty(int i)
{
return 0.0;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkInterface(int i)
{
double f = _mathFunctionInterface.Calculate(i);
double dv = _mathFunctionInterface.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkDelegate(int i)
{
double f = _calculate(i);
double dv = _derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClassSealed(int i)
{
double f = _mathFunctionClassSealed.Calculate(i);
double dv = _mathFunctionClassSealed.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClass(int i)
{
double f = _mathFunctionClass.Calculate(i);
double dv = _mathFunctionClass.Derivate(i);
return f - (dv * 12.34534);
}
}
Идея [MethodImpl(MethodImplOptions.NoInlining)]
состоит в том, чтобы запретить компилятору вычислять адреса методов перед циклом, если метод был встроен.