Прикол
Люблю С++, використовую її цілу вічність, викладаю тривалий час. Звичайно, усвідомлюю недоліки – як то кажуть, “ти змушуєш мою голову битися частіше”, періодами за це ненавиджу. Але, все ж, значна частина складнощів – не через саму мову. Свій вклад дає давня історія, багаж зворотної сумісності і безлічі застарілих практик1. Однак, підозрюю, вагому роль відіграє також щось схоже на переляк – внаслідок заплутаності через складність мови і безлічі правил.
Наштовхнув мене на цю думку код, який трапився в дипломній роботі мого студента – дуже потужного.
Звичайно, код проіснував недовго, ймовірно, до його появи доклалася втома і поспіх, але все ще вражає – до чого можна дійти. Це навіть веселіше, ніж на COBOL писати.
Отож, нехай існує певний об’єкт commands_hm_m
, який повертає посилання на свій вміст, проіндексований переданими аргументами, та такі змінні:
std::array<size_t, 2> instruction_pointer, begin;
Бачу, що з ними працює такий код:
std::array<size_t, 2> ip_position;
std::copy(instruction_pointer.begin(),
instruction_pointer.end(),
ip_position.begin());
std::transform(ip_position.begin(), ip_position.end(),
ip_position.begin(),
[n = 0, this](size_t num)mutable {
return num - this->begin[n++];
});
commands_hm_m(ip_position[1], ip_position[0])++;
Довго медитую над ним і через десяток кілька хвилин розумію – мало бути так:
++commands_hm_m(instruction_pointer[1] - begin[1], instruction_pointer[0] - begin[0]);
Залишаю на розсуд читачів – а як так вийшло? :-)
Випробування
Вирішив подивитися, як на це реагують компілятори, скориставшись Godbolt Compiler Explorer-ом2.
Звертання до складного об’єкту земулював викликом не визначеної функції3. Отож, код виглядає так:
#include <cstdlib>
#include <array>
#include <algorithm>
int& commands_hm_m(size_t a, size_t b);
void fn1(const std::array<size_t, 2>& instruction_pointer, const std::array<size_t, 2>&begin){
std::array<size_t, 2> ip_position;
std::copy(instruction_pointer.begin(),
instruction_pointer.end(),
ip_position.begin());
std::transform(ip_position.begin(), ip_position.end(),
ip_position.begin(),
[n = 0, &begin](size_t num)mutable {
return num - begin[n++];
});
commands_hm_m(ip_position[1], ip_position[0])++;
}
void fn2(const std::array<size_t, 2>& instruction_pointer, const std::array<size_t, 2>&begin) {
++commands_hm_m(instruction_pointer[1] - begin[1], instruction_pointer[0] - begin[0]);
}
Випробовував:
- GCC 13.2,
- Clang 16.0.0,
- MSVC 19.35,
Для GCC i Clang використовував -O1 та -O3, (оскільки -O0 нецікаве), для MSVC – /O2 та /O1, оптимізувати за швидкістю та оптимізувати за розміром, відповідно.
Лінк на код на Compiler explorer, із налаштуваннями компіляторів, що використовувалися. Асемблерний код далі брався звідти, іноді – з незначними редагуваннями, для кращої читабельності, але без змін реальних команд.
GCC
GCC приємно здивував – і взагалі сильно здивував!
І для -O1 і для -O3 він згорнув обидві функції в практично ідентичний код. Зрозумів, що мав на увазі автор та оптимізував!
; Both fn1 and fn2, /O1
mov rax,rsi
sub rsp,0x8
mov rsi,QWORD PTR [rdi]
mov rdi,QWORD PTR [rdi+0x8]
sub rsi,QWORD PTR [rax]
sub rdi,QWORD PTR [rax+0x8]
; For call -- removed syntactic noise
call { commands_hm_m(unsigned long, unsigned long) }
add DWORD PTR [rax],0x1
add rsp,0x8
ret
Для -O3 – код фактично збігається, хіба переставлено перших дві команди.
Clagn
Clagn справився трішки гірше. Із -O3 він все ще зрозумів, що мав на увазі програміст:
; fn1:
push rax
mov rax,QWORD PTR [rdi]
mov rdi,QWORD PTR [rdi+0x8] ; 1A
sub rax,QWORD PTR [rsi] ; 2A
sub rdi,QWORD PTR [rsi+0x8] ; 1B
mov rsi,rax ; 2B
; For call -- removed syntactic noise
call { commands_hm_m(unsigned long, unsigned long) }
inc DWORD PTR [rax]
pop rax
ret
; fn2:
push rax
mov rax,QWORD PTR [rdi]
mov rdi,QWORD PTR [rdi+0x8] ; 1A
sub rdi,QWORD PTR [rsi+0x8] ; 1B
sub rax,QWORD PTR [rsi] ; 2A
mov rsi,rax ; 2B
; For call -- removed syntactic noise
call { commands_hm_m(unsigned long, unsigned long) }
inc DWORD PTR [rax]
pop rax
ret
Функції не абсолютно тотожні – переставлено віднімання, але у решті повністю збігаються.
Виглядає, що fn2 може бути трішки повільнішою, через гірше розплутані ланцюжки залежностей – команди 1A та 1B йдуть підряд, як і 2A та 2B, конвеєр процесора може на тому підгальмовувати.
Припускаю, через більший об’єм роботи, компілятору бракло якогось ресурсу (часу?) до-оптимізувати.
Однак, аналіз з використанням uiCA – The uops.info Code Analyzer, який використовує багато різних утиліт мікроархітектурного аналізу, для процесорів від Skylake до Rocket Lake, не показав різниці, хіба що (старенька) IACA 2.3 щось-там запідозрила.
Слід пам’ятати про певну умовність такого аналізу, але, в цілому, можна припустити, що різниця від перестановки команд навряд чи може бути виміряною на практиці.
Однак, з -O1 Clang справився гірше. Код другої, компактнішої, функції, збігається із отриманим для -O34. А для першої отримано таке:
; fn1, -O1
sub rsp,0x18
movups xmm0,XMMWORD PTR [rdi]
movaps XMMWORD PTR [rsp],xmm0
xor eax,eax
nop DWORD PTR [rax]
trloop:
mov rcx,QWORD PTR [rsi+rax*8]
sub QWORD PTR [rsp+rax*8],rcx
lea rcx,[rax+0x1]
mov rax,rcx
cmp rcx,0x2
jne trloop
mov rsi,QWORD PTR [rsp]
mov rdi,QWORD PTR [rsp+0x8]
; For call -- removed syntactic noise
call { commands_hm_m(unsigned long, unsigned long) }
inc DWORD PTR [rax]
add rsp,0x18
ret
Виглядає цей код жахливо. Хоча, якщо подивитися з допомогою uiCA, різні утиліти стверджують, що асимптотично – коли такий код виконується раз за разом, він може бути повільнішим десь між десятком і сотнею відсотків.
MSVC
А ось MSVC справився зовсім погано. Для компактнішої функції, і з врахуванням різних ABI, результат збігається з іншими розглянутими компіляторами:
; fn2, і для /O2 і для /O1:
sub rsp, 40
mov rax, rdx
mov rdx, QWORD PTR [rcx]
mov rcx, QWORD PTR [rcx+8]
sub rdx, QWORD PTR [rax]
sub rcx, QWORD PTR [rax+8]
call commands_hm_m(unsigned __int64,unsigned __int64)
inc DWORD PTR [rax]
add rsp, 40
ret 0
А для більшої отримано такий жах:
; fn1, для /O2
push rbx
sub rsp, 64
mov rax, QWORD PTR __security_cookie
xor rax, rsp
mov QWORD PTR __$ArrayPad$[rsp], rax
mov rbx, rdx
mov r8d, 16
mov rdx, rcx
lea rcx, QWORD PTR ip_position$[rsp]
call memmove
xor ecx, ecx
lea rax, QWORD PTR ip_position$[rsp]
mov r8d, ecx
npad 11
$LL51@fn1:
movsxd rcx, ecx
inc r8d
mov rdx, QWORD PTR [rbx+rcx*8]
mov ecx, r8d
sub QWORD PTR [rax], rdx
add rax, 8
lea rdx, QWORD PTR ip_position$[rsp+16]
cmp rax, rdx
jne SHORT $LL51@fn1
mov rdx, QWORD PTR ip_position$[rsp]
mov rcx, QWORD PTR ip_position$[rsp+8]
call { commands_hm_m(unsigned __int64,unsigned __int64) }
inc DWORD PTR [rax]
mov rcx, QWORD PTR __$ArrayPad$[rsp]
xor rcx, rsp
call __security_check_cookie
add rsp, 64
pop rbx
ret 0
Аналіз uiCA за посиланням, там все жахливо, хоча більше, ніж вдвічі-втричі – не мало б сповільнитися.
Навіть якщо заборонити код захисту від переповнення буферу, з яким пов’язана функція __security_init_cookie(), (ключем /GS-), особливо краще не стає:
; fn1, для /O2 /GS-
push rbx
sub rsp, 48
mov rbx, rdx
mov r8d, 16
mov rdx, rcx
lea rcx, QWORD PTR ip_position$[rsp]
call memmove
xor ecx, ecx
lea rax, QWORD PTR ip_position$[rsp]
mov r8d, ecx
npad 10
$LL51@fn1:
movsxd rcx, ecx
inc r8d
mov rdx, QWORD PTR [rbx+rcx*8]
mov ecx, r8d
sub QWORD PTR [rax], rdx
add rax, 8
lea rdx, QWORD PTR ip_position$[rsp+16]
cmp rax, rdx
jne SHORT $LL51@fn1
mov rdx, QWORD PTR ip_position$[rsp]
mov rcx, QWORD PTR ip_position$[rsp+8]
call { commands_hm_m(unsigned __int64,unsigned __int64) }
inc DWORD PTR [rax]
add rsp, 48
pop rbx
ret 0
Підсумок
- Я й сам колись любив говорити таким канцеляритом: “має місце присутність відсутності наявності”, але, все ж, не потрібно так писати код.
- Та й боятися простих механізмів мови – конструкторів копіювання, до прикладу, не варто. Розуміти їх потрібно, звичайно.
- Сучасні компілятори вражають!
- GCC зрозумів задум автора навіть із не дуже агресивною оптимізацією.
- Clang потребував трохи більше зусиль, але теж справився.
- А ось MSVC– ні.
- Навіть якщо компілятор зміг оптимізувати код – компілюватиме він його довше. Для сучасного С++ – це важливо.
- Однак, один такий тест не може бути критерієм оцінювання компіляторів:
- Для інших задач результати можуть сильно відрізнятися, аж до протилежних.
- Зокрема, провал MSVC тут не означає, що це поганий компілятор. Можливо, у нього є якісь причини, пов’язані з особливостями Windows або зворотної сумісності.
- Хоча, з моєї практики, він справді трохи поступається GCC в оптимізації, все ж, різниця невелика.
До рівня попереднього прикладу, де для перевірки нульового біта число перетворювали в стрічку і дивилися, чи є там літера 1, цей, все ж, не дотягує – там лише безпосередня втрата продуктивності складала 20-30 раз.
Інші дипломні приколи
Того року з дипломами було весело – зокрема, зацитую найкумедніші хибодруки5.
- “Ghoul of the thesis” – “упир диплому”?
- “Threads liberty for creating threads” – свобода, це важливо… Але я підозрюю, мало бути library.
- Всюди вжито poor замість pure.
- “A custom thread pull was developed and interrogated for thread management and work schedule.” – як на людей війна впливає…
- “There is no master key solutions or mythologies.” – не те щоб в методологіях не було багато міфологій…
Виноски
-
Останніх особливо важко позбутися – через безліч згадок в Інтернеті, відсутність потреби оновлювати свої знання в багатьох досвідчених розробників, та й через згадану зворотну сумісність. ↩
-
Вимірювати реальну продуктивність отриманого коду, за допомогою іншого інструменту за тим посиланням – Quick C++ Benchmark, таки полінувався. ↩
-
Щоб не заінлайнило абощо. ↩
-
Ще одна ілюстрація – в будь-якому випадку, оптимізувати її компілятору простіше. ↩
-
Видається, так виглядають результати поспіху разом із неуважністю, коли редактор пропонує автовиправлення. Часто бачу схоже в коді. Постійно жартую з того приводу: “Я буду говорити лише в присутності свого авокадо.” ↩