Как избежать накладных расходов на виртуальные вызовы С#

У меня есть несколько сильно оптимизированных математических функций, выполнение которых занимает 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)] состоит в том, чтобы запретить компилятору вычислять адреса методов перед циклом, если метод был встроен.