Pull to refresh

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

Website development *C++ *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<void()> 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<int, N> &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<int, N> 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<int> &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<int> 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<int>(N);
            for (int i = 0; i < N; i++)
            {
                m.Add(i);
            }
            ListSort(m);

        }

        static void ListSort(List<int> 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++ и использовании голых указателей. Но последнее чревато кучей ошибок.

Update


По просьбам читателей изменил и дополнил тесты:
  • Использовал std::swap вместо ручного обмена в C++ коде
  • Сделал вызов .at для вектора вместо [], чтобы он проверял границы в релизной сборке
  • Добавил проект с .NET Native

Результаты получились такие:
Тест x86 x64
(С++) С-style 60ms 52ms
(С++) std::array 51ms 60ms
(С++) std::vector 147ms 81ms
(C#) массив 67ms 90ms
(C#) unsafe массив 63ms 105ms
(C#) List 395ms 390ms
(C# + .NET Native) массив 62ms 59ms
(C# + .NET Native) unsafe массив 63ms 52ms
(C# + .NET Native) List 274ms 282ms

Получается в .NET Native быстродействие такое же, как у C++.
Из-за тормозов в дебаге для std::swap отлаживать стало невозможно.

Исходники там же: github.com/gandjustas/PerfTestCSharpVsCPP
Tags:
Hubs:
Total votes 64: ↑46 and ↓18 +28
Views 43K
Comments Comments 334