Indrekis
Indrekis

Categories

  • comp_eng
  • cpp

Tags

  • методичка
  • C++
  • онлайн інструменти
  • прикол

Прикол

Люблю С++, використовую її цілу вічність, викладаю тривалий час. Звичайно, усвідомлюю недоліки – як то кажуть, “ти змушуєш мою голову битися частіше”, періодами за це ненавиджу. Але, все ж, значна частина складнощів – не через саму мову. Свій вклад дає давня історія, багаж зворотної сумісності і безлічі застарілих практик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 щось-там запідозрила.

Слід пам’ятати про певну умовність такого аналізу, але, в цілому, можна припустити, що різниця від перестановки команд навряд чи може бути виміряною на практиці.

Подробиці для функції 1 та функції 2 – за посиланнями.

Однак, з -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.” – не те щоб в методологіях не було багато міфологій…

Виноски

  1. Останніх особливо важко позбутися – через безліч згадок в Інтернеті, відсутність потреби оновлювати свої знання в багатьох досвідчених розробників, та й через згадану зворотну сумісність. 

  2. Вимірювати реальну продуктивність отриманого коду, за допомогою іншого інструменту за тим посиланням – Quick C++ Benchmark, таки полінувався. 

  3. Щоб не заінлайнило абощо. 

  4. Ще одна ілюстрація – в будь-якому випадку, оптимізувати її компілятору простіше. 

  5. Видається, так виглядають результати поспіху разом із неуважністю, коли редактор пропонує автовиправлення. Часто бачу схоже в коді. Постійно жартую з того приводу: “Я буду говорити лише в присутності свого авокадо.”