Порівняльний аналіз компіляторів С + + (исходники), Різне, Програмування, статті

На жаль, вибір компілятора часто обумовлений, знову-таки, ідеологією і міркуваннями на зразок “його всі використовують”. Звичайно, середа розробки Microsoft Visual C + + декілька зручніша, ніж у портировано gcc – але це ж зовсім не означає, що реліз свого продукту ви повинні компілювати з використанням MSVC + +. Використовуйте оболонку, компілюйте проміжні версії на MSVC + + (до речі, час компіляції у нього значно менше, ніж у gcc), але реліз можна зібрати з використанням іншого компілятора, наприклад від Intel. І, залежно від компілятора, можна отримати приріст в продуктивності на 10% просто так, на рівному місці. Але який “правильний” компілятор вибрати, щоб він згенерував максимально швидкий код? На жаль, однозначної відповіді на це питання немає – одні компілятори краще оптимізують віртуальні виклики, інші – краще працюють з пам’яттю.


Спробуємо визначити, хто в чому сильний серед компіляторів для платформи Wintel (x86-процесор + Win32 ОС). У забігу беруть участь компілятори Microsoft Visual C + + 6.0, Intel C + + Compiler 4.5, Borland Builder 6.0, MinGW (портований gcc) 3.2.


Порядок тестування


Як перевірити, наскільки ефективний код генерує компілятор? Дуже просто: потрібно вибрати декілька найбільш часто вживаних конструкцій мови і алгоритмів – і виміряти час їх виконання після компіляції різними компіляторами. Для більш точного визначення часу необхідно набрати статистику і виконати кожну конструкцію деяка кількість разів.


Начебто все просто – але тут починають виникати певні проблеми. Провести тестування деяких конструкцій (наприклад, звернення до поля об’єкту) не вдасться через оптимізації на рівні компілятора: рядки типу for (unsigned i = 0; i <10000000; i + +) dummy = obj-> dummyField; все компілятори просто викинули з кінцевого бінарного коду.


Другим неприємним моментом є те, що в результати всіх тестів неявно увійшло час виконання самого циклу “for”, в якому відбувається набір статистики. У деяких реалізаціях воно може бути дуже навіть істотним (наприклад, два такту на одну ітерацію порожнього for для gcc). Виміряти “чисте” час виконання порожнього циклу вдалося не для всіх компіляторів – VC + + і Intel Compiler виконують досить хорошу “розкрутку” коду і виключають з кінцевого коду все порожні цикли, inline-виклики порожніх методів і т.д. Навіть конструкцію вигляду for (unsigned i = 0; i <16; i + +) dummy + +; VC + + реалізував як dummy + = 16;.


Наявність такої нетривіальної низькорівневої оптимізації наводить на думку про необхідність аналізу згенерованого коду на рівні асемблера. По-перше, це дозволить переконатися в тому, що ми дійсно виміряли те, що хотіли виміряти (а не оптимізований компілятором порожній цикл, з якого він викинув всі “зайві” виклики). По-друге, це дозволить більш точно визначити, чий код найбільш оптимальний, що істотно доповнить картину тестування.


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


Для вимірювання часу виконання тестів використовувався лічильник машинних тактів, доступний по команді процесора RDTSC, що дозволило не тільки порівняти час виконання великої кількості однотипних операцій, але й отримати наближене час виконання операції в тактах (друга величина є більш показовою і зручною для порівняння). Всі тести проводилися на Pentium III (700 МГц), параметри компіляції були встановлені в “-O2 -6” (оптимізація по швидкості + оптимізація під набір команд Pentium Pro). Крім того, для Borland Builder була додана опція – fast-call – передача параметрів через регістри (Intel Compiler, MSVC + + і gcc автоматично використовують передачу параметрів через регістри при використанні оптимізації за швидкістю).


Тестування було розділене на декілька незалежних частин. Перша – тестування швидкості роботи основних конструкцій мови (віртуальні виклики, прямі виклики і т.д.). Друга – тестування швидкості роботи STL. Третя – тестування менеджера пам’яті, що поставляється разом з компілятором. Четверта – розбір ассемблерного коду таких базових операцій, як виклик функції і побудови циклу. П’ята – порівняння часу компіляції і розміру виконуваного файлу.


Тестування швидкості роботи основних конструкцій мови


Перший тест дуже навіть простий, він полягає у вимірюванні швидкості прямого виклику (member call), віртуального виклику (virtual call), виклику статик-метода (дана операція повністю аналогічна виклику звичайної функції), створення об’єкту і видалення об’єкта з віртуальним деструктором (create object), створення / видалення об’єкта з inline-конструктором і деструкцією (create inline object), створення template “ного об’єкта (create template object). Результати тесту наведені в таблиці 1.


Перша цифра – це повний час, витрачений на тест (у мілісекундах); цифра в дужках – кількість тактів на одну команду.


Результати вийшли дуже навіть цікавими: перше місце зайняв Borland Builder, а ось gcc на виклику методів, особливо віртуальних, показав істотне відставання. По всій видимості – через бурхливого розвитку COM “а, де всі виклики віртуальні, розробникам” рідних “компіляторів під Win32 довелося максимально оптимізувати ці типи викликів. Іншим цікавим фактом є те, що добре оптимізувати створення об’єкта з inline-конструктором і деструкцією зміг, знову-таки, тільки Builder.


Звичайно, у MSVC + + також спостерігається невеликий приріст продуктивності, але пояснюється це тим, що MSVC + + дуже добре “розкручує” код і всі заглушки просто викидає. Тобто в тесті з inline-викликами MSVC + + визначив, що викликається метод є порожнім, і виключив його виклик. Після виключення виклику порожнього методу у нього залишився порожній цикл, який компілятор також викинув.


Borland ж у разі використання inline-конструктора робить inline не тільки виклик методу “Конструктор”, але і виділення пам’яті під об’єкт. Те ж саме робить Builder щодо деструктора. Цікаво зазначити, що з шаблонами Builder працює точно так само, як з inline-методами, чого абсолютно не скажеш про інші компіляторах.


Тестування STL


STL, як відомо, входить до ISO стандарт C + + і містить дуже багато корисного і чудово реалізованого коду, використання якого істотно полегшує життя програмістам. Звичайно, MCVC + +, gcc і Builder використовують різні реалізації STL – і результати тестування будуть сильно залежати від ефективності реалізації тих чи інших алгоритмів, а не від якості самого компілятора. Але, так як STL входить в ISO-стандарт, тестування цієї бібліотеки просто невіддільно від тестування самого компілятора.


Проводилося тестування тільки найбільш часто використовуваних класів STL: string, vector, map, sort. При тестуванні string “а вимірювалася швидкість конкатенації; для vector” а ж – час додавання елемента (Видалення не тестувалося, так як це просто тестування realloc “а, яке буде проведено нижче); для map” а вимірювалося час додавання елемента і швидкість пошуку необхідного ключа; для sort “а – час сортування. Так як Microsoft не рекомендує використовувати STL в VC + +, для порівняння було додано тестування конкатенації рядків на основі рідного класу VC + + для роботи з рядками CString і, щоб вже зовсім нікого не образити, то і рідного класу Builder “а – AnsiString. Результати, знову ж таки, виявилися дуже навіть цікавими (див. табл. 2)


Згідно з результатами, не рекомендований STL string працює в 12 разів швидше, ніж рідний CString Microsoft! Як тут в черговий раз не задуматися про практичність рекомендацій Microsoft … А ось просто приголомшливий результат на пошуку від Intel Compiler це результат оптимізації “нічого не робить коду” – пошук як такий він просто викинув з кінцевого бінарного коду. Не менш цікавий результат gcc – у всіх тестах, пов’язаних з виділенням пам’яті, gcc опинився на першому місці.


Тестування менеджера пам’яті


Як відомо, при виділенні пам’яті malloc рідко звертається безпосередньо до системи – і використовує замість цього свою внутрішню структуру для динамічного виділення пам’яті і зміни розміру вже виділеного блоку. Швидкість роботи цього внутрішнього менеджера може вельми істотно впливати на швидкість роботи всього програми. Тестування менеджера пам’яті було розбито на дві частини: у першій вимірювалася швидкість роботи пари malloc / free, а в другій – malloc / realloc, причому realloc повинен був виділити удвічі більший об’єм пам’яті, ніж malloc.


І знову швидше за всіх був Borland Builder C + +. Завдяки такій швидкій реалізації malloc “а він знаходиться на першому місці і за швидкістю створення / видалення об’єктів – та й на тестах STL, пов’язаних зі зміною розміру блоку пам’яті, бігає достатньо швидко.


Розбір ассемблерного коду якихось базових операцій


Для аналізу використовувався досить простий код на С + +:


void dummyFn1(unsigned);
void dummyFn2(unsigned aa) {
for (unsigned i=0;i<16;i++) dummyFn1(aa);
}


А тепер подивимося, у що цей шматок коду компілює MSVC + + (приводиться тільки текст необхідної функції):


?dummyFn2@@YAXI@Z PROC NEAR
push esi
push edi
mov edi, DWORD PTR _aa$[esp+4]
mov esi, 16
$L271:
push edi
call?dummyFn1@@YAXI@Z

add esp, 4
dec esi
jne SHORT $L271
pop edi
pop esi
ret 0
?dummyFn@@YAXI@Z
ENDP
Як видно, MSVC + + інвертований цикл і for (unsigned i = 0; i <16; i + +) у нього перетворився в unsigned i = 16; while (i -);, що дуже правильно з точки зору оптимізації - ми економимо на одній операції порівняння (Див. наступний лістинг), яка займає, як мінімум, 5 байт, і порушує вирівнювання. Звичайно, компілятор на свій розсуд поміняв порядок зміни змінної i, але в даному прикладі ми її використовуємо просто як лічильник циклу, тому така заміна цілком допустима.


А ось що видав Intel Compiler (взагалі-то, він спочатку взагалі повністю розвернув цикл, але після збільшення кількості ітерацій на порядок припинив займатися такою самодіяльністю):


?dummyFn2@@YAXI@Z PROC NEAR
$B1$1:
push ebp
push ebx
mov ebp, DWORD PTR [esp+12]
sub esp, 20
xor ebx, ebx
$B1$2:
mov DWORD PTR [esp], ebp
call?dummyFn1@@YAXI@Z
$B1$3:
inc ebx
cmp ebx, 16
jb $B1$2
$B1$4:
add esp, 20
pop ebx
pop ebp
ret
?dummyFn2@@YAXI@Z ENDP


По-перше, використовується прямий порядок циклу for, тому з’явилася додаткова команда порівняння “cmp ebx, 16”. А ось і дуже цікавий момент-перед початком циклу ми виділили на стеку необхідну кількість пам’яті плюс якийсь запас (“sub esp, 20”), а потім замість пари push reg; ..; add esp, 4;, як це робить MSVC + +, використовували одну команду копіювання. Крім того, використання регістра загального призначення ebx для лічильника циклу замість індексного esi, як в MSVC + +, додатково зменшує час виконання і розмір коду.


Borland Builder згенерував наступну конструкцію:


@@dummyFn2$qui proc near
?live16385@0:
@1:
push ebp
mov ebp,esp
push ebx
push esi
mov esi,dword ptr [ebp+8]
?live16385@16:
@2:
xor ebx,ebx
@3:
push esi
call @@dummyFn1$qui
pop ecx
@5:
inc ebx
cmp ebx,16
jb short @3
?live16385@32:
@7:
pop esi
pop ebx
pop ebp
ret
@@dummyFn2$qui endp


Якщо не вважати більшої кількості підготовчих операцій, то блок виклику власне функції є чимось середнім між MSVC + + і Intel Compiler: цикл використовується прямий і передача параметрів здійснюється за допомогою push reg;. Правда, є цікавий момент: замість add esp, 4 використовується pop ecx; що економить, як мінімум, 4 байти, – правда, через додаткового звернення до пам’яті команда “pop” може працювати повільніше, ніж складання.


Ну і, нарешті, gcc (зверніть увагу, gcc для асемблера використовує синтаксис AT & T):


__Z7dummy2Fnj:
LFB1:
pushl %ebp
LCFI0:
movl %esp, %ebp
LCFI1:
pushl %esi
LCFI2:
pushl %ebx
LCFI3:
xorl %ebx, %ebx
movl 8(%ebp), %esi
.p2align 4,,7
L6:
subl $12, %esp
incl %ebx
pushl %esi
LCFI4:
call __Z2dummyFn1j
addl $16, %esp
cmpl $15, %ebx
jbe L6
leal -8(%ebp), %esp
popl %ebx
popl %esi
popl %ebp
ret

 


Даний код є найгіршим з усіх наведених вище – gcc використовує прямий цикл плюс пару push esi; ..; add esp, 4 (це відбувається неявно в команді “addl $ 16,% esp”) для передачі параметрів; крім того, резервує місце на стеку прямо в циклі, а не поза ним, як це робить Intel Compiler. Крім того, абсолютно незрозуміло, навіщо резервувати місце на стеку, а потім використовувати команду push reg;. Єдиний приємний момент – це явне вирівнювання початку циклу по межі, чого не роблять інші компілятори – оскільки лінійка кеша сегменту коду досягає 32-х байт, то мітки початку циклів повинні бути вирівняні по межі 16 байт. На кожен байт, що виходить за межі кеша, процесор сімейства P2 витрачає 9-12 тактів.


Порівняння часу компіляції і розміру виконуваного файлу


Для виконання цього тесту використовувався все той же вихідний код, з якого були вилучені всі compiler-specific тести. Тестування виконувалося окремо для компіляції релізу і для налагоджувальної версії, розмір бінарного файлу вказаний тільки для релізу (див. табл. 4). Щоб виключити вплив файлового кеша, проводилися дві однакові компіляції підряд – час вимірювався по другій за допомогою команди “date” (виняток склав тільки Builder – він сам вимірює час компіляції).


Перше місце поділили Borland Builder і MSVC + +, а ось gcc – знову на останньому місці, як за швидкістю компіляції, так і за розміром бінарного файлу. Цікавим моментом є той факт, що час компіляції налагоджувальної версії у gcc і Builder “а вище часу компіляції релізу. Пояснюється це тим, що при компіляції налагоджувальної версії компілятору необхідно додати налагоджувальну інформацію, що істотно збільшує розмір об’єктного файлу – і, як наслідок, час роботи лінковщика.


Результати


Здавалося б, висновок про найефективнішому компіляторі напрошується сам собою – це Borland Builder C + +. Але не варто поспішати. Багато розробники вказують на помилки при формуванні коду у Borland Builder (Зокрема, при використанні посилань його поведінка стає непередбачуваним). Крім того, Borland Builder C + + явно успадковує багато чого від Delphi (один модифікатор виклику методу DYNAMIC чого вартий), в Внаслідок чого при компіляції абсолютно правильного С + + коду можуть виникати помилки (наприклад, відсутність множинного спадкоємства для VCL-класів; а всі нащадки від TObject є VCL-класами).


З іншого боку, найстабільнішим і “вилизаним” компілятором можна назвати gcc. Але швидкість виконання скомпільованій коду на ньому буде не надто високою. Причиною тому, ймовірно, існування gcc на багатьох платформах і, як наслідок, необхідність компілювання під ці платформи.


MSVC + + або Intel Compiler не мають явно виражених недоліків, так що їхні позиції приблизно рівні.


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


Схожі статті:


Сподобалася стаття? Ви можете залишити відгук або підписатися на RSS , щоб автоматично отримувати інформацію про нові статтях.

Коментарів поки що немає.

Ваш отзыв

Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

*