Еще одно сравнение производительности С++ и C#

Навеяно вот этой статьей.

Существует три мнения относительно производительности C++ и C#.

Те кто знают (или думают что знают) C++ считают, что C++ в разы или даже на порядки быстрее.
Те кто знают C++ и C# знают, что для обычных задач быстродействие C++ не нужно, а там где нужно — можно и C#-код заоптимизировать до невозможности. Верхний предел оптимизации у C++ выше, чем у C#, но такие рекорды никому не нужны.
Те кто знают только C# — никогда не испытывали проблем с его быстродействием.

Люди из первой категории все время пытаются доказать свою правоту. При этом приводят примеры оптимизированного кода на C++ и самого пессимизированного кода на C#.

Пример «типичного» сравнения


image
Любой программист, который знает C# сразу увидит две ошибки:

  1. Вызов GC.Collect, который сводит на нет любые оптимизации сделанные в рантайме для сборки мусора.
  2. Использование for цикла, который гарантированно не устраняет проверки границ на каждое обращение к массиву.


При этом в реальности ни один C#-программист не напишет код с GC.Collect и очень малая часть программистов допустит ошибку в цикле for.
Какой смысл сравнивать гарантированного неэффективный код на C# даже с обычным кодом на C++? Разве что доказать свою точку зрения.

Честное сравнение


Чтобы сравнивать языки честно — надо сравнивать типовой код на обоих языках. То есть такой код, который можно встретить в программах с вероятностью больше статистической погрешности.

Для примера буду использовать ту же саму пузырьковую сортировку массива.

Тесты для C++


Для случая C++ протестирую три варианта:

  1. С-style массив (указатель)
  2. std: array
  3. std: vector


Каждый тест будет запускаться по 100 раз и результат будет усредняться.

Код измерения
std::chrono::high_resolution_clock::duration measure(std::function f, int n = 100)
{
        auto begin = std::chrono::high_resolution_clock::now();
        for (int i = 0; i < n; i++)
        {
                f();
        }
        auto end = std::chrono::high_resolution_clock::now();
        return (end - begin) / n;
}

Код теста для C-style

Код
void c_style_sort(int *m, int n) 
{
        for (int i = 0; i < N - 1; i++)
                for (int j = i + 1; j < N; j++) {
                        if (m[i] < m[j])
                        {
                                int tmp = m[i];
                                m[i] = m[j];
                                m[j] = tmp;
                        }
                }
}

void c_style_test()
{
        int* m = new int[N];

        for (int i = 0; i < N; i++)
        {
                m[i] = i;
        }
        c_style_sort(m, N);
        delete[] m;
}



В тесте честно создается, заполняется, уничтожается массив и вызывается функция для его сортировки.Код теста для std: array

Код
void cpp_style_sort(std::array &m)
{
        auto n = m.size();
        for (int i = 0; i < n-1; i++)
                for (int j = i + 1; j < n; j++) {
                        if (m[i] < m[j])
                        {
                                int tmp = m[i];
                                m[i] = m[j];
                                m[j] = tmp;
                        }
                }
}

void cpp_style_test()
{
        std::array m;

        for (int i = 0; i < N; i++)
        {
                m[i] = i; 
        }
        cpp_style_sort(m);
}


Код почти такой же как в первом случае, но теперь явно не освобождаем память, а отдаем все на откуп автоматической деструкции.

Кто знает C++ уже поняли, что std: array не вызывает аллокаций, а сам массив хранится в теле класса, то есть в стеке в этом примере. std: array должен стать однозначным лидером в этом забеге.

Код теста для std: vector

Код
void vector_sort(std::vector &m)
{
        auto n = m.size();

        for (int i = 0; i < n - 1; i++)
                for (int j = i + 1; j < n; j++) {
                        if (m[i] < m[j])
                        {
                                int tmp = m[i];
                                m[i] = m[j];
                                m[j] = tmp;
                        }
                }
}

void vector_test()
{
        std::vector m;
        m.reserve(N);

        for (int i = 0; i < N; i++)
        {
                m.push_back(i);
        }
        vector_sort(m);
}


Код полностью аналогичен варианту с std: array. Но std: vector — изменяемый массив, в отличие от std: array. Поэтому vector использует динамическую память для хранения массива и должен честно проверять выход за границы.

Тесты для C#


Также сделаю три теста:

  1. Обычный массив
  2. Обычный массив с использованием unsafe (указателей)
  3. System.Collections.Generic.List


В отличие от «типичного» подхода сравнения производительности, я не буду вызывать GC.Collect, а положусь на рантайм.

Тесты будут прогоняться многократно, поэтому уборки мусора будут случаться и учитываться в замерах.

Код измерения
        static long Measure(Action f, int n = 100)
        {
            var sw = System.Diagnostics.Stopwatch.StartNew();
            for (int i = 0; i < n; i++)
            {
                f();
            }
            return sw.ElapsedMilliseconds / n;
        }

Код теста для обычного массива

Код
static void ArrayTest()
{
    var m = new int[N];
    for (int i = 0; i < m.Length; i++)
    {
        m[i] = i;
    }
    ArraySort(m);

}

static void ArraySort(int[] m)
{
    for (int i = 0; i < m.Length - 1; i++)
        for (int j = i + 1; j < m.Length; j++)
        {
            if (m[i] < m[j])
            {
                int tmp = m[i];
                m[i] = m[j];
                m[j] = tmp;
            }
        }
}


Очень важный момент — в ограничении цикла for стоит m.Length (минус константа). Такой паттерн определяется JIT и устраняет проверки на границу массива.Код теста для unsafe

Код
static unsafe void UnsafeTest()
{
    var m = new int[N];
    fixed(int* ptr = &m[0])
    {
        for (int i = 0; i < N; i++)
        {
            ptr[i] = i;
        }
        UnsafeSort(ptr, N);
    }
}

static unsafe void UnsafeSort(int* m, int n)
{
    for (int i = 0; i < n - 1; i++)
        for (int j = i + 1; j < n; j++)
        {
            if (m[i] < m[j])
            {
                int tmp = m[i];
                m[i] = m[j];
                m[j] = tmp;
            }
        }
}

Сортировка выглядит так же, только используются указатели, и гарантированно никаких проверок (и никаких оптимизаций). Я не стал делать тест с fixed array, потому что в реальности его не встретишь.Код теста для List

Код
        static void ListTest()
        {
            var m = new List(N);
            for (int i = 0; i < N; i++)
            {
                m.Add(i);
            }
            ListSort(m);

        }

        static void ListSort(List m)
        {
            var n = m.Count;
            for (int i = 0; i < n - 1; i++)
                for (int j = i + 1; j < n; j++)
                {
                    if (m[i] < m[j])
                    {
                        int tmp = m[i];
                        m[i] = m[j];
                        m[j] = tmp;
                    }
                }
        }

В отличие от обычного массива, для List нет оптимизаций в JIT, поэтому значение длины списка храним в переменной.

Результаты


Я компилировал код в Visual Studio 2015, запускал под .NET Framework 4.6. Везде настройки Release, по умолчанию.

Получил вот такие результат:

Тест x86 x64
(С++) С-style 55ms 55ms
(С++) std: array 0ms (52ms) 65ms
(С++) std: vector 100ms 65ms
(C#) массив 67ms 90ms
(C#) unsafe массив 63ms 105ms
(C#) List 395ms 390ms

В режиме x86 оптимизатор полностью выкинул сортировку для std: array, поэтому получилось 0. В реальности работает чуть быстрее, чем C-style массив за счет отсутствия аллокаций.

Выводы


  • Для обоих языков идиоматичный код — самый эффективный (вообще странно если бы это было не так)
  • C# медленнее C++ в таких задачах медленнее на 20%-50% (это верняя граница)
  • Для x64 оптимизировать надо отдельно (очевидно, но все же)


Код можно найти тут — github.com/gandjustas/PerfTestCSharpVsCPP

Что осталось «за кадром»


В C# массив — ссылочный тип. Поэтому без проблем можно передавать в любую функцию. В C++ все контейнеры ведут себя как «размерные» типы и копируют все содержимое при передаче в качестве параметра. Нужно очень внимательно писать код для C++, чтобы не копировать лишний раз массивы. Зачастую придется прибегать к умным указателям, которые несут дополнительный оверхед.
В большом приложении такой оверхед сожрет весь выигрыш от использования C++.
Заметно выиграть можно только на задачах с кучей математики, в которой компилятор C++ традиционно силен, или при отказе от идиом C++ и использовании голых указателей. Но последнее чревато кучей ошибок.

© Habrahabr.ru