Порівняння продуктивності С++ і C#

Існують різні думки щодо продуктивності С++ і C#.

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

З іншого боку, вагомим аргументом є те, що С++ компілюється безпосередньо в машинний код і працює з мінімально можливою кількістю хелперів і прошарків.

Зустрічаються і думки про те, що продуктивність коду вимірювати не правильно, бо мікро рівень не характеризує продуктивність на макро рівні. (я звичайно погоджуся з тим що на макрорівні можна зіпсувати продуктивність, але навряд чи погоджуся з тим, що продуктивність на макро-рівні не складається з продуктивності на мікро-рівні)

Траплялися і твердження про те, що код на С++ приблизно в десять разів швидше коду на С#.

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


Тест, який виконаний у цій статті

Мені хотілося виконати самий примітивний тест, який покаже різницю між мовами на мікро-рівні. У тесті пройдемо повний цикл операцій з даними, створення контейнера, заповнення, обробка та видалення, тобто як зазвичай і буває в додатках.
Будемо працювати з даними типу int, щоб зробити їх обробку максимально ідентичною. Порівнювати будемо тільки релизные білди дефолтної конфігурації використовуючи Visual Studio 2010.

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

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

Код тесту
С++ HeapArray # HeapArray fixed tmp
Як ви можете переконатися, код досить простий і майже ідентичний. Оскільки в C# не можна явно виконати видалення, час виконання якого ми хочемо виміряти, замість видалення будемо використовуватиitems = null; GC.Collect(); за умови що нічого крім контейнера ми (в нашому прикладі) не створювали, GC.Collect видалити мав би теж тільки контейнер, тому думаю це досить адекватна заміна delete[] items.

Оголошенняint tmp; за циклом у випадку C# економить час, тому розглянута саме така варіація тесту для випадку C#.

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

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



З таблиць видно що:
1. найшвидша З# реалізація працює повільніше найшвидшою 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 рази підвищувати вартість процесора.

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

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

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

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

З одного боку ви навряд чи будете писати настільки оптимальний щоб завжди показувати максимальну продуктивність.

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

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

Але зовсім інша справа коли близько 100% часу виконання програми займає виконання вашого коду, а не коду ОС. У цьому випадку ви легко можете отримати і -80% і великі втрати продуктивності.

Висновки
Звичайно з усього вище написаного не слід що потрібно терміново переходити з З# на С++. По перше розробка на C# дешевше, а по друге для ряду завдань продуктивність сучасних процесорів надлишкова, і навіть оптимізація в рамках C# не є потрібною. Але мені здається важливим звернути увагу на накладні витрати тобто плату за використання managedсередовища, і оцінку цих витрат. Очевидно, що в залежності від ринкових умов і виникаючих завдань, ця плата може виявитися важливою. Інші аспекти порівняння З#, С++, можна знайти в моїй попередній статті Вибір між C++ і C#.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.