Производительность указателей на функции С++/CLI по сравнению с делегатами .NET

Для моего проекта С++/CLI я просто попытался измерить стоимость указателей на объекты С++/CLI и .NET.

Я ожидал, что указатели на функции С++/CLI быстрее, чем .NET делегаты. Таким образом, мой тест отдельно подсчитывает количество вызовов делегата .NET и указателя встроенной функции в течение 5 секунд.

Результаты

Теперь результаты были (и до сих пор) шокирующими меня:

  • делегат .NET: выполнение 910M с результатом 152080413333030 в 5003 мс
  • Указатель функций: 347M исполнение с результатом 57893422166551 в 5013ms

Это означает, что использование указателя функции С++/CLI почти в 3 раза медленнее, чем использование управляемого делегата из кода С++/CLI. Как это может быть? Я должен использовать управляемые конструкции, когда дело доходит до использования интерфейсов, делегатов или абстрактных классов в критичных для производительности разделах?

Код проверки

Функция, вызываемая непрерывно:

__int64 DoIt(int n, __int64 sum)
{
    if ((n % 3) == 0)
        return sum + n;
    else
        return sum + 1;
}

Код, вызывающий метод, пытается использовать все параметры, а также возвращаемое значение, поэтому ничто не оптимизируется (надеюсь). Здесь код (для делегатов .NET):

__int64 executions;
__int64 result;
System::Diagnostics::Stopwatch^ w = gcnew System::Diagnostics::Stopwatch();

System::Func<int, __int64, __int64>^ managedPtr = gcnew System::Func<int, __int64, __int64>(&DoIt);
w->Restart();
executions = 0;
result = 0;
while (w->ElapsedMilliseconds < 5000)
{
    for (int i=0; i < 1000000; i++)
        result += managedPtr(i, executions);
    executions++;
}
System::Console::WriteLine(".NET delegate:       {0}M executions with result {2} in {1}ms", executions, w->ElapsedMilliseconds, result);

Подобно вызову делегата .NET, используется указатель на функцию С++:

typedef __int64 (* DoItMethod)(int n, __int64 sum);

DoItMethod nativePtr = DoIt;
w->Restart();
executions = 0;
result = 0;
while (w->ElapsedMilliseconds < 5000)
{
    for (int i=0; i < 1000000; i++)
        result += nativePtr(i, executions);
    executions++;
}
System::Console::WriteLine("Function pointer:    {0}M executions with result {2} in {1}ms", executions, w->ElapsedMilliseconds, result);

Дополнительная информация

  • Скомпилирован с Visual Studio 2012
  • .NET Framework 4.5 была нацелена
  • Сборка релиза (количество показов остается пропорциональным для отладочных построек)
  • Соглашение о вызове __stdcall (__fastcall не разрешено, когда проект компилируется с поддержкой CLR)

Все выполненные тесты:

  • Виртуальный метод .NET: выполнение 1025M с результатом 171358304166325 в 5004 м.
  • делегат .NET: выполнение 910M с результатом 152080413333030 в 5003 мс
  • Виртуальный метод: 336M исполнение с результатом 56056335999888 в 5006 м.
  • Указатель функций: 347M исполнение с результатом 57893422166551 в 5013ms
  • Функциональный вызов: 1459M исполнения с результатом 244230520832847 в 5001ms
  • Встроенная функция: исполнение 1385M с результатом 231791984166205 в 5000 мс

Прямой вызов "DoIt" представлен здесь "Функциональный вызов", который, как представляется, встраивается компилятором, поскольку нет (значимой) разницы в количествах выполнения по сравнению с вызовом встроенной функции.

Вызовы для виртуальных методов С++ являются "медленными" в качестве указателя функции. Виртуальный метод управляемого класса (класс ref) выполняется так же быстро, как делегат .NET.

Update: Я немного углубился, и кажется, что для тестов с неуправляемыми функциями переход на собственный код происходит каждый раз, когда вызывается функция DoIt. Поэтому я завернул внутренний цикл в другую функцию, которую я заставил скомпилировать неуправляемым:

#pragma managed(push, off)
__int64 TestCall(__int64* executions)
{
    __int64 result = 0;
    for (int i=0; i < 1000000; i++)
            result += DoItNative(i, *executions);
    (*executions)++;
    return result;
}
#pragma managed(pop)

Кроме того, я протестировал std:: function следующим образом:

#pragma managed(push, off)
__int64 TestStdFunc(__int64* executions)
{
    __int64 result = 0;
    std::function<__int64(int, __int64)> func(DoItNative);
    for (int i=0; i < 1000000; i++)
        result += func(i, *executions);
    (*executions)++;
    return result;
}
#pragma managed(pop)

Теперь новые результаты:

  • Функциональный вызов: 2946M исполнение с результатом 495340439997054 в 5000 мс
  • std:: function: 160M исполнение с результатом 26679519999840 в 5018ms

std:: function немного разочаровывает.

Ответ 1

Вы видите стоимость "двойного thunking". Основная проблема с вашей функцией DoIt() заключается в том, что она компилируется как управляемый код. Вызов делегата выполняется очень быстро, он не имеет возможности переходить от управляемого к управляемому коду через делегат. Однако указатель функции медленный, компилятор автоматически генерирует код для первого переключения из управляемого кода в неуправляемый код и выполняет вызов через указатель функции. Затем заканчивается заглушка, которая переключается с неуправляемого кода обратно на управляемый код и вызывает DoIt().

Предположительно, что вы действительно хотели измерить, был вызов собственного кода. Используйте #pragma для принудительного создания DoIt() как машинного кода, например:

#pragma managed(push, off)
__int64 DoIt(int n, __int64 sum)
{
    if ((n % 3) == 0)
        return sum + n;
    else
        return sum + 1;
}
#pragma managed(pop)

Теперь вы увидите, что указатель функции быстрее, чем делегат