Еще одно сравнение производительности С++ и C#
Навеяно вот этой статьей.
Существует три мнения относительно производительности C++ и C#.
Те кто знают (или думают что знают) C++ считают, что C++ в разы или даже на порядки быстрее.
Те кто знают C++ и C# знают, что для обычных задач быстродействие C++ не нужно, а там где нужно — можно и C#-код заоптимизировать до невозможности. Верхний предел оптимизации у C++ выше, чем у C#, но такие рекорды никому не нужны.
Те кто знают только C# — никогда не испытывали проблем с его быстродействием.
Люди из первой категории все время пытаются доказать свою правоту. При этом приводят примеры оптимизированного кода на C++ и самого пессимизированного кода на C#.
Пример «типичного» сравнения
Любой программист, который знает C# сразу увидит две ошибки:
- Вызов GC.Collect, который сводит на нет любые оптимизации сделанные в рантайме для сборки мусора.
- Использование for цикла, который гарантированно не устраняет проверки границ на каждое обращение к массиву.
При этом в реальности ни один C#-программист не напишет код с GC.Collect и очень малая часть программистов допустит ошибку в цикле for.
Какой смысл сравнивать гарантированного неэффективный код на C# даже с обычным кодом на C++? Разве что доказать свою точку зрения.
Честное сравнение
Чтобы сравнивать языки честно — надо сравнивать типовой код на обоих языках. То есть такой код, который можно встретить в программах с вероятностью больше статистической погрешности.
Для примера буду использовать ту же саму пузырьковую сортировку массива.
Тесты для C++
Для случая C++ протестирую три варианта:
- С-style массив (указатель)
- std: array
- 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#
Также сделаю три теста:
- Обычный массив
- Обычный массив с использованием unsafe (указателей)
- 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++ и использовании голых указателей. Но последнее чревато кучей ошибок.