[recovery mode] Сравнение производительности С++ и C#

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

Например, сложно поспорить с тем, что код C# может работать быстрее за счет оптимизации под платформу во время JIT компиляции. Или например с тем, что ядро .Net Framework само по себе очень хорошо оптимизировано.

С другой стороны, весомым аргументом является то, что С++ компилируется непосредственно в машинный код и работает с минимально возможным количеством хелперов и прослоек.

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

Попадались и утверждения о том, что код на С++ примерно в десять раз быстрее кода на С#.

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

Тест, который выполнен в этой статье

Мне хотелось выполнить самый примитивный тест, который покажет разницу между языками на микро-уровне. В тесте пройдем полный цикл операций с данными, создание контейнера, заполнение, обработка и удаление, т.е. как обычно и бывает в приложениях.
Работать будем с данными типа int, дабы сделать их обработку максимально идентичной. Сравнивать будем только релизные билды дефолтной конфигурации используя Visual Studio 2010.

Код будет выполнять следующие действия:
1. Аллоцирование массива\контейнера
2. Заполнение массива\контейнера числами по возрастанию
3. Сортировка массива\контейнера методом пузырька по убыванию (метод выбран самый простой, по скольку мы не сравниваем методы сортировки, а средства реализации)
4. Удаление массива\контейнера

Код была написан несколькими альтернативными методами, отличающимися различными типами контейнеров и методами их аллокации. В самой статье приведу лишь примеры кода, которые как правило, работали максимально быстро для каждого из языков. Остальные же примеры, со вставками для подсчета скорости выполнения,- полностью можно увидеть тут.

Код теста

С++ HeapArray С# HeapArray fixed tmp
5c1b1fa5f5dc4ef98b0d34825128835a.png c317dd0925e94bac980394bd1f0207a9.png


Как вы можете убедиться, код достаточно простой и почти идентичный. Поскольку в C# нельзя явно выполнить удаление, время выполнения которого мы хотим измерить, вместо удаления будем использовать items = null; GC.Collect (); при условии что ничего кроме контейнера мы (во всем нашем примере) не создавали, GC.Collect удалить должен бы тоже только контейнер, поэтому думаю это достаточно адекватная замена delete[] items.

Объявление int tmp; за циклом в случае C# экономит время, поэтому рассмотрена именно такая вариация теста для случая C#.

На разных машинах получались разные результаты данного теста (видимо в силу разницы архитектур), однако разницу в производительности кода результаты измерений позволяют оценить.

В измерениях, для подсчета времени выполнения кода, был использован QueryPerformanceCounter, измерялось «время» создания, заполнения, сортировки и удаления на тестовых платформах формах получились следующие результаты:

df5bda6ba8a34740adc6441fd38e37cc.png

Из таблиц видно что:
1. Cамая быстрая С# реализация работает медленнее самой быстрой C++ реализации на 30–60% (в зависимости от платформы)
2. Разброс между самой быстрой и самой медленной С++ реализацией 1–65% (в зависимости от платформы)
3. Самая медленная (из рассмотренных конечно) реализация на С#, медленнее самой медленной С++ реализации примерно в 4 раза
4. Больше всего времени занимает этап сортировки (по сему, в дальнейшем рассмотрим его более детально)

Еще стоит обратить внимание на то, что std: vector является медленным контейнером на старых платформах, однако вполне быстрым на современных. А также на то, что время «удаления» в случае первого .Net теста несколько выше, видимо из-за того что кроме тестовых данных удаляются еще какие-то сущности.

Причина разницы производительности С++ и С# кода

Давайте посмотрим на код, который выполняется процессором в каждом случае. Для этого возьмем код сортировки из самых быстрых примеров и посмотрим во что он компилируется, смотреть будем используя отладчик Visual Studio 2010 и режим disassembly, в результате для сортировки увидим следующий код:

С++ С#
for (int i=0; i<10000;i++)
00F71051 xor ebx, ebx
00F71053 mov esi, edi
for (int j=i; j<10000;j++)
00F71055 mov eax, ebx
00F71057 cmp ebx,2710h
00F7105D jge HeapArray+76h (0F71076h)
00F7105F nop
{
if (items[i] < items[j])

00F71060 mov ecx, dword ptr [edi+eax*4]
00F71063 mov edx, dword ptr [esi]
00F71065 cmp edx, ecx
00F71067 jge HeapArray+6Eh (0F7106Eh)
{
int tmp = items[j];
items[j] = items[i];

00F71069 mov dword ptr [edi+eax*4], edx
items[i] = tmp;
00F7106C mov dword ptr [esi], ecx
for (int j=i; j<10000;j++)
00F7106E inc eax
00F7106F cmp eax,2710h
00F71074 jl HeapArray+60h (0F71060h)

for (int i=0; i<10000;i++)
00F71076 inc ebx
00F71077 add esi,4
00F7107A cmp ebx,2710h
00F71080 jl HeapArray+55h (0F71055h)
}
}

int tmp;
for (int i = 0; i < 10000; i++)

000000ac xor edx, edx
000000ae mov dword ptr [ebp-38h], edx
000000b1 nop
000000b2 jmp 0000015C
for (int j = i; j < 10000; j++)
000000b7 mov eax, dword ptr [ebp-38h]
000000ba mov dword ptr [ebp-3Ch], eax
000000bd nop
000000be jmp 0000014C
{
if (items[i] < items[j])

000000c3 mov eax, dword ptr [ebp-38h]
000000c6 mov edx, dword ptr [ebp-58h]
000000c9 cmp eax, dword ptr [edx+4]
000000cc jb 000000D3
000000ce call 736D3D61
000000d3 mov eax, dword ptr [edx+eax*4+8]
000000d7 mov edx, dword ptr [ebp-3Ch]
000000da mov ecx, dword ptr [ebp-58h]
000000dd cmp edx, dword ptr [ecx+4]
000000e0 jb 000000E7
000000e2 call 736D3D61
000000e7 cmp eax, dword ptr [ecx+edx*4+8]
000000eb jge 00000149
{
tmp = items[j];

000000ed mov eax, dword ptr [ebp-3Ch]
000000f0 mov edx, dword ptr [ebp-58h]
000000f3 cmp eax, dword ptr [edx+4]
000000f6 jb 000000FD
000000f8 call 736D3D61
000000fd mov eax, dword ptr [edx+eax*4+8]
00000101 mov dword ptr [ebp-34h], eax
items[j] = items[i];
00000104 mov eax, dword ptr [ebp-38h]
00000107 mov edx, dword ptr [ebp-58h]
0000010a cmp eax, dword ptr [edx+4]
0000010d jb 00000114
0000010f call 736D3D61
00000114 mov eax, dword ptr [edx+eax*4+8]
00000118 mov dword ptr [ebp-4Ch], eax
0000011b mov eax, dword ptr [ebp-3Ch]
0000011e mov edx, dword ptr [ebp-58h]
00000121 cmp eax, dword ptr [edx+4]
00000124 jb 0000012B
00000126 call 736D3D61
0000012b mov ecx, dword ptr [ebp-4Ch]
0000012e mov dword ptr [edx+eax*4+8], ecx
items[i] = tmp;
00000132 mov eax, dword ptr [ebp-38h]
00000135 mov edx, dword ptr [ebp-58h]
00000138 cmp eax, dword ptr [edx+4]
0000013b jb 00000142
0000013d call 736D3D61
00000142 mov ecx, dword ptr [ebp-34h]
00000145 mov dword ptr [edx+eax*4+8], ecx
for (int j = i; j < 10000; j++)
00000149 inc dword ptr [ebp-3Ch]
0000014c cmp dword ptr [ebp-3Ch],2710h
00000153 jl 000000C3
for (int i = 0; i < 10000; i++)
00000159 inc dword ptr [ebp-38h]
0000015c cmp dword ptr [ebp-38h],2710h
00000163 jl 000000B7
}
}


Что мы тут можем увидеть? Если организация цикла более менее идентична, за исключением недостаточного использования регистров, то уже работа с элементами массива вызывает вопросы.

Почему для С# items[j] = items[i]; компилируется в 14 инструкций, среди которых 2 call неизвестного времени выполнения, а для С++ этот же код компилируется в 3 инструкции?

По всей видимости, дополнительный код необходим для проверки выхода за границы массива, подсчета ссылок, возможно и каких-то других дополнительных проверок, ведь это же в конце-концов managed код, который предполагает дополнительное управление. Так или иначе, факт остается фактом — в случае C# выполняемого процессором кода на этапе сортировки получается заметно больше. Думаю его наличие и является основным фактором влияющим на разницу в производительности рассмотренного примера.

Другие сравнения производительности

Стоит отметить что есть и другие статьи, где измеряли производительность С++ и С#. Из тех, что попадались мне, самой содержательной показалась Head-to-head benchmark: C++ vs .NET

Автор этой статьи, в некоторых тестах «подыграл» C# запретив использовать SSE2 для С++, поэтому некоторые результаты С++ тестов с плавающей стали примерно в два раза медленнее чем были бы с включенным SSE2. В статье можно найти и другую критику методологии автора, среди которой очень субъективный выбор контейнера для теста в С++.

Однако не принимая в расчет тесты с плавающей точкой без SSE2, и делая поправку на ряд других особенностей методики тестирования, результаты, полученные в статье, стоит рассмотреть.

По результатам измерений можно сделать ряд интересных выводов:
1. Дебажный билд С++ заметно медленнее релизного, при том что разница дебажного и релизного билда С# менее существенна
2. Производительность C# под .Net Framework заметно (более 2х раз) выше чем производительность под Mono
3. Для С++ вполне можно найти контейнер который будет работать медленнее подобного контейнера для C#, и никакая оптимизация не поможет это побороть кроме как использование другого контейнера
4. Некоторые операции работы с файлом в С++ заметно медленнее аналогов в С#, однако их альтернативы столь же заметно быстрее аналогов С#.

Если подводить итоги и говорить о Windows, то статья приходит примерно к похожим результатам: код С# медленнее С++ кода, примерно на 10–80%

Много ли это -10…-80%?
Допустим при разработке на С# мы всегда будем использовать наиболее оптимальное решение, что потребует от нас очень неплохих навыков. И предположим мы будем укладываться в суммарные 10…80% потерь производительности производительности. Чем это нам грозит? Попробуем сравнить эти проценты с другими показателями характеризующими производительность.

Например, в 1990–2000 годах, одно-поточная производительность процессора росла за год примерно на 50%. А начиная с 2004 года темпы роста производительности процессоров упали, и составляли лишь 21% в год, по крайней мере до 2011 года.
image
A Look Back at Single-Threaded CPU Performance

Ожидаемые показатели роста производительности весьма туманны. Вряд ли в 2013 и 2014 годах был показан рост выше 21%, более того, вполне вероятно что в будущем рост ожидается еще ниже. По крайней мере, планы Intel по осваиванию новых технологий с каждым годом все скромнее…

Другое направление для оценки,- это энегроэффективность и дешевизна железа. Например тут можно увидеть, что говоря о топовом железе +50% одно-поточной производительности может в 2–3 раза удорожать стоимость процессора.

C точки же зрения энергоэфективности и шума — сейчас вполне реально собрать экономичный PC на пассивном охлаждении, однако придется пожертвовать производительностью, и эта жертва вполне может быть около 50% и более производительности относительно прожорливого и горячего, но производительного железа.

Как будет расти производительность процессоров точно не известно, однако по оценкам видно что в случае 21% роста производительности в год, приложение на С#, может отставать по производительности на 0.5–4 года относительно приложения на С++. В случае, например 10% роста, — отставание уже будет 1–8 лет. Однако, реальное приложение может отставать намного меньше, ниже рассмотрим почему.

Я пока не берусь оценивать рентабельность жертвы 10…80% производительности ради получения экономии на разработке. Очевидно, что эта рентабельность зависит от стоимости получения этих 10…80% другими способами (т.е. за счет железа). Однако наметившаяся тенденция показывает, что каждый следующий процент производительности железа будет дороже предыдущего, что, вполне вероятно, рано или поздно приведет к ситуации, когда дешевле будет получить дополнительную производительность оптимизируя код.

Какая же все-таки реальная оценка?

С одной стороны вы вряд-ли будете писать столь оптимальный чтобы всегда показывать максимальную производительность.

Но с другой стороны, что более важно: сколько runtime (времени выполнения) вашей программы будет занимать ваш код, а сколько код системы?

Например если код занимает 1% времени выполнения приложения или сервиса, то даже 10-ти кратное падение производительности этого кода, не очень сильно повлияло бы на скорость работы приложения, и удар по производительности был бы лишь около 10%.

Но совсем другое дело когда около 100% времени выполнения приложения занимает выполнение вашего кода, а не кода ОС. В этом случае вы легко можете получить и -80% и большие потери производительности.

Выводы
Конечно из всего выше написанного не следует что нужно срочно переходить с С# на С++. Во первых разработка на C# дешевле, а во вторых для ряда задач производительность современных процессоров избыточна, и даже оптимизация в рамках C# не является нужной. Но мне кажется важным обратить внимание на накладные расходы т.е плату за использование managedсреды, и оценку этих расходов. Очевидно что в зависимости от рыночных условий и возникающих задач, эта плата может оказаться значимой. Другие аспекты сравнения С# и С++, можно найти в моей предыдущей статье Выбор между C++ и C#.

© Habrahabr.ru